Skip to content

feat(golang)!: the Go SDK: stash tags, stashgen and generated Encrypt/Decrypt - #1094

Merged
coderdan merged 30 commits into
mainfrom
feat/go-stashgen
Oct 7, 2026
Merged

coderdan merged 30 commits into
mainfrom
feat/go-stashgen

Conversation

@coderdan

@coderdan coderdan commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Go applications can now declare encryption on ordinary structs using stash tags and generate typed Encrypt, Decrypt, and Fields helpers with stashgen. The generated code handles encryption plans internally, so application code works with its own record types. This replaces the previous value and record APIs and renames the Go packages from stackencrypt to encrypt and stackauth to auth.

//go:generate go tool stashgen -type User
type User struct {
    _     struct{} `stash:"context=users"`
    ID    int64    `stash:"id,passthrough"`
    Email string   `stash:"email,encrypt,index=equality;match"`
}

encrypted, err := users.Encrypt(ctx, cipher, people)
people, err := users.Decrypt(ctx, cipher, encrypted)

The batch encryption call uses one request to ZeroKMS, CipherStash's key-management service.

A second round of commits (2026-10-07) answers the three review findings below: a row can now be bound to its own context (context_field), the deterministic test engine reports a wrong context the way production does, and the code stashgen writes from -for and from a policy is executed against the engine, not only compiled.

Changes

  • Generator: Adds the stashgen command and library, struct-tag grammar, deterministic generated output, and checks against the embedded Rust encryption engine. Invalid declarations report the affected field.
  • Generated API: Adds encrypted record types, encryption/decryption helpers, field accessors, single-column updates, caller context extensions, ciphertext and index types, and the runtime support used by generated code. Removes the previous value and record calls and runtime value exports.
  • Schema-driven declarations: Adds encrypt/policy for rules based on field metadata and encrypt/policy/protosource for metadata from Protocol Buffers descriptors. Both use the same declaration reader and code generator as struct tags.
  • Package migration: Renames the Go encryption and authentication packages and updates build tasks, workflows, repository documentation, and path guard tests.
  • CI and compatibility: Adds a deterministic test runtime and a check that go generate ./... leaves committed generated files unchanged. Generated Go code is checked against records encrypted by Rust.
  • Context field (new): stack-encrypt's data grammar, the guest and the Go SDK gain context_field. See "The context field" below.
  • Wrong-context error (new): The deterministic test engine refuses a wrong context as ErrForbidden, as ZeroKMS does. See "The deterministic engine's wrong-context error" below.
  • Generated code from -for and from a policy runs (new): See "Running every generator path" below.

The context field

