Skip to content

feat(go): one option type for record calls and probes - #1019

Merged
coderdan merged 3 commits into
mainfrom
feat/go-term-options
Oct 3, 2026
Merged

coderdan merged 3 commits into
mainfrom
feat/go-term-options

Conversation

@coderdan

@coderdan coderdan commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

In the Go SDK, a query against encrypted data works by deriving a probe (an index term) for the value you are looking for and comparing it with the terms stored beside each row. A probe only matches when it is derived under exactly the same encryption context as the stored term. Multi-tenant apps extend every field's context with the tenant id when writing rows, but the probe call had no way to take that same extension: developers rebuilt the context by hand, and any slip (a different integer type, a different nesting) silently returned zero rows with no error.

The record calls and the probe call now share one option type, Option. ExtendContext is passed to encrypt, decrypt and Term as the same value, so the three cannot drift apart.

Changes

  • stackencrypt options: RecordOption (now an interface rather than a function type) is what EncryptRecords, EncryptRecord, DecryptRecords and DecryptRecord accept. Option is a RecordOption that Cipher.Term accepts as well. ExtendContext returns an Option; WithPlan returns a RecordOption only, so handing it to Term does not compile. Several ExtendContext options on one call join in order, by one method shared by both sides, and the ExtendContext doc says so.
  • Cipher.Term: gains a variadic options parameter. One shared function applies an extension for both the record plan and the probe.
  • Docs: the package doc and the ExtendContext / Term docs explain that the extension should be held in one value and passed to every call, and why a part's type matters.
  • Tests: a unit test pins that a probe's context under ExtendContext equals the context the record plan sends for the same field, and differs from the unextended context and another tenant's. The guest input matrix gained a Term call with the option. Further tests check that two ExtendContext options on one call equal one option with both parts on the record plan and the probe, that WithPlan is not an Option, and that a bad extension part fails Term and both record directions with an error naming the part. The live records test writes rows for two tenants and checks a tenant's probe matches only that tenant's term, and an unextended probe matches neither.

Verification

  • scripts/go-binding-test.sh languages/golang (what mise run go:test and the macOS/Windows jobs run) passed with both guests built: gofmt, go vet, every package's tests, and the GOOS=linux GOARCH=386 vet.
  • golangci-lint run ./stackencrypt/...: 0 issues.
  • The live test (TestLiveRecordsAndTerms) was skipped locally because no STACK_ENCRYPT_TEST_* credentials are set. The new tenant-probe assertion against real ZeroKMS runs in tests-golang.yml.

No changeset: the Go module is not a published npm package and has no release process yet. No skill names the Go API.

Review notes

  • Start with record.go (the option types and extend) and cipher.go (Term).
  • The optional WithTenant alias was deliberately left out: ExtendContext already reads naturally and an alias would add a second spelling of the same thing.
  • RecordOption keeps its name and meaning but changes from a function type to an interface. That is a breaking change only for code that built one by hand, which the unexported recordOptions argument already prevented outside this module.

@changeset-bot

changeset-bot Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 97749aa

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Mutable byte-slice context parts remain aliased, allowing a saved option’s context to drift between calls.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Unifies Go record and probe options so tenant-scoped contexts remain consistent.

Changes:

  • Replaces RecordOption with shared Option/TermOption interfaces.
  • Adds context-extension support to Cipher.Term.
  • Adds documentation and tenant-isolation coverage.
File Description
stackencrypt/​record.go Defines shared options and context extension logic.
stackencrypt/​cipher.go Applies options to term probes.
stackencrypt/​client.go Updates client record methods.
stackencrypt/​doc.go Documents shared options.
stackencrypt/​unit_test.go Tests matching contexts.
stackencrypt/​live_test.go Tests tenant-isolated terms.
stackencrypt/​guest_test.go Covers guest option encoding.
stackencrypt/​export_test.go Updates test helper types.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread languages/golang/stackencrypt/record.go Outdated
@coderdan
coderdan marked this pull request as ready for review October 2, 2026 21:19
@coderdan
coderdan requested a review from a team as a code owner October 2, 2026 21:19
@coderdan
coderdan requested a review from auxesis October 2, 2026 21:20
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-02T21:23:26.829212Z bf3cf24 Draft marked ready
🔒 Security Review ✅ Completed 2026-10-02T21:24:36.861651Z bf3cf24 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cipherstash-bot cipherstash-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation: 🟡 merge after changes

The change is sound. One item must change before merging. Add a test for the error that extend returns from Cipher.Term. ExtendContext validates nothing. That error is the only check on a part of the wrong type, and no test exercises it. Everything else can wait for a follow-up pull request.

I checked two things and am not reporting them. The byte-slice copy in ExtendContext is correct. vcffi.Marshal builds a fresh buffer, so defer wipe(encodedContext) in Term does not reach the option's own bytes. The same gap one layer down, in NewContext and Context.With, is the subject of #1022 in this stack.