What changed. A plan (the engine's saved declaration of how a record is encrypted) can take its context from one of its own fields. The Rust plan builder already had this (FieldsBuilder::context_field; the derive's #[stash(context_field)]), but the data grammar that bindings send had no spelling for it, and ADR-0007 wrongly listed it among the typed, Rust-only parts. Now:

  • stack-encrypt: the data plan gains a plan-level key, "context_field": "<field name>", beside the field specs. The named field is a passthrough of type string; its value in each record (a label such as "tenants/acme") is the context every other field is sealed under; each other field's "context" is its identity alone (["email"]), extended by the caller's parts as before. Without the key, nothing changes: the two-segment and shared-prefix rules hold and every existing plan seals the same bytes. record::decrypt and check_record take an optional expected context (the chain's open(record).context(expected)) and refuse a record stored under another with Error::ContextMismatch before any key is requested. dynamic::Value implements IntoLabel; LabelError gains NotText. ADR-0007 and the plan document carry a dated amendment.
  • Guest: se_decrypt_record's options object takes an optional context label, the expected context; a mismatch is a new shared status, STATUS_CONTEXT_MISMATCH (28), which Go reads as ErrContextMismatch.
  • Go SDK: the tag stash:"tenant,context_field" on a string field. The rules are the Rust derive's: one per struct, never beside context=, a string, no other part on the tag; stashgen refuses each at generate time, naming the field. Generated code declares it with gensupport.DeclareContextField. The field is stored as it is, in the clear and unauthenticated like a passthrough, so the row names its own context.

Design choices.

  • Grammar spelling: a plan-level "context_field" key rather than a marker inside the field's spec, because the context source is a property of the plan (the builder has exactly one), and a reserved top-level key keeps field specs unchanged. The key is reserved: a field named context_field is refused rather than read ambiguously.
  • Tag spelling: context_field, the engine's word, as the SDK principles require for engine behaviour a user writes.
  • Go decrypt API: one new call, cipher.Context("tenants/acme"), mirroring Extend and the Rust chain's .context(..). It returns a cipher that names the context every row through it is under: Decrypt refuses a row stored under another (ErrContextMismatch, before any key is retrieved), Encrypt refuses a value whose field says otherwise before anything is sent, a query derives its term under the label (a type with a context field has no context of its own, so a query without one is ErrEncoding naming the call), and a field's own Encrypt seals one value under it. This was chosen over a DecryptExpecting variant because SDK principle 4 says what changes from one caller to the next attaches to the cipher, not to each call, and because a query on such a type needs the same label. Without a Context, each row's context comes from the row; the *Client opens each row under what it stores.
  • Single-field Encrypt on a context-field type uses the cipher's label as the context, so a column update works; without a label it is refused.

The deterministic engine's wrong-context error

DeterministicSource::retrieve_keys refused a tag that was not the descriptor's with RetrieveKeyError::FailedRetrieval, which Go read as ErrNotFound. ZeroKMS refuses the same retrieve with HTTP 403, which Go reads as ErrForbidden, and the live suite asserts that. The source now refuses with RetrieveKeyError::RequestFailed of kind Forbidden, wrapping a small error that says what the 403 stands for; a stack-kms unit test holds the kind, and TestExtendIsPartOfTheIdentity asserts ErrForbidden, the same error the live suite asserts.

Running every generator path

Only the tag path's generated code was executed against the engine; -for output and policy output were compiled against a stub and compared as text. testmember.Member (a struct with User's fields and no tags) is now declared for with -for in testusers (EncryptMember) and generated from a policy into testpolicy by internal/genmember, both through go generate, so the CI drift check covers them. A new test seals the same values through all three paths, holds the -for and policy terms to the tag path's bytes, opens each path's rows through the cipher and the client and the tag path's rows through the other two, and derives probes through each path's Fields.

Verification

Run on the final head of this branch, 2026-10-07:

  • Rust: cargo fmt --all --check (clean after formatting); cargo clippy --workspace --all-targets --all-features -- -D warnings (clean); mise x --env test -- cargo nextest run -p stack-encrypt -p stack-kms --all-features (618 tests pass, including 10 new context-field unit tests, the two new fixture tests and the new stack-kms test); mise run doc and mise run test:doc (clean). The fixture gained only its context_field key; the existing records are byte-for-byte as committed.
  • Guest: mise run wasm:guest:build, wasm:guest:build:deterministic and wasm:guest:test (clippy for wasm32, 50 native tests pass, including the new context-field test). Fuzz corpora: mise run fuzz:check-record -- -runs=0 (3 runs) and mise run fuzz:plan-build -- -runs=0 (5 runs), both clean.
  • Go: from languages/golang, go generate ./... then git status --porcelain is empty; go vet ./...; CGO_ENABLED=0 go test ./... -count=1 (every package passes against the rebuilt deterministic guest, including the new context-field round trip, the fixture's context-field case, the three-path test and the tightened ErrForbidden assertion); golangci-lint run ./... (0 issues). The eight generator golden cases, including the new tenants case with a model, compile against the stub SDK.
  • Live coverage: not rerun; the credential-gated tests are unchanged.

Related

Review notes

  • Start with languages/golang/cmd/stashgen/README.md, then the generated encrypt/internal/testusers/user_stash.go, and the declaration-to-plan conversion in gensupport. For the context field, read dynamic::record's module docs ("A plan whose context is a field of the record"), then encrypt/internal/testusers/note_stash.go and encrypt/context_field_test.go.
  • Passthrough values stay in Go. The runtime interface cannot carry values such as time.Time, gorm.DeletedAt, or arbitrary driver.Valuer implementations. Generated code preserves these caller values directly; only encrypted fields, and the context field, are sent to the engine.
  • Opaque structs become one JSON document. Generated code serializes them as bytes for a single encrypted column. Supported fields are limited to JSON-compatible types; nested structs are rejected.
  • Deferred features: This PR rejects encrypt_into because the runtime has no EQL targets yet. It also leaves out the proposed go vet check for printing tagged structs and implementations of MatchOption and JSONOption. A policy cannot name a context_field yet; a policy always carries a Context.

@coderdan
coderdan requested a review from a team as a code owner October 6, 2026 09:05
@changeset-bot

changeset-bot Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: c1bb0dd

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

@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Too many files!

This PR contains 237 files, which is 137 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

Only developers with an assigned seat can use this organization's usage-based review budget, and seats here are assigned manually. Ask an admin to assign a seat, or change the review continuation mode in Billing.

Check out review usage here.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 94435027-1e7b-4d2a-956c-9d3a0d330b97
📥 Commits

Reviewing files that changed from the base of the PR and between 415b62c and c1bb0dd.

⛔ Files ignored due to path filters (6)
  • Cargo.lock is excluded by !**/*.lock
  • languages/golang/auth/guest/Cargo.lock is excluded by !**/*.lock
  • languages/golang/encrypt/guest/Cargo.lock is excluded by !**/*.lock
  • languages/golang/encrypt/policy/protosource/internal/testpb/classification.pb.go is excluded by !**/*.pb.go
  • languages/golang/encrypt/policy/protosource/internal/testpb/individual.pb.go is excluded by !**/*.pb.go
  • languages/golang/go.sum is excluded by !**/*.sum
📒 Files selected for processing (237)
  • .github/dependabot.yml
  • .github/workflows/tests-golang.yml
  • .gitignore
  • AGENTS.md
  • Cargo.toml
  • SECURITY.md
  • docs/plans/2026-10-04-plan-builder.md
  • languages/golang/.gitattributes
  • languages/golang/auth/README.md
  • languages/golang/auth/clientkey.go
  • languages/golang/auth/doc.go
  • languages/golang/auth/errors.go
  • languages/golang/auth/guest.go
  • languages/golang/auth/guest/.gitignore
  • languages/golang/auth/guest/Cargo.toml
  • languages/golang/auth/guest/src/abi.rs
  • languages/golang/auth/guest/src/auth.rs
  • languages/golang/auth/guest/src/headers.rs
  • languages/golang/auth/guest/src/host.rs
  • languages/golang/auth/guest/src/lib.rs
  • languages/golang/auth/guest/src/ops.rs
  • languages/golang/auth/guest/src/status.rs
  • languages/golang/auth/lock_unix.go
  • languages/golang/auth/lock_windows.go
  • languages/golang/auth/mount.go
  • languages/golang/auth/oauth2.go
  • languages/golang/auth/store.go
  • languages/golang/auth/store_test.go
  • languages/golang/auth/strategy.go
  • languages/golang/auth/strategy_test.go
  • languages/golang/auth/token.go
  • languages/golang/auth/transport.go
  • languages/golang/auth/wasm/README.md
  • languages/golang/cmd/stashgen/README.md
  • languages/golang/cmd/stashgen/main.go
  • languages/golang/cmd/stashgen/main_test.go
  • languages/golang/encrypt/README.md
  • languages/golang/encrypt/checker.go
  • languages/golang/encrypt/cipher.go
  • languages/golang/encrypt/ciphertext.go
  • languages/golang/encrypt/client.go
  • languages/golang/encrypt/clientkey.go
  • languages/golang/encrypt/context_field_test.go
  • languages/golang/encrypt/credentials.go
  • languages/golang/encrypt/credentials_test.go
  • languages/golang/encrypt/doc.go
  • languages/golang/encrypt/errors.go
  • languages/golang/encrypt/example/README.md
  • languages/golang/encrypt/example/main.go
  • languages/golang/encrypt/example/model.go
  • languages/golang/encrypt/example/user_stash.go
  • languages/golang/encrypt/export_test.go
  • languages/golang/encrypt/fixture_test.go
  • languages/golang/encrypt/generated_paths_test.go
  • languages/golang/encrypt/gensupport/codec.go
  • languages/golang/encrypt/gensupport/convert.go
  • languages/golang/encrypt/gensupport/declaration.go
  • languages/golang/encrypt/gensupport/export_test.go
  • languages/golang/encrypt/gensupport/field.go
  • languages/golang/encrypt/gensupport/gensupport.go
  • languages/golang/encrypt/gensupport/gensupport_internal_test.go
  • languages/golang/encrypt/gensupport/gensupport_test.go
  • languages/golang/encrypt/guest.go
  • languages/golang/encrypt/guest/.gitignore
  • languages/golang/encrypt/guest/Cargo.toml
  • languages/golang/encrypt/guest/src/abi.rs
  • languages/golang/encrypt/guest/src/config.rs
  • languages/golang/encrypt/guest/src/deterministic.rs
  • languages/golang/encrypt/guest/src/headers.rs
  • languages/golang/encrypt/guest/src/host.rs
  • languages/golang/encrypt/guest/src/lib.rs
  • languages/golang/encrypt/guest/src/ops.rs
  • languages/golang/encrypt/guest/src/options.rs
  • languages/golang/encrypt/guest/src/response.rs
  • languages/golang/encrypt/guest/src/status.rs
  • languages/golang/encrypt/guest/tests/native_ops.rs
  • languages/golang/encrypt/guest_test.go
  • languages/golang/encrypt/internal/genmember/main.go
  • languages/golang/encrypt/internal/testmember/member.go
  • languages/golang/encrypt/internal/testpolicy/doc.go
  • languages/golang/encrypt/internal/testpolicy/member_stash.go
  • languages/golang/encrypt/internal/testusers/account_stash.go
  • languages/golang/encrypt/internal/testusers/document_stash.go
  • languages/golang/encrypt/internal/testusers/everything_stash.go
  • languages/golang/encrypt/internal/testusers/kinds.go
  • languages/golang/encrypt/internal/testusers/kinds_stash.go
  • languages/golang/encrypt/internal/testusers/lookup_stash.go
  • languages/golang/encrypt/internal/testusers/memberstash_stash.go
  • languages/golang/encrypt/internal/testusers/note_stash.go
  • languages/golang/encrypt/internal/testusers/probe_stash.go
  • languages/golang/encrypt/internal/testusers/secret_stash.go
  • languages/golang/encrypt/internal/testusers/user_stash.go
  • languages/golang/encrypt/internal/testusers/users.go
  • languages/golang/encrypt/keyset.go
  • languages/golang/encrypt/kinds_test.go
  • languages/golang/encrypt/live_internal_test.go
  • languages/golang/encrypt/live_test.go
  • languages/golang/encrypt/memory_linux_test.go
  • languages/golang/encrypt/memory_test.go
  • languages/golang/encrypt/options.go
  • languages/golang/encrypt/options_test.go
  • languages/golang/encrypt/order_test.go
  • languages/golang/encrypt/policy/policy.go
  • languages/golang/encrypt/policy/policy_test.go
  • languages/golang/encrypt/policy/protosource/internal/testpb/doc.go
  • languages/golang/encrypt/policy/protosource/protosource.go
  • languages/golang/encrypt/policy/protosource/protosource_test.go
  • languages/golang/encrypt/records.go
  • languages/golang/encrypt/roundtrip_test.go
  • languages/golang/encrypt/runtime_test.go
  • languages/golang/encrypt/targets_test.go
  • languages/golang/encrypt/term.go
  • languages/golang/encrypt/testdata/cllw_order.txt
  • languages/golang/encrypt/transport.go
  • languages/golang/encrypt/unit_test.go
  • languages/golang/encrypt/wasm/README.md
  • languages/golang/go.mod
  • languages/golang/internal/factstest/factstest.go
  • languages/golang/internal/factstest/factstest_test.go
  • languages/golang/internal/guest/clientkey.go
  • languages/golang/internal/guest/doc.go
  • languages/golang/internal/guest/errors.go
  • languages/golang/internal/guest/memory.go
  • languages/golang/internal/guest/memory_test.go
  • languages/golang/internal/guest/status.go
  • languages/golang/internal/guesttest/probe.go
  • languages/golang/internal/record/fixture_test.go
  • languages/golang/internal/record/record.go
  • languages/golang/internal/record/record_test.go
  • languages/golang/stackencrypt/README.md
  • languages/golang/stackencrypt/cipher.go
  • languages/golang/stackencrypt/context.go
  • languages/golang/stackencrypt/doc.go
  • languages/golang/stackencrypt/example/README.md
  • languages/golang/stackencrypt/example/explicit/README.md
  • languages/golang/stackencrypt/example/explicit/main.go
  • languages/golang/stackencrypt/example/main.go
  • languages/golang/stackencrypt/export_test.go
  • languages/golang/stackencrypt/label.go
  • languages/golang/stackencrypt/label_test.go
  • languages/golang/stackencrypt/leaf.go
  • languages/golang/stackencrypt/live_test.go
  • languages/golang/stackencrypt/order_live_test.go
  • languages/golang/stackencrypt/plan/doc.go
  • languages/golang/stackencrypt/plan/fact.go
  • languages/golang/stackencrypt/plan/message.go
  • languages/golang/stackencrypt/plan/plan_test.go
  • languages/golang/stackencrypt/plan/plantest/compare.go
  • languages/golang/stackencrypt/plan/plantest/golden_test.go
  • languages/golang/stackencrypt/plan/plantest/plantest.go
  • languages/golang/stackencrypt/plan/plantest/plantest_internal_test.go
  • languages/golang/stackencrypt/plan/plantest/snapshot.go
  • languages/golang/stackencrypt/plan/plantest/testdata/TestPolicies/audits.golden
  • languages/golang/stackencrypt/plan/plantest/testdata/TestPolicies/individuals.golden
  • languages/golang/stackencrypt/plan/policy.go
  • languages/golang/stackencrypt/policy_plan_test.go
  • languages/golang/stackencrypt/record.go
  • languages/golang/stackencrypt/unit_test.go
  • languages/golang/stashgen/declaration.go
  • languages/golang/stashgen/doc.go
  • languages/golang/stashgen/emit.go
  • languages/golang/stashgen/engine.go
  • languages/golang/stashgen/enginetest/enginetest.go
  • languages/golang/stashgen/errors.go
  • languages/golang/stashgen/export_test.go
  • languages/golang/stashgen/generate.go
  • languages/golang/stashgen/golden_test.go
  • languages/golang/stashgen/imports.go
  • languages/golang/stashgen/load.go
  • languages/golang/stashgen/model.go
  • languages/golang/stashgen/model_test.go
  • languages/golang/stashgen/names.go
  • languages/golang/stashgen/policy.go
  • languages/golang/stashgen/policy_test.go
  • languages/golang/stashgen/read.go
  • languages/golang/stashgen/refusal_test.go
  • languages/golang/stashgen/stub_test.go
  • languages/golang/stashgen/tag.go
  • languages/golang/stashgen/tag_test.go
  • languages/golang/stashgen/testdata/cases/accounts/account.go
  • languages/golang/stashgen/testdata/cases/accounts/account_stash.go.golden
  • languages/golang/stashgen/testdata/cases/accounts/go.mod
  • languages/golang/stashgen/testdata/cases/contacts/contacts.go
  • languages/golang/stashgen/testdata/cases/contacts/contactstash_stash.go.golden
  • languages/golang/stashgen/testdata/cases/contacts/crm/contact.go
  • languages/golang/stashgen/testdata/cases/contacts/go.mod
  • languages/golang/stashgen/testdata/cases/documents/document_stash.go.golden
  • languages/golang/stashgen/testdata/cases/documents/documents.go
  • languages/golang/stashgen/testdata/cases/documents/go.mod
  • languages/golang/stashgen/testdata/cases/embedded/embedded.go
  • languages/golang/stashgen/testdata/cases/embedded/go.mod
  • languages/golang/stashgen/testdata/cases/embedded/patient_stash.go.golden
  • languages/golang/stashgen/testdata/cases/foreign/go.mod
  • languages/golang/stashgen/testdata/cases/foreign/individuals.go
  • languages/golang/stashgen/testdata/cases/foreign/individualstash_stash.go.golden
  • languages/golang/stashgen/testdata/cases/foreign/pb/individual.go
  • languages/golang/stashgen/testdata/cases/orders/go.mod
  • languages/golang/stashgen/testdata/cases/orders/order_stash.go.golden
  • languages/golang/stashgen/testdata/cases/orders/orders.go
  • languages/golang/stashgen/testdata/cases/orders/refund_stash.go.golden
  • languages/golang/stashgen/testdata/cases/tenants/go.mod
  • languages/golang/stashgen/testdata/cases/tenants/note_stash.go.golden
  • languages/golang/stashgen/testdata/cases/tenants/tenants.go
  • languages/golang/stashgen/testdata/cases/users/go.mod
  • languages/golang/stashgen/testdata/cases/users/model.go
  • languages/golang/stashgen/testdata/cases/users/user_stash.go.golden
  • languages/golang/stashgen/testdata/policy_individual_stash.go.golden
  • languages/golang/stashgen/testdata/stubgorm/go.mod
  • languages/golang/stashgen/testdata/stubgorm/gorm.go
  • languages/golang/stashgen/testdata/stubsdk/encrypt/encrypt.go
  • languages/golang/stashgen/testdata/stubsdk/encrypt/eql/eql.go
  • languages/golang/stashgen/testdata/stubsdk/encrypt/gensupport/gensupport.go
  • languages/golang/stashgen/testdata/stubsdk/go.mod
  • mise.toml
  • packages/stack-encrypt/CHANGELOG.md
  • packages/stack-encrypt/CONTEXT.md
  • packages/stack-encrypt/Cargo.toml
  • packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md
  • packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs
  • packages/stack-encrypt/src/descriptor.rs
  • packages/stack-encrypt/src/dynamic/record.rs
  • packages/stack-encrypt/src/dynamic/value.rs
  • packages/stack-encrypt/src/plan/mod.rs
  • packages/stack-encrypt/tests/common/mod.rs
  • packages/stack-encrypt/tests/fixtures/README.md
  • packages/stack-encrypt/tests/fixtures/label_segments.json
  • packages/stack-encrypt/tests/fixtures/record_lowering.json
  • packages/stack-encrypt/tests/record_lowering.rs
  • packages/stack-guest-abi/src/lib.rs
  • packages/stack-guest-abi/src/status.rs
  • packages/stack-kms/Cargo.toml
  • packages/stack-kms/src/key_source.rs
  • packages/stack-kms/src/lib.rs
  • scripts/__tests__/cargo-lock-freshness.test.mjs
  • scripts/__tests__/cargo-publish-opt-out.test.mjs
  • scripts/__tests__/crates-ci.test.mjs
  • scripts/go-binding-test.sh

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 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-06T09:13:10.722915Z 1670ebd PR opened
🔒 Security Review ✅ Completed 2026-10-06T09:14:40.767899Z 1670ebd PR opened
ℹ️ 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.

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Mutation testing (cargo-mutants, --in-diff, stack-auth + stack-encrypt)

caught missed unviable timeout
16 0 9 0

Every mutant in the changed lines was caught by a test.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1670ebdfd6

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread languages/golang/encrypt/gensupport/codec.go Outdated
Comment thread languages/golang/encrypt/gensupport/convert.go
@blacksmith-sh

This comment has been minimized.

coderdan added a commit that referenced this pull request Oct 6, 2026
Two Codex findings on PR #1094, both real, both the same shape: a value
the generator accepted and Encrypt sealed could never be decrypted.

Get sent every opened value through convert, whose switch knows the wire
kinds and a few slice types. A passthrough field is the Go value the
struct held, kept on the host, so a time.Time or a sql.NullTime — the
plan's own accounts example — reached convert and was refused. Get now
returns a value that already is a T before it converts anything.

An opaque struct came back from JSON into Values and each field went
through Get, so a map[string]string (JSON gives map[string]any), a type
defined over a scalar, a nested struct or a pointer could not be read,
although classify and readableInOpaque had accepted them. The opaque
value now crosses as a JSON document of a generated shape struct
(documentOpaque, with json tags for the declared names), and the
generated Value decodes it straight into that struct with gensupport.
Opaque, so every type encoding/json carries both ways comes back as it
was. The generator's rule is that: a field of an opaque struct may be a
scalar or a type defined over one, []byte, a slice or array, a map with
string or integer keys, a pointer, a struct whose fields are all
exported, or a type with its own MarshalJSON/UnmarshalJSON (time.Time); a
channel, an interface, a map keyed by a struct, or a struct JSON would
truncate is refused at generate time with the field named.

Outside an opaque struct the engine seals a composite as a tree of
leaves and a column holds one, so stashgen now refuses encrypt or index=
on a struct, slice or map field ("seals only as part of an opaque
struct") instead of letting Encrypt fail at run time. A type defined
over a scalar (type Email string) is accepted: the engine returns the
underlying type, and the generated Value reads it at that type and
converts, since Go has no way to do that generically without reflection.

The proof is in encrypt/internal/testusers: Account (time.Time and
sql.NullTime passthrough beside an indexed field), Kinds (one sealed
field of every scalar kind, defined types among them, and passthrough
fields of every type the codec cannot carry) and Everything (an opaque
struct with a field of every type JSON carries), each generated by the
real stashgen against the real guest and walked through Encrypt then
Decrypt over the deterministic build with reflect.DeepEqual.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
@coderdan

coderdan commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

The one failure in the live-credentials job, TestPlaintextDoesNotRemainInGuestMemoryAfterEncrypt, was a record with an empty Email under a match index. The engine refuses a text that produces no match tokens (an empty term would match every row), on the typed Rust path and the data plan alike, so it is not a lowering defect. 8b09837 makes the generated API name the row, field and index in that error, pins the behaviour in a hermetic test, documents what a match index needs in the stashgen README, and has the residency test encrypt a fully populated record. The branch is also rebased onto the current head of #1093.

@coderdan
coderdan requested review from cipherstash-bot and removed request for cipherstash-bot October 6, 2026 09:25

@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: 🔴 fix before merging

Fix the problems below and change two tests before merging. The other items can wait for a follow-up.

Fix these problems before merging:

  • Get cannot read back some field types that stashgen accepts.
  • A sealed slice or map field fails every Encrypt.
  • The live test TestPlaintextDoesNotRemainInGuestMemoryAfterEncrypt fails before it reaches its memory check.

Change these tests before merging:

  • TestRefusals runs only against a fake engine. That fake engine disagrees with the shipped engine.
  • The Rust fixture test skips on every error.

Not kept: a finding that the opaque path has no test for JSON numbers or base64 bytes. TestConvertStaysWithinAFamily in encrypt/gensupport/gensupport_internal_test.go (lines 141 to 155) already tests json.Number, base64 into []byte, and opaqueValues.

Other findings not posted as comments

  • Optional: GoName in languages/golang/encrypt/policy/protosource/protosource.go:89 does not match GoCamelCase in protoc-gen-go. For foo_1bar, sha256sum and _x, protoc-gen-go gives Foo_1Bar, Sha256Sum and XX. GoName gives Foo_1bar, Sha256sum and X. TestGoName expects the wrong value for foo_1bar. stashgen uses GoName only when a struct field has no protobuf name= tag. So a difference causes a wrong refusal, not a wrong field.
  • Optional: DeterministicSource in languages/golang/encrypt/guest/src/deterministic.rs is a second copy of the type in packages/stack-encrypt/tests/common/mod.rs. The two copies must derive the same bytes. If they do not, the Go fixture test cannot open the Rust records. Move the type into stack-kms under the test-support feature, next to FakeDataKeySource. Then import it in both places.
  • Optional: the enum branch of scalar (languages/golang/encrypt/policy/protosource/protosource.go:77) has no test, because the testpb messages declare no enum.
  • Optional: no test makes the whole-declaration check in guestEngine.Check (languages/golang/stashgen/engine.go:106) fail. So no test covers its FieldError with no field name.
  • Optional: the crate doc in packages/stack-guest-abi/src/lib.rs:34 names the path bindings/go/encrypt/guest. That path does not exist. The real path is languages/golang/encrypt/guest.
  • For information: the match index refuses an empty string, or text shorter than 3 characters. The whole batch then fails with ErrTerm. So a program with an optional or short value in a match field cannot encrypt that batch. encrypt/README.md, cmd/stashgen/README.md and encrypt/doc.go do not say this.
How this review was made
Agent Model Review type Result
claude claude-opus-5-5 test-gap 8 found, 7 posted
claude claude-opus-5-5 rust 3 found, 2 posted
codex gpt-5.6-terra test-gap 2 found, 1 posted
codex gpt-5.6-terra rust 0 found, 0 posted

Synthesis: claude-opus-5-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-5 read every comment as a new reader would. 5 comment(s) had a problem that stopped the reader acting; it rewrote 5. It also rewrote the review body.

Stack: 2 earlier and 0 later pull request(s) (#1090, #1093, *️⃣ #1094). *️⃣ marks this pull request.

Context loaded: the description, 1 linked issue(s) and 3 discussion entries.

Comment thread languages/golang/encrypt/gensupport/convert.go
Comment thread languages/golang/encrypt/records.go
Comment thread languages/golang/stashgen/refusal_test.go
Comment thread languages/golang/encrypt/live_test.go
Comment thread languages/golang/encrypt/fixture_test.go
Comment thread languages/golang/encrypt/checker.go Outdated
Comment thread languages/golang/encrypt/guest/src/deterministic.rs Outdated
Comment thread languages/golang/encrypt/gensupport/codec.go
@coderdan

coderdan commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

The review body's other findings are addressed in d0d5799: protosource.GoName is now protoc-gen-go's GoCamelCase word for word, with TestGoName corrected; DeterministicSource lives once, in stack-kms behind test-support beside FakeDataKeySource, imported by the stack-encrypt tests and the guest (the rebuilt test guest still opens the Rust fixture, so the move changed no bytes); scalar's enum branch and guestEngine.Check's whole-declaration failure each have a test; the stack-guest-abi crate doc names the real guest path; and the encrypt README, doc.go and the stashgen README say that a match index needs text with at least one token (three characters for the n-gram) and that an optional or short value does not belong under match.

One finding of our own, raised while adding the residency checks: after Decrypt, exactly one unwiped copy of each opened string remains in the guest's freed heap. The copy is made by vitaminc-aead-value's FfiValue decoder, which copies the decrypted leaf into a new Protected and drops the AEAD output it copied from without wiping it. The after-Decrypt residency test is in place and skipped with that reason, so it runs when vitaminc fixes the decoder.

coderdan added a commit that referenced this pull request Oct 6, 2026
…ts type

Closes #1082 (CIP-4326). A data plan field with any term output (eq,
match, ore, ope) and no "type" is now refused when the plan is built, with
Error::Plan, by record::plan, plan_with and Plan::new / new_with alike: an
indexed field's terms derive from the one declared kind, and every value is
checked against it on the way in and on the way out, so the engine never
trusts a binding to have tagged each value the same way. Before this a 34
sent once as a Float64 and once as an Int64 under one "ore" field was
accepted both times and stored two terms. That gap was kept open only
until the Go side filled the type; the Go SDK (#1094) fills it on every
field from the Go kind, so nothing a binding sends today is refused.

The type stays optional where no term derives from the field: a field whose
only output is "c" or "passthrough", and a target field, whose kind is the
EQL type's own (#1095). Declaring a type changes no stored byte (#1093), so
no row is re-encrypted. The IndexSpec: Index<Value> dispatch is unchanged:
the value still carries its tag, now always checked against a declaration
before the term is derived. The "transitional" wording goes from the
dynamic module, kind and record docs and from the plan document.

Tests: refused at build for every index kind, alone and beside a
ciphertext, spelled as a key and as the match object form, by the parser
and by hand; a sealed-only and a passthrough field still build, seal and
open without a type. Every test that indexed an untyped field now declares
the type; the one that fed a float to an untyped equality field now shows
there is no such field to feed, since float64 is refused at build and an
integer kind refuses the float value. The guest's native tests and
plan_check cover the refusal; the check_record fuzz generator emits a
"type" entry so indexed plans still parse under fuzzing.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
@coderdan
coderdan added this pull request to stack #1097 October 6, 2026 15:24
coderdan added a commit that referenced this pull request Oct 6, 2026
…ts type

Closes #1082 (CIP-4326). A data plan field with any term output (eq,
match, ore, ope) and no "type" is now refused when the plan is built, with
Error::Plan, by record::plan, plan_with and Plan::new / new_with alike: an
indexed field's terms derive from the one declared kind, and every value is
checked against it on the way in and on the way out, so the engine never
trusts a binding to have tagged each value the same way. Before this a 34
sent once as a Float64 and once as an Int64 under one "ore" field was
accepted both times and stored two terms. That gap was kept open only
until the Go side filled the type; the Go SDK (#1094) fills it on every
field from the Go kind, so nothing a binding sends today is refused.

The type stays optional where no term derives from the field: a field whose
only output is "c" or "passthrough", and a target field, whose kind is the
EQL type's own (#1095). Declaring a type changes no stored byte (#1093), so
no row is re-encrypted. The IndexSpec: Index<Value> dispatch is unchanged:
the value still carries its tag, now always checked against a declaration
before the term is derived. The "transitional" wording goes from the
dynamic module, kind and record docs and from the plan document.

Tests: refused at build for every index kind, alone and beside a
ciphertext, spelled as a key and as the match object form, by the parser
and by hand; a sealed-only and a passthrough field still build, seal and
open without a type. Every test that indexed an untyped field now declares
the type; the one that fed a float to an untyped equality field now shows
there is no such field to feed, since float64 is refused at build and an
integer kind refuses the float value. The guest's native tests and
plan_check cover the refusal; the check_record fuzz generator emits a
"type" entry so indexed plans still parse under fuzzing.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
@coderdan
coderdan requested a review from auxesis October 6, 2026 23:21

@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: 🔴 fix before merging

Fix one problem and change one more before merging. The generated Decrypt cannot read any row of a type that has an index-only field (index= without encrypt). Encrypt fails when an interface-typed passthrough field holds nil. The other items are follow-ups or optional, and they can wait.

None of these findings repeat the earlier review on this pull request or the author's replies to it.

Other findings not posted as comments

  • Optional: the _comment in packages/stack-encrypt/tests/fixtures/label_segments.json:2 names languages/golang/encrypt/label_test.go. That file does not exist. The Go test that reads the fixture is languages/golang/internal/record/fixture_test.go. The comment is still wrong at the end of the stack (#1110).
  • Optional: stack-encrypt still declares the sha2 dev-dependency, but no code in the crate uses it now that DeterministicSource is in stack-kms. Its comment in packages/stack-encrypt/Cargo.toml (lines 67 to 71) still says that the key source is in tests/common. cargo udeps does not report it, because stack-kms links sha2. Delete the dependency and its comment.
  • Optional: the new public DeterministicSource in packages/stack-kms/src/key_source.rs:311 does not implement Debug, but FakeDataKeySource does. Add opaque_debug::implement!(DeterministicSource);, as src/key.rs does for DataKey, so that the seed is not printed. Also update the test-support comment in packages/stack-kms/Cargo.toml, which names only FakeDataKeySource.
  • Optional: a nil []byte or Blob in a sealed field comes back from Decrypt as a non-nil empty slice. In an opaque struct, nil comes back as nil. No test or README text states this, and the second row of languages/golang/encrypt/kinds_test.go:61 sets By: []byte{} instead of nil.
  • Optional: no test encrypts or decrypts an empty batch (nil or zero rows) through the generated API. It works today. languages/golang/encrypt/gensupport/codec.go:91
  • Optional: Seal passes its already-extended plan to locateTermFailure, which passes it to Derive, and Derive extends it again. So each probe derives under the cipher's extension twice. Pass the plan that Seal received. languages/golang/encrypt/records.go:62
  • Optional: for a pointer message type (the Generate path, for example *pb.Individual), the generated Source reads fields with no nil check. A nil element in the slice makes Encrypt panic instead of returning an error. languages/golang/stashgen/emit.go:314
  • Optional: protosource lists the members of a protobuf oneof as facts, but protoc-gen-go puts those members in wrapper types, not in the message struct. Generate then stops with "the struct has no such field" for any message that has a oneof. Refuse a oneof with a clear message, or document the limit. languages/golang/encrypt/policy/protosource/protosource.go:35
  • Optional: Generate takes its context through the WithContext option, but FromTags takes ctx as its first argument. Make Generate take ctx first, before the API is released. languages/golang/stashgen/policy.go:41
  • Optional: Declaration.Identity writes into the fields slice that it shares with the receiver, so the earlier Declaration value changes too. Generated code builds one chain, so no generated file is affected today. languages/golang/encrypt/gensupport/declaration.go:134
  • Optional: no test seals a field under one Identity, renames the field, and opens the data through the engine. Only FieldContext is unit-tested. languages/golang/encrypt/gensupport/declaration.go:128
  • Optional: code generated with -for, or from a policy through stashgen.Generate, never runs against a guest. The golden test compares the text, and the stub test only compiles it. languages/golang/stashgen/policy.go:90
  • Optional: no test reaches the stashgen.Generate refusals for two facts that name one field, or for a policy decision that does not parse. languages/golang/stashgen/policy.go:134
How this review was made
Agent Model Review type Result
claude claude-opus-5-5 test-gap 8 found, 6 posted
claude claude-opus-5-5 rust 2 found, 0 posted
claude claude-opus-5-5 golang 4 found, 4 posted
codex gpt-5.6-terra test-gap 0 found, 0 posted
codex gpt-5.6-terra rust 0 found, 0 posted
codex gpt-5.6-terra golang 1 found, 1 posted

Synthesis: claude-opus-5-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-5 read every comment as a new reader would. 3 comment(s) had a problem that stopped the reader acting; it rewrote 3.

Stack: position 3 of 8 (#1090, #1093, *️⃣ #1094, #1095, #1096, #1103, #1104, #1110). *️⃣ marks this pull request.

Context loaded: the description, 2 linked issue(s) and 29 discussion entries.

Comment thread languages/golang/stashgen/emit.go
Comment thread languages/golang/encrypt/gensupport/codec.go
Comment thread languages/golang/encrypt/gensupport/codec.go
Comment thread languages/golang/stashgen/policy.go Outdated
Comment thread languages/golang/encrypt/gensupport/codec.go Outdated
Comment thread languages/golang/stashgen/model.go
Comment thread languages/golang/stashgen/read.go
Comment thread languages/golang/stashgen/read.go
coderdan added a commit that referenced this pull request Oct 7, 2026
…trip

The second cipherstash-bot review on #1094:

- Decrypt of a type with an index-only field (`index=` without `encrypt`)
  failed for every row: nothing opens for it, and Value asked for it. The
  generated Value now skips it, so it keeps its zero value.
- A nil interface in a passthrough field (`any`, `error`) failed Encrypt and
  Decrypt: a nil interface asserts to no type. Passthrough and Get return the
  zero T for it.
- Open on a nil *Cipher or *Client returns ErrEncoding instead of panicking.
- A nil element of a pointer message type is an error, not a panic.
- An opaque struct with NaN or an infinity is ErrEncoding, and the README
  says floats there must be finite.
- Generate takes ctx first (WithContext is gone) and closes the guest engine
  it starts.
- stashgen refuses a file that would redeclare a name the package declares.
- protosource refuses a message with a oneof; a proto3 optional stays a fact.
- Declaration.Identity and add no longer write into a slice an earlier
  Declaration shares.
- Seal passes the unextended plan to locateTermFailure, so a probe is not
  extended twice.

New tests: an index-only type and nil any/error passthroughs round-trip over
the deterministic guest; a renamed field opens under its Identity and not
without it; empty batches, nil []byte, NaN, nil Decrypter; refusal cases for
model double-binding, a model naming no field, two opaque fields writing one
JSON key, a package name clash, duplicate facts and an unparsable decision.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
@coderdan

coderdan commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

The second cipherstash-bot review: the review-body findings, all in e8cd325 and 6efc22b except the last one.

  • label_segments.json comment now names languages/golang/internal/record/fixture_test.go.
  • sha2 dev-dependency is gone from stack-encrypt, along with its comment.
  • DeterministicSource has opaque_debug::implement!, and the test-support comment in stack-kms/Cargo.toml names it.
  • A nil []byte or Blob comes back empty and non-nil. The README now says so, and TestANilSealedByteSliceComesBackEmpty checks it.
  • An empty batch, nil or zero-length, is tested by TestAnEmptyBatchRoundTrips.
  • Seal passes locateTermFailure the plan it received, so a probe is extended once.
  • A nil element of a pointer message type: the generated Source returns nil for it, and Encrypt reports value N is nil instead of panicking. The goldens show it.
  • oneof: protosource refuses a message with a real oneof and names the field and the oneof. A proto3 optional field (a synthetic oneof) is still a fact. Tested with dynamic descriptors.
  • Generate takes ctx first, and WithContext is removed.
  • Declaration.Identity clones the slice, and add appends to a clipped slice, so an earlier Declaration never changes.
  • Renamed field: TestARenamedFieldOpensUnderItsIdentity seals under email, then opens under mail with Identity("mail", "email"). A third declaration without the identity is refused.
  • Generate refusals: TestGenerateRefusals now covers two facts that name one field and a decision that does not parse.
  • Not done: running code generated with -for or from a policy against a guest. That needs the generated file compiled into a test package with a real pb type, which is more than a follow-up's worth here. The golden plus the stub compile still cover it. I'm leaving it as a known gap.

The five PRs above (#1095, #1096, #1103, #1104, #1110) are rebased onto this.

https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc

coderdan added a commit that referenced this pull request Oct 7, 2026
…ts type

Closes #1082 (CIP-4326). A data plan field with any term output (eq,
match, ore, ope) and no "type" is now refused when the plan is built, with
Error::Plan, by record::plan, plan_with and Plan::new / new_with alike: an
indexed field's terms derive from the one declared kind, and every value is
checked against it on the way in and on the way out, so the engine never
trusts a binding to have tagged each value the same way. Before this a 34
sent once as a Float64 and once as an Int64 under one "ore" field was
accepted both times and stored two terms. That gap was kept open only
until the Go side filled the type; the Go SDK (#1094) fills it on every
field from the Go kind, so nothing a binding sends today is refused.

The type stays optional where no term derives from the field: a field whose
only output is "c" or "passthrough", and a target field, whose kind is the
EQL type's own (#1095). Declaring a type changes no stored byte (#1093), so
no row is re-encrypted. The IndexSpec: Index<Value> dispatch is unchanged:
the value still carries its tag, now always checked against a declaration
before the term is derived. The "transitional" wording goes from the
dynamic module, kind and record docs and from the plan document.

Tests: refused at build for every index kind, alone and beside a
ciphertext, spelled as a key and as the match object form, by the parser
and by hand; a sealed-only and a passthrough field still build, seal and
open without a type. Every test that indexed an untyped field now declares
the type; the one that fed a float to an untyped equality field now shows
there is no such field to feed, since float64 is refused at build and an
integer kind refuses the float value. The guest's native tests and
plan_check cover the refusal; the check_record fuzz generator emits a
"type" entry so indexed plans still parse under fuzzing.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
coderdan and others added 27 commits October 7, 2026 04:23
The two packages are `encrypt` and `auth`, at languages/golang/encrypt and
languages/golang/auth, as the plan builder design names them: a package
name does not repeat the product, and `encrypt.Cipher` reads as what it
is where `stackencrypt.Cipher` repeated itself (SDK principle 13).

A move with nothing else in it, so a review can read it as one: `git mv`
of both directories, the guest crates with them, the package clauses, the
import paths, the selectors, the error prefixes and the prose; every path
in mise.toml, the root Cargo.toml exclude list, .gitignore, dependabot,
tests-golang.yml, go-binding-test.sh, the scripts/__tests__ guards,
AGENTS.md, SECURITY.md, stack-guest-abi's module doc, stack-encrypt's
CONTEXT.md and the label-segments fixture comment. The task alias
`go:stackencrypt:test` goes with the name it kept. The CI variable
STACKENCRYPT_TESTS_REQUIRE_LOCK becomes STACK_ENCRYPT_TESTS_REQUIRE_LOCK.

Two renames the compiler forced: the plan package's `encrypt` verdict
constant collided with the package it now imports, so it is `sealed`;
two test locals named `auth` shadowed the auth package and are `cts`.
docs/plans and the ADRs keep the old names as history.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
The Go SDK is now what the plan builder design describes: a struct's stash
tags are the declaration, stashgen writes the encrypted type and its
functions, and a program calls users.Encrypt, users.Decrypt and
users.Fields. Everything the old package offered beside that is removed,
not deprecated — it was never released: Cipher.Encrypt/Decrypt and the
Element forms, Client.Decrypt, EncryptRecord(s)/DecryptRecord(s),
EncryptedRecord, EncryptedField, Cipher.Term, RecordOption, WithPlan,
ExtendContext, WithGuest, TermKind, Plan, FieldPlan, NewPlan, PlanFromTags
and every run-time tag reader, the plan package and plantest, factstest,
Context/Label and their constructors, and the four Sealed* storage types.

What replaces them, and why it has the shape it has:

- encrypt.Cipher.Extend appends the caller's parts to every field's
  context in every call through the cipher, so the write, the query and
  the read cannot use different ones. encrypt.Ciphertext is the frozen
  stack-encrypt leaf, the one storage type: every sealed field is one
  leaf. Index is Equality, Ore, Ope, Match() and JSON(); the term types
  stay. Decrypter is what a generated Decrypt takes: *Cipher refuses a
  foreign keyset's row before any key is retrieved, *Client opens each
  row under the keyset that sealed it.
- The record path (Cipher.Seal, Cipher.Open, Client.Open, Cipher.Derive)
  takes the internal record types, so only generated code reaches it.
  internal/record is the data form of a declaration as dynamic::record
  reads it: a field's context is its label (context segments + identity)
  nested under each extension part, "type" is the wire kind, outputs are
  c/eq/match/ore/ope. Both gensupport (running) and stashgen (checking)
  lower to it, so the generator checks the plan the program will run.
- encrypt/gensupport is the real library behind generated code: Declare
  and the verb methods carry the field's wire kind, which stashgen picks
  from the Go type (int8..int32 travel as int32, int/int64 as int64, the
  unsigned likewise, []byte as bytes); Codec.Encrypt/Decrypt send a slice
  as one guest call and one ZeroKMS request; Field[T] seals one value and
  derives the field's terms; Get converts within a kind's family and
  refuses the rest, so a value that opens to another type is an error.
- Passthrough fields stay on the host. The FFI codec cannot carry every
  Go type a program stores beside a ciphertext (time.Time, gorm.DeletedAt,
  any driver.Valuer), and nothing the engine does to a passthrough value
  is observable, so the plan the engine sees has the sealed fields only
  and the generated Seal reads passthrough values back from the Record.
  The plan says passthrough crosses; this is the deviation, recorded here
  and in the record package's doc.
- An opaque struct crosses as one JSON document, declared bytes, because
  the engine seals a composite FfiValue as a tree of leaves and an opaque
  struct is one column. Its fields are what JSON carries; the generator
  refuses a nested struct inside one for now.
- stashgen.GuestEngine is the embedded guest: encrypt.NewChecker
  instantiates it with no credentials and asks se_plan_check one field at
  a time, so a refusal names the field, then for the whole plan. The
  engine produces no EQL type in this build (se_targets is empty), so
  encrypt_into is refused with "EQL types are not available yet".

The guest loses se_encrypt, se_decrypt and the element exports (ADR-0007
as amended: every value crosses under a declaration) and gains
se_plan_check and se_targets. A `deterministic-kms` feature builds a TEST
guest whose keys derive from a seed — the DeterministicSource of
stack-encrypt's tests/common, copied — so the Go tests open the records
Rust sealed in tests/fixtures/record_lowering.json through the generated
testusers package and derive the same term bytes, and run round trips,
foreign-keyset refusal, tampering and the ORE/OPE ordering properties
with no ZeroKMS. The hermetic tests replace the old live ordering tests;
the live suite keeps the real round trips through generated code. The
test build's import gate requires no transport import: with no ZeroKMS
client in it, the linker drops the host module.

Generated *_stash.go files are committed (internal/testusers, example),
and tests-golang.yml runs `go generate ./...` and fails on a diff. The
stashgen goldens and the stub SDK follow the new signatures; the stub
agreement test now covers encrypt too and compares signatures without
parameter names. The example is rewritten to the eight steps; the
explicit-credentials example, which only showed the removed API, is
gone with its task.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
Two Codex findings on PR #1094, both real, both the same shape: a value
the generator accepted and Encrypt sealed could never be decrypted.

Get sent every opened value through convert, whose switch knows the wire
kinds and a few slice types. A passthrough field is the Go value the
struct held, kept on the host, so a time.Time or a sql.NullTime — the
plan's own accounts example — reached convert and was refused. Get now
returns a value that already is a T before it converts anything.

An opaque struct came back from JSON into Values and each field went
through Get, so a map[string]string (JSON gives map[string]any), a type
defined over a scalar, a nested struct or a pointer could not be read,
although classify and readableInOpaque had accepted them. The opaque
value now crosses as a JSON document of a generated shape struct
(documentOpaque, with json tags for the declared names), and the
generated Value decodes it straight into that struct with gensupport.
Opaque, so every type encoding/json carries both ways comes back as it
was. The generator's rule is that: a field of an opaque struct may be a
scalar or a type defined over one, []byte, a slice or array, a map with
string or integer keys, a pointer, a struct whose fields are all
exported, or a type with its own MarshalJSON/UnmarshalJSON (time.Time); a
channel, an interface, a map keyed by a struct, or a struct JSON would
truncate is refused at generate time with the field named.

Outside an opaque struct the engine seals a composite as a tree of
leaves and a column holds one, so stashgen now refuses encrypt or index=
on a struct, slice or map field ("seals only as part of an opaque
struct") instead of letting Encrypt fail at run time. A type defined
over a scalar (type Email string) is accepted: the engine returns the
underlying type, and the generated Value reads it at that type and
converts, since Go has no way to do that generically without reflection.

The proof is in encrypt/internal/testusers: Account (time.Time and
sql.NullTime passthrough beside an indexed field), Kinds (one sealed
field of every scalar kind, defined types among them, and passthrough
fields of every type the codec cannot carry) and Everything (an opaque
struct with a field of every type JSON carries), each generated by the
real stashgen against the real guest and walked through Encrypt then
Decrypt over the deterministic build with reflect.DeepEqual.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
CI's live job failed TestPlaintextDoesNotRemainInGuestMemoryAfterEncrypt
with "term derivation failed": it encrypted a User with every field but
Notes at its zero value, and the empty Email sits under a match index.
Reproduced hermetically over the deterministic guest: the empty string
and a separator-only string fail, a zero Age under ore and an empty
Notes with no index do not.

The engine is right to refuse. sem::match_terms defines no match term
for text that yields no token — empty, separator-only, or shorter than
the n-gram — because an empty term would match every row, and the typed
Rust path refuses it the same way. Not a lowering bug; nothing changes
under packages/stack-encrypt.

What was wrong on the Go side is the message: the guest reports a
status and nothing else. Cipher.Seal now asks the engine again on
ErrTerm, one term at a time (a term derives locally, so this costs no
key request), and wraps the error with the row, the field and the index:
`value 1, field "email": the engine derives no match term for this
value (a match index needs text with at least one token; ...)`.
TestAMatchIndexNeedsText pins it for the record call and for the field
entry, and pins that the other zero values seal and open. The live
residency test encrypts a fully populated record. cmd/stashgen's README
says what a match index needs (in the previous commit, with the README's
other changes).

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
…f the embed

`//go:embed wasm` embeds the whole directory, so after `mise run
wasm:guest:build:deterministic` every binary built from the tree carried
the seed-only test build beside the real guest. Nothing loaded it, but a
later read of guestFS by pattern could have. The task now writes it to
encrypt/testdata, which go build and the embed both ignore; the tests
read it from disk with os.ReadFile and skip when it is absent. The
.gitignore entry, the CI artifact and checksum paths and the wasm README
follow it.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
…round-trips

A reviewer generated a field of a defined type (type Status string) and
could not decrypt it: convert named only the built-in types, so a
*Status, a time.Duration, a []int or a map[string]string reached its
default arm. Get now falls through to convertVia, the one place this
package uses reflection: a defined scalar type is read as its underlying
type and converted, a slice or array element by element, a map with
string keys entry by entry, each through convert and its range checks.
Generated code stays reflection-free. A JSON number widens to its
family's widest type first, so the same reader serves an opaque
document's fields.

TestGetReadsEveryTypeTheGeneratorAccepts holds the reviewer's cases and
more; testusers.Kinds gains a Status and a time.Duration field, Everything
gains []int, []float32, Status and time.Duration, and both round-trip
through generated code over the deterministic guest. testusers.User gains
-model Rows=UserRow and TestModelRowsRoundTrip proves each term lands in
its own column and the rows decrypt back; nothing ran Records before.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
…e keys

The Go decoder was the only definition of an se_targets entry and kept a
zero value for an unknown key or a mistyped value. The entry shape is
now the one eql-bindings serialises — name, family, suffix, plaintext
(a ValueKind name or null), sql_domain, indexes, query, query_sql_domain,
producible, reason — documented as wire format in the guest's targets
rustdoc and read by parseTargets, a function that returns ErrInternal on
an unknown key, a repeated key, a missing name, indexes or producible,
or a value of the wrong type. record.Target carries those fields;
stashgen's EQLType carries Producible and Reason and the reader refuses a
type the engine lists but does not produce, with the engine's reason.
TestParseTargetsReadsEachEntry drives it with a two-entry list and every
malformed shape. The fake engine's equality rule now matches
dynamic::admits: integers, text and bytes, not floats or booleans.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
… the fake

TestRefusalsHoldAgainstTheEmbeddedEngine runs stashgen with GuestEngine —
the engine it ships with — over the cases a reviewer found the fake and
the real engine disagreeing on (time.Time, a channel, equality on a map,
equality on a float) and the composites a reviewer sealed and could not
open ([]string, map[string]int64); each is refused naming the field, and
a float equality is refused by both engines alike. The sealed-field rule
(one scalar, or a type defined over one; a composite only inside an
opaque struct) is what refuses the first three before either engine is
asked. TestTheWholeDeclarationIsCheckedAgainstTheEmbeddedEngine makes
the whole-plan check fail — two fields a policy pinned to one identity —
and pins its FieldError with no field name. The two doc comments that
still said a composite seals as one value say what the code does.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
…ails on any other error

The residency check runs hermetically over the deterministic guest with a
populated record (a match index refuses an empty Email) and checks the
guest's memory for the plaintext and the email after Encrypt. The same
check after Decrypt is its own test and is skipped with the reason: one
unwiped copy of each opened string remains, made by vitaminc-aead-value's
FfiValue decoder, which copies the decrypted leaf into a new Protected and
leaves the AEAD output it copied from to drop unwiped — the host's output
buffer is wiped through se_dealloc and the Protected payloads on drop, so
the copy is vitaminc's to remove, and the test runs again once it is. The
live variant keeps the Encrypt check across a real ZeroKMS request. The
fixture test skips only when the guest is not built and fails on every
other error from NewDeterministicClient.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
…d the review's small findings

DeterministicSource existed twice — stack-encrypt's tests/common and the
guest's deterministic.rs — and the Go fixture test depends on the two
deriving the same bytes. It now lives once in stack-kms behind its
test-support feature, beside FakeDataKeySource; the stack-encrypt tests
and the guest import it, and the guest's deterministic-kms feature no
longer carries its own sha2. The rebuilt test guest still opens the
fixture, which is the proof the move changed no bytes.

protosource.GoName is protoc-gen-go's GoCamelCase word for word: a digit
ends a word (foo_1bar is Foo_1Bar, sha256sum is Sha256Sum), a leading
underscore is X, a dot is an underscore unless a lower-case letter
follows; TestGoName held the wrong value for foo_1bar. scalar's enum
branch has a test through a dynamic descriptor, since testpb declares no
enum. stack-guest-abi's crate doc names the guest's real path. The three
documents say what a match index needs — text with at least one token,
three characters for the n-gram — that the whole batch fails otherwise,
and that an optional or short value does not belong under match.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
…drops sha2

DeterministicSource prints without its seed, as DataKey does. stack-encrypt
no longer uses sha2 now that the deterministic source lives in stack-kms, and
the label_segments fixture names the Go test that actually reads it.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
…trip

The second cipherstash-bot review on #1094:

- Decrypt of a type with an index-only field (`index=` without `encrypt`)
  failed for every row: nothing opens for it, and Value asked for it. The
  generated Value now skips it, so it keeps its zero value.
- A nil interface in a passthrough field (`any`, `error`) failed Encrypt and
  Decrypt: a nil interface asserts to no type. Passthrough and Get return the
  zero T for it.
- Open on a nil *Cipher or *Client returns ErrEncoding instead of panicking.
- A nil element of a pointer message type is an error, not a panic.
- An opaque struct with NaN or an infinity is ErrEncoding, and the README
  says floats there must be finite.
- Generate takes ctx first (WithContext is gone) and closes the guest engine
  it starts.
- stashgen refuses a file that would redeclare a name the package declares.
- protosource refuses a message with a oneof; a proto3 optional stays a fact.
- Declaration.Identity and add no longer write into a slice an earlier
  Declaration shares.
- Seal passes the unextended plan to locateTermFailure, so a probe is not
  extended twice.

New tests: an index-only type and nil any/error passthroughs round-trip over
the deterministic guest; a renamed field opens under its Identity and not
without it; empty batches, nil []byte, NaN, nil Decrypter; refusal cases for
model double-binding, a model naming no field, two opaque fields writing one
JSON key, a package name clash, duplicate facts and an unparsable decision.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
About 4,000 of this branch's added lines are machine output: the
*_stash.go files stashgen writes, and the *.golden files its tests
compare against. GitHub shows them expanded next to the hand-written
source, which buries the code a reviewer actually needs to read.

linguist-generated makes GitHub collapse them in diffs by default and
leaves them out of language statistics. It changes nothing for git, CI
or the build, and the files can still be expanded on demand. Every
*_stash.go file in the tree carries stashgen's "DO NOT EDIT" header,
so the pattern catches no hand-written file.

Claude-Session: https://claude.ai/code/session_01WQQPj7Y5yASArvZaHgFpCt
Go principle 10 in docs/sdk-design-principles.md says a change to which
fields are encrypted shows as a change to a committed file that a
reviewer reads. The *_stash.go files are that committed record, and for
a type declared through a policy they are the only place a rule change
shows. Marking them linguist-generated made GitHub collapse them by
default, so a reviewer could pass over exactly the change the principle
wants read.

The *.golden files stay marked: they are the generator's test
expectations, not the declarations a program ships.

Claude-Session: https://claude.ai/code/session_01WQQPj7Y5yASArvZaHgFpCt
The third cipherstash-bot review on #1094 (Go principle 12): when a stored
value did not fit its field's Go type, the error carried the value, so a
sealed uint8 that opened as 300 returned "300 does not fit a uint8". The
value is decrypted plaintext, and an error is what a program logs. The
same held for an opaque JSON number, a map key inside a sealed map, and
encoding/json's own errors, which quote the input.

Every conversion error now names types only. Get and Opaque wrap
encrypt.ErrEncoding, which doc.go already promised for a stored value
that does not fit its declaration, and encoding/json's errors are no
longer wrapped. TestDecryptErrorsHoldNoPlaintext fails on each case
against the previous code.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
The third cipherstash-bot review on #1094, the claims and print items:

- The batch claim. Encrypt and Decrypt send one ZeroKMS request for each
  500 sealed values, plus one the first time a keyset is used, not one
  request per call. The READMEs, doc.go and the comment stashgen writes
  on every generated Encrypt and Decrypt now say so.
- Database libraries. No test runs database/sql, pgx, sqlx or GORM, so
  the docs now name the interfaces instead: Ciphertext and each term
  type implement driver.Valuer and sql.Scanner, one column each.
- The Rust bytes. A field lowered from data seals as vitaminc's tagged
  leaf whatever its kind, which a Rust record opens only through a Value
  field. Three comments said a uint32 or string field wrote the bytes a
  bare Rust u32 or String does.
- What this build refuses. index=json and an index with options are
  marked refused and listed under "When stashgen stops"; the options
  refusal now names the real limit (a query term uses only the default
  options) rather than the record plan, which carries them. The two
  Go-only tag words, opaque and json, are named. Only a policy sets
  Identity, and the README says why. A type with nothing sealed on its
  own gets no Fields, and the README says why.
- Go 1.26, not 1.24, matching go.mod. The step 5 snippet compiles
  (opened, not people, and err is checked before defer). The stashgen
  quick start uses index= until EQL types land. The CI recipe also fails
  on a generated file that was never committed.
- "a auth" and "a encrypt client" read "an".
- Print methods. The print notice checks the signature, not only the
  name: a LogValue() any is not a slog.LogValuer, so slog prints every
  field. -redact also writes GoString, which %#v calls, and refuses a
  struct that already has one. testusers.Secret is generated with
  -redact, and TestARedactedStructPrintsNoSealedField formats it with
  %v, %+v, %#v, %s and slog.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
…catches an uncommitted generated file

The third cipherstash-bot review on #1094 (Go principles 1, 9 and 10):

- The shape check embedded the same named type the user's struct did,
  so a field added to an embedded Person, or to gorm.Model, still
  converted. The generated code reads fields through the embedded
  struct one by one, so Encrypt dropped the new field with no error.
  stashgen now also writes a shape for each embedded struct it reads
  (recursively, skipping one tagged `stash:"-"`). One from another
  package with an unexported field cannot convert, and the generator's
  notice says so: the compiler finds a removed or retyped field and CI
  finds an added one. TestAFieldAddedToAnEmbeddedStructStopsTheBuild
  adds a field to the embedded case's Person and checks the stale file
  fails to build at the embedded shape.
- The "Generated code is committed" step ran git diff, which ignores
  untracked files, so a //go:generate line whose output was never
  committed passed. It now also fails on untracked files.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
…parameter

The third cipherstash-bot review on #1094 (Go principles 1 and G6):

- Generate wrote its file without the name check FromTags runs, and had
  no way to set a name, so two messages generated into one package both
  declared Encrypt and the compiler reported the clash in the generated
  file. Generate now loads the output package (without the file it
  replaces) and refuses a clash, and WithName gives the names a prefix
  as -name does on the tag path.
- The output path was a required option, found missing only at run
  time. It is now a parameter:

    stashgen.Generate(ctx, source, message, "../individuals/individual_stash.go")

  and Output is gone.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
…elling

The third cipherstash-bot review on #1094 (Go principles 8 and 13),
before the first release makes these names permanent:

- auth.ErrAuthTransport, auth.ErrAuthConfig, auth.ErrAuthOther and
  auth.WithAuthBaseURL repeat the package name; they are now
  auth.ErrTransport, auth.ErrConfig, auth.ErrOther and auth.WithBaseURL.
  The internal/guest names stay.
- gensupport.UInt32/UInt64 and record.UInt32/UInt64 are Uint32/Uint64,
  as the standard library and stashgen's own KindUint spell them.
  Generated files and goldens are regenerated.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
…oved here

The note on plan.Custom and plantest snapshots described a Go package this
PR deletes. Go has no release yet, so no user ever saw plan.Custom bind a
text part; the crate CHANGELOG ships to crates.io and should not name it.
The keys of a target entry were strict, but two values were not: plaintext
became a record.Kind and each index a record.Output with no check. A typo
in the Rust serialiser ("strng", "equality") then reached stashgen as an
EQL type with no kind or no indexes, and no error. Both are now refused;
"json", the SteVec document index no plan output carries, is accepted.
record.Kind's check is exported as Known for it.
…s Decrypt

- The example sealed through Extend("tenant-42") and decrypted through the
  Client, which refuses every such row; it now decrypts through the cipher,
  and the Cipher, Decrypter and README docs say a Client opens only rows
  sealed with no extension.
- doc.go says the guest holds nothing but the client key and keyset cache
  between calls; after Decrypt one unwiped copy of each opened string
  remains, as the skipped residency test records. The test comment no
  longer names a vitaminc line nobody has confirmed.
- A generated Decrypt over a struct with index-only fields says those fields
  come back zero and must not be encrypted again to update the row, and the
  stashgen README says the same: re-encrypting stores the term for zero.
- The generated Encrypt and Decrypt comments, records.go and gensupport
  count the keyset request beside the one per 500 sealed values.
- The README lists which terms a database can compare: ORE terms sort almost
  correctly as plain bytes, so ORDER BY on them is wrong without an error.
- The tag table warns that the name and context= are part of every stored
  value's context, and the cipher section that a value is not bound to its
  row.
… the record

The plan builder has three context sources: the plan, the call, and a
field of the value (`FieldsBuilder::context_field`, the derive's
`#[stash(context_field)]`). The data grammar `dynamic::record` lowers
had only the first: a plan held one `Label` and every field's `"context"`
had to sit under it. ADR-0007 listed `context_field` among the typed,
Rust-only parts, but it is not typed, and the ADR's own rule says a
capability the grammar cannot express is added to the grammar once so
every author gets it. A binding could not bind a row to its own
context at all.

The grammar gains a plan-level `"context_field": "<name>"` key beside
the field specs. Under it the named field is a passthrough of type
string whose value in each record (`"tenants/acme"`) is the context
every other field is sealed under, and each field's `"context"` is its
identity alone, a one-segment label, extended by the caller's parts as
before. `Plan::with_context_field` holds the rules (the field exists, is
passthrough only, is a string or untyped, and every label has one
segment), `lower()` goes through the builder's own `context_field` over
a `Value` (which now implements `IntoLabel`; a value that is not text is
the new `LabelError::NotText`), and the extension still becomes
`.extend(..)`. Without the key the two-segment and shared-prefix rules
hold exactly as they did, so every existing plan parses and seals the
same bytes; a one-segment label is now the plan's refusal rather than
the field's, which is the same `Error::Plan` from `plan()`.

`record::decrypt` and `check_record` take the context the caller
expects, the chain's `open(record).context(expected)`, and refuse a
record whose stored context differs with `Error::ContextMismatch`
before any key is requested; `None` opens each record under what it
stores, and `Some` on a plan with a context of its own is the chain's
`TwoContextSources`. `Plan::label` is an `Option` now, as the builder's
is, and `Plan::context_field` names the field.

The cross-author fixture gains a `context_field` case: a Rust chain
over `Value` fields with `context_field` and the data plan each seal a
note under `tenants/acme`, each opens the other's under the stored
context and refuses it under another, and both derive the term under
`tenants/acme/text`. Only that key was appended; the existing records
are as committed, and each test's update path now rewrites only the
entries it owns.
…ects

`se_decrypt_record` took a keyset selector and nothing else, so a host
had no way to say which context it expected a record's context field
to hold, and the engine's `ContextMismatch` had no status of its own:
it fell through the catch-all to `STATUS_INTERNAL`, which tells a Go
caller the guest is at fault.

The options object gains an optional `context` key on the opening side,
a plain label the engine checks each record's stored context field
against before any key is retrieved (`dynamic::record::decrypt`'s new
expected context); on the sealing and term exports it is refused, as a
second context source would be. The shared status table gains
`STATUS_CONTEXT_MISMATCH` (28), appended after the credential guest's
codes, and the Go host decodes it to `ErrContextMismatch`. The boundary
validation reads the same expected context so a mismatch is refused the
same way with and without a cipher.
The Go SDK could not bind a value to its row: every struct took its
context from a `context=` tag, so a value copied to another row of the
same table decrypted there with no error, and the README said so. The
engine binds a row to its own context with `context_field`, and the
data grammar now spells it, so the SDK gets it under the same word.

`stash:"tenant,context_field"` on a string field makes that field's
value, a label such as "tenants/acme", the context every other field is
sealed under; the field is stored as it is, in the clear and
unauthenticated like a passthrough, so the row names its own context.
The rules are the Rust derive's: exactly one per struct, never beside a
`context=` tag, a string, and no other part on the tag. `stashgen`
refuses each at generate time with a FieldError, and refuses a name
that is not a label segment, because the name is the field's label on
the wire. Generated code declares it through
`gensupport.DeclareContextField`; the record plan carries it as
`ContextField` and writes the plan-level key, the context field's own
entry and one-segment labels the engine reads.

`Cipher.Context(label)` is the one new call, mirroring `Extend`: a
cipher names the context every row through it is under, as the Rust
chain's `.context(..)` does. Through it `Decrypt` refuses a row whose
stored context is another with `ErrContextMismatch` before any key is
retrieved, `Encrypt` refuses a value whose field says otherwise before
anything is sent, a query derives its term under the label (a type with
a context field has no context of its own to derive under, so a query
through a cipher without one is `ErrEncoding` naming the call), and a
field's own `Encrypt` seals the one value under it. Without a context,
`Encrypt` and `Decrypt` take each row's context from the row, and the
`*Client` opens each row under the context it stores. A cipher with a
context refuses a type whose context is in its tags, as the engine
refuses two sources.

The round-trip tests seal two tenants in one batch through the
deterministic guest, open them with and without a named context, and
open the Rust record fixture's new `context_field` case through the
generated `Note` type, so the three authors of that declaration agree
on the bytes.
…orbidden

`DeterministicSource::retrieve_keys` refused a tag that was not the
descriptor's with `RetrieveKeyError::FailedRetrieval`, which the Go
guest maps to `STATUS_KMS_NOT_FOUND` and a Go program reads as
`ErrNotFound`. ZeroKMS refuses the same retrieve with HTTP 403, which
reaches Go as `ErrForbidden`, and the live suite asserts that. So the
hermetic suite and the live suite could not assert one error for a
wrong-context open, and the hermetic one settled for `err != nil`.

The source now refuses with `RetrieveKeyError::RequestFailed` of kind
`Forbidden`, the classification production gives, wrapping a small
error that says what the 403 stands for here. A unit test holds the
kind, and `TestExtendIsPartOfTheIdentity` asserts `ErrForbidden`, the
same error `live_test.go` asserts.
… the guest

`stashgen` writes a file three ways: from a struct's own tags, from
tags declared for a type in another package (`-for`), and from a policy
(`stashgen.Generate`). Only the tag path's output was ever executed, in
the round-trip tests over the deterministic guest. The other two were
compiled against the stub SDK and compared as text, so a file that
built and read correctly but sealed under another label, or lost a
term, would have passed.

`testmember.Member` is a struct with User's fields that carries no
tags. `testusers` declares them for it with `-for` (`memberStash`,
`EncryptMember`), and `genmember` writes `testpolicy` from a policy
with the same decisions, through `go generate` like every generated
file, so `go generate ./... && git diff --exit-code` covers both. The
new test seals the same values through all three, holds the -for and
policy terms to the tag path's bytes, opens each path's rows through
the cipher and the client and the tag path's rows through the other
two, and derives probes through each path's `Fields`.
@coderdan
coderdan merged commit 4a20345 into main Oct 7, 2026
51 checks passed
@coderdan
coderdan deleted the feat/go-stashgen branch October 7, 2026 17:44
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.

3 participants