Other findings not posted as comments

  • languages/golang/stackencrypt/cipher.go:98 — a probe still writes the field's own context by hand. A typo there matches no rows and reports no error. ExtendContext removes the hand-written tenant extension, which is the larger half of the problem. But Term still takes context Context from the caller. The record call instead builds each field's context from the plan (record.go:428, NewContext(f.context)). A probe with MustContext("users/emails") against a field tagged context=users/email derives a different term. Plan.Fields() already exposes each field's Context string, so a caller can read it today. A small accessor such as Plan.FieldContext("Email") would make that the obvious path. This is beyond the scope of this pull request.
How this review was made
Agent Model Review type Result
claude claude-opus-5 test-gap 3 found, 3 posted
claude claude-opus-5 golang 4 found, 3 posted
codex gpt-5.6-terra test-gap 1 found, 1 posted
codex gpt-5.6-terra golang 0 found, 0 posted

Synthesis: claude-opus-5 merged the findings, removed duplicates and dropped findings it could not confirm in the code. 1 posted finding(s) were raised by two or more models.

Plain language: claude-opus-5 read every comment as a new reader would. 1 comment(s) had a problem that stopped the reader acting; it rewrote 1. It also rewrote the review body.

Stack: 0 earlier and 1 later pull request(s) (:asterisk: #1019, #1022). *️⃣ marks this pull request.

Context loaded: the description, 0 linked issue(s) and 5 discussion entries.

Comment thread languages/golang/stackencrypt/cipher.go
Comment thread languages/golang/stackencrypt/record.go Outdated
Comment thread languages/golang/stackencrypt/unit_test.go Outdated
Comment thread languages/golang/stackencrypt/record.go Outdated

@auxesis auxesis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderdan nice work, thanks for tackling this.

There's some review feedback to be fixed before merge, but giving you an approval in advance.

Please hold off merging this until I have finished migrating the Go bindings and Rust crates into this repo.

I will give you a heads up when it's safe to merge.

@coderdan
coderdan added this pull request to stack #1024 October 2, 2026 22:02
@coderdan
coderdan force-pushed the feat/go-term-options branch from b168206 to 11bbba8 Compare October 2, 2026 23:59
A tenant-scoped query probe had to be assembled by hand. ExtendContext was
a RecordOption, so EncryptRecords and DecryptRecords took it and Term did
not: the caller rebuilt the field's context with Context.With, re-typing
what the plan already knew, and any slip (uint64(7) on write and 7 on read,
or a different nesting) produced a valid term in a different domain. The
query returned nothing and no error, which is the failure ADR-0004 warns
about.

RecordOption becomes Option, accepted by every record call, and TermOption
is the subset Cipher.Term also accepts. ExtendContext returns a TermOption,
so one value serves encrypt, decrypt and probe; WithPlan returns an Option
only, so passing it to Term does not compile. One function, extend, applies
an extension for both the plan and the probe, and a unit test pins that the
two produce the same context. The live records test now checks a tenant's
probe matches only that tenant's rows.
Cloning the []any only copied the slice of parts; a []byte part still
pointed at the caller's buffer, so an option held across calls, which is
what this API asks for, would extend by whatever that buffer held at each
call. The option now owns a copy of every byte part, and a test mutates the
source buffer between applications to pin it.
The type named Option was the narrow one: it served only the record
calls, while TermOption served those and Cipher.Term too. The broad name
now goes to the broad type. RecordOption is what only a record call
takes (WithPlan); Option is a RecordOption that Cipher.Term accepts as
well (ExtendContext). Cipher.Term takes ...Option and still refuses a
plan at compile time.

Several ExtendContext options on one call join in order, so
ExtendContext(a), ExtendContext(b) is the context ExtendContext(a, b)
gives. That rule is now written in the ExtendContext doc, with the
warning that follows from it: an extension given twice extends twice,
and a probe built with it once matches none of those rows. applyRecord
and applyTerm both call one appendTo method, so the record side and the
probe side cannot combine options by different rules.

Tests: TestSeveralExtensionsJoinInOrder applies two options to one
record plan and one probe and checks both equal the one-option context;
TestTermExtensionMatchesRecordFieldContext now also checks WithPlan is
not an Option; TestGuestRefusesMalformedInputsBeforeState gains bad
extension parts on Term, a record write and a record read, and
TestBadExtensionPartFailsTheCall checks those calls fail because of the
part, which tells a dropped error apart from a guest refusal.
@coderdan
coderdan force-pushed the feat/go-term-options branch from 11bbba8 to 97749aa Compare October 3, 2026 06:32
@coderdan
coderdan merged commit 39a341f into main Oct 3, 2026
31 checks passed
@coderdan
coderdan deleted the feat/go-term-options branch October 3, 2026 06:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants