Skip to content

feat(go): plantest.Golden checks in the contexts a policy produces - #1025

Open
coderdan wants to merge 4 commits into
mainfrom
claude/intelligent-mayer-0t48ek
Open

coderdan wants to merge 4 commits into
mainfrom
claude/intelligent-mayer-0t48ek

Conversation

@coderdan

@coderdan coderdan commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Stacked on #1022. In the Go SDK, a policy (plan.Policy) decides how each field of a message is encrypted, and it builds each field's encryption context from names: for an EQL column, the context is "<table>/<column>". That context is mixed into every ciphertext and every searchable index term written for the field. If it changes, rows already written stop decrypting and stop matching queries, and nothing on the write path reports an error. Renaming a proto field or a Go struct field is enough to change it.

This PR adds plantest.Golden, a test helper that saves a snapshot of every context and target a policy produces to a checked-in file. The test fails when any of them changes. A context change is reported separately from other changes, along with the plan.Column pin that keeps the old context.

func TestIndividualsPolicy(t *testing.T) {
    plantest.Golden(t, source, policy.Individuals)
}

Changes

  • New package stackencrypt/plan/plantest:
    • Golden(t, src, m) builds the plan the same way plan.PlanFor does at startup.
    • It compares the result with testdata/<test name>.golden. A subtest's snapshot goes in a folder named after the parent test.
    • -update writes the file. If the file already existed, it also logs what changed.
    • A policy that doesn't build (a classified field that no rule decides, or a plan.Fail) fails the test with the build error.
    • A message the policy encrypts nothing of is still snapshotted: the file lists its plaintext fields.
  • Snapshot format: plain text with one entry per field. An encrypted field records its column, context, index terms and facts. A field decided Plaintext records its name and facts.
    • Fields and facts are sorted, values that aren't plain identifiers are quoted, and \r\n checkouts compare equal. The output is the same on every run and platform.
  • Failure message: changes are grouped by what they cost, followed by a unified diff.
    • Context changes (data loss): a column whose context moved, a context no field writes any more, or a changed table.
    • Target changes (migration): different index terms, a plaintext field that is now encrypted or the reverse, or a database column rename that keeps its context.
    • Other changes: new fields, changed facts, a plaintext field the policy no longer decides.
  • Pin hints are checked before they're shown: the plan is rebuilt with the suggested plan.Column / plan.Identity pin, and the hint appears only if that restores the old column and context. Otherwise the message gives the reason no pin can (the table changed, or the target supplies its own context).
  • Docs: a "Checking contexts in with a golden test" section in the Go README, and a pointer in the plan package doc.

Verification

  • The two acceptance cases are tests, run with both a protobuf-shaped fact source (TestRenameWithoutAPinIsAContextChange) and a Go struct (TestStructFieldRename):
    • Renaming a field with no pin fails with a context-change message naming plan.Column("medicare_number").
    • Adding that pin makes the test pass, and the snapshot is byte-identical to the one written before the rename.
  • TestChangesAreSortedByWhatTheyCost covers 11 kinds of change. For each, it checks the change lands in exactly one section, with the expected advice.
  • Determinism: shuffling fields and facts gives the same bytes; render, parse, render round-trips with spaces, quotes, newlines, non-ASCII and empty values; a \r\n checkout passes.
  • TestPolicies runs the real Golden against two checked-in snapshots. CI runs these on Linux, macOS, Windows and 386.
  • Mutation checks: I broke the code 13 different ways one at a time (for example, no pin hint, snapshot keyed by field name, unsorted facts, no CRLF handling, ambiguous rename guessed, terms change ignored). Each made at least one test fail; the code was restored after each.
  • gofmt, go vet ./..., and golangci-lint v2.14.0 (the version mise.toml pins) report 0 issues. go test ./... passes, also under GOARCH=386. GOOS=windows go vet passes on the new package.
  • Not run locally: the guest-backed stackencrypt tests, because the WASI guests weren't built here. Their tests skip without the guests, and this PR doesn't touch them.

No changeset: the Go module has no release process yet. No skill covers the Go module.

Review notes

  • Encrypted fields are listed by column, not by source field name. This is deliberate. A rename that the policy pins with plan.Column then leaves the snapshot unchanged, which is what lets the test pass "unchanged" after the pin is added. If the source field name were recorded, every safe rename would also need -update, and an unchanged snapshot would no longer mean "storage unchanged". The current field name still appears in every failure message, so the developer knows which rule to pin.
  • The API is Golden(t, src, m) with an explicit plan.Source, mirroring plan.PlanFor(src, m). Once a protobuf fact source exists, it plugs in as src.
  • plantest registers the conventional -update flag on the default flag set. A test package that defines its own -update would panic with "flag redefined". The package doc says to read plantest's flag instead.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U5Y8iJ3ZM71digszHzpyrd


Generated by Claude Code

@changeset-bot

changeset-bot Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 2beca7d

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

@coderdan
coderdan force-pushed the fix/go-context-owns-byte-parts branch from f1cdbb8 to 887529d Compare October 2, 2026 23:59
@coderdan
coderdan force-pushed the claude/intelligent-mayer-0t48ek branch from 28beeca to 8787b99 Compare October 3, 2026 00:12
@coderdan
coderdan force-pushed the fix/go-context-owns-byte-parts branch from 887529d to ab8ca58 Compare October 3, 2026 06:32
An error occurred while trying to automatically change base from fix/go-context-owns-byte-parts to feat/go-term-options October 3, 2026 06:41
@coderdan
coderdan changed the base branch from fix/go-context-owns-byte-parts to main October 3, 2026 06:44
A policy derives each field's encryption context from names, so an
ordinary rename (a proto field, a Go struct field, a table) can change a
context with no error on the write path: rows already written stop
decrypting and their query terms stop matching. plantest.Golden makes
that a test failure.

Golden builds the plan as plan.PlanFor does at startup and compares what
it stores each field as with a snapshot checked in at
testdata/<test name>.golden; -update rewrites it. The snapshot records
only what stored rows depend on: per encrypted field its column, context,
index terms and facts, keyed by column rather than field name, so a
rename the policy pins with plan.Column leaves it byte-for-byte unchanged
and the test passes; per field decided Plaintext, its name and facts.
Output is sorted and line-ending-normalised, so it is the same on every
run and platform.

On a mismatch the failure sorts the changes by cost: context changes
(data loss) first, target changes (a migration) next, then the rest,
followed by a unified diff. For a lost context it names the pin that
keeps it, and only after building the plan with that pin to confirm it
restores the old column and context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5Y8iJ3ZM71digszHzpyrd
@coderdan
coderdan force-pushed the claude/intelligent-mayer-0t48ek branch from 8787b99 to 0478ea2 Compare October 3, 2026 20:56
@coderdan
coderdan requested a balanced review from Copilot October 3, 2026 21:55

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

Custom targets, plaintext-only policies, and Windows paths can currently produce incorrect guidance or failures.

Review effort: Balanced
Findings: 1 High severity · 3 Medium severity

Open (4)
What changed in this PR

Adds golden snapshot testing for Go encryption policies to detect unsafe context and target changes.

Changes:

  • Introduces plantest.Golden, deterministic snapshots, diffing, and migration guidance.
  • Adds comprehensive tests and checked-in policy snapshots.
  • Documents golden policy testing.
File Description
languages/​golang/​stackencrypt/​README.md Documents golden tests.
plan/​doc.go Links policy documentation to plantest.
plantest/​plantest.go Implements the public helper and update workflow.
plantest/​snapshot.go Builds, renders, and parses snapshots.
plantest/​compare.go Classifies changes and generates guidance.
plantest/​plantest_internal_test.go Tests comparison and snapshot behavior.
plantest/​golden_test.go Exercises the public API.
testdata/​TestPolicies/​individuals.golden Adds an encrypted-policy fixture.
testdata/​TestPolicies/​audits.golden Adds a plaintext-policy fixture.

💡 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/plan/plantest/plantest.go
Comment thread languages/golang/stackencrypt/plan/plantest/compare.go Outdated
Comment thread languages/golang/stackencrypt/plan/plantest/compare.go
Comment thread languages/golang/stackencrypt/plan/plantest/compare.go Outdated
The snapshot now records each column's target kind (EQL when the context
is the column identity, Custom when the target supplies it), and the
comparison uses it:

- A table change is a context change only when the message has an EQL
  column; a plaintext-only or Custom-only message loses nothing.
- A new column under an old one's context is guessed a database column
  rename only between EQL columns. Custom columns may share a context,
  and a column gone from a context another still writes is not a lost
  context.
- "Restore plan.Table" is shown only after building the plan with the
  old table proves it brings the context back, with a pin if one is
  also needed.

goldenPath also spells Windows device names (CON, NUL.x, COM1) and a
trailing dot the same way on every platform, so a subtest name cannot
fail on Windows alone or share another's snapshot there.
@coderdan
coderdan marked this pull request as ready for review October 3, 2026 22:50
@coderdan
coderdan requested a review from a team as a code owner October 3, 2026 22:50
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 3, 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-03T22:56:10.398800Z 79ac8e7 Draft marked ready
🔒 Security Review ✅ Completed 2026-10-03T22:58:49.062223Z 79ac8e7 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.

@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: 79ac8e77ec

ℹ️ 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/stackencrypt/plan/plantest/snapshot.go Outdated
Comment thread languages/golang/stackencrypt/plan/plantest/plantest.go Outdated
…robes

goldenPath spelled distinct subtest names alike ("a:b" and "a?b" were
both a_b.golden), so two tests read and wrote one snapshot. A name part
spelled differently from the test's, or one Windows reserves as a device,
now takes '~' and an FNV-32a hash of the original before its first dot.
No name spelled as it is contains '~', and the hash also leaves a device
stem unreserved, replacing the '_' suffix.

targetKind read one sample identity, so plan.Custom("plantest/probe")
was recorded as EQL. It now asks with two identities: a Custom context
is fixed, so it can equal one but never both.

@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

Nothing must change before merging. The package is well covered. It has the two acceptance cases, the 11-case change-classification table, the determinism and round-trip tests, the CRLF case, and the Windows path and collision cases. go test ./... passes in languages/golang/stackencrypt/plan/plantest on this branch.

The one finding worth taking soon is the rename guess in compare.go. An old column can lose its context. If exactly one new column carries the same facts, the failure says "likely the same field renamed". It then offers a checked plan.Column pin. It does this even when the two fields are unrelated. If the developer applies that pin, the code stores the new field's values in the old column under the old context. Nothing else in the message lists the new column, so the developer never learns that it is new. I reproduced this with a temporary test. The other findings listed here can wait.

Other findings not posted as comments

  • TestUpdateFlag (plantest_internal_test.go:563) sets the process-wide -update flag. updating() reads flag.Lookup("update") on flag.CommandLine. That is one value for the whole test binary, and the external plantest_test tests share that binary. Nothing runs in parallel today, so the test is correct now. If someone later adds t.Parallel() to TestPolicies, or to one of its subtests, that test can run while this test holds the flag at true. Golden would then rewrite testdata/TestPolicies/*.golden from the policy and report success, so a changed context would pass in CI. A seam in plantest.go that the test can replace (var lookupFlag = flag.Lookup) removes the shared state.
  • parse rejects four kinds of bad input, and only one of them has a test. TestTextOnlyAndUnreadableSnapshots covers an unknown line. There is no test for an empty or truncated file ("no table line", snapshot.go:270), an unterminated quoted value (snapshot.go:289), an unknown target kind (snapshot.go:253), or a second table line. When parse fails, explain drops the three-section summary and prints only the diff. A parse rule that rejects valid input therefore hides the context-change warning.
  • No test covers a read error that is not "the file does not exist" (plantest.go:181). If that branch goes away, the code reports a snapshot it cannot read as a missing snapshot. The failure then tells the developer to run -update, which hides the real cause.
  • No test covers the write failures in update mode (os.MkdirAll and os.WriteFile, plantest.go:190 and plantest.go:195). If either error is dropped, -update reports success while it writes no snapshot.
  • No test covers the error for a source that gives one field twice (snapshot.go:111). The code would write both facts with the same Field into the snapshot. Which one wins depends on the order the source returns them, so the snapshot stops being the same on every run. plan.PlanFor rejects a repeated encrypted field first. This check is therefore reached only through a repeated field that the policy decides Plaintext.
  • No test covers the branch that gives up on aligning a large snapshot (compare.go:375). A policy over a few hundred fields produces more than 2048 lines, so this branch runs on real input. Every diff test uses about 20 lines.
  • No test checks the line numbers and counts in the @@ hunk header (compare.go:351). Wrong numbers send the reader to the wrong line of a checked-in snapshot. The +, - and lines below the header would still be right, so the existing tests keep passing.
  • render writes a column's index terms unquoted (snapshot.go:178, through termList), while names, contexts and facts go through token. Today every TermKind.String() value is free of spaces, so nothing breaks. Closing this needs either a token call per term, or a comment that says the term names are a closed set with no spaces.
  • rerun (plantest.go:163) has one test case, TestPolicy/a.b. No test checks a top-level test name with no /, and that is the common case.

I dropped one finding from a source after I checked the code. The claim was that two probe identifiers cannot prove that a plan.Target binds the column identity as its context. targetKind (snapshot.go:70) reports EQL only when the context equals both probes, and a fixed plan.Custom context can equal at most one probe. To reach a wrong answer, a target must return id.String() for both probe identifiers and a fixed context for everything else. The discussion already covers the single-probe version of this, and the author fixed it in 6e7204f2e.

How this review was made
Agent Model Review type Result
claude claude-opus-5 test-gap 7 found, 2 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 1 found, 0 posted

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

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

Stack: not part of a stack.

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

return !before && !seenCol[n.name] && !oldContexts[n.context] && len(o.facts) > 0 && slices.Equal(n.facts, o.facts)
}); ok {
seenCol[n.name] = true
msg += fmt.Sprintf(" Field %s now writes column %s under %q with the same facts, so it is likely the same field renamed. %s",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fix in a follow-up: this message can name an unrelated new field as a rename, and the pin it offers then stores that field's values in the old column under the old context.

Impact: A developer removes one classified field and adds a different field with the same annotations in the same change. The old column's context is lost. Exactly one new column carries the same facts, so this branch runs and calls it "likely the same field renamed". pinAdvice checks the pin and the pin does work, so the hint looks verified. The developer applies it, and the new field's values are written into the old column under the old field's context. That column then holds two different kinds of data. Line 140 also sets seenCol[n.name] = true, which removes the new column from the "new column" list at line 164, so no other line tells the developer that this column is new.

Evidence: I added a temporary test to the package. The old field home_phone was removed and an unrelated new field work_phone was added, both annotated user.contact.phone. The failure printed exactly one line:

  - column home_phone: no field writes its context "individuals/home_phone" any more. Field work_phone (WorkPhone) now writes column work_phone under "individuals/work_phone" with the same facts, so it is likely the same field renamed. Pinning the rule that decides field work_phone (WorkPhone) with plan.Column("home_phone") keeps it.

There is no second line saying work_phone is new.

Fix: Keep the guess, but state the other reading and keep listing the new column. Two changes:

	}); ok {
		seenCol[n.name] = true
		msg += fmt.Sprintf(" Field %s now writes column %s under %q with the same facts, so it may be the same field renamed: %s"+
			" If %s is instead a new field and %s was removed, do not pin it: that would store two fields in column %s under one context.",
			fieldName(n.from), token(n.name), n.context, pinAdvice(m, facts, n.from, o, old.table),
			token(n.name), token(o.name), token(o.name))
		c.stored(o, n)

and add the new column to c.other as well, so it is still listed:

		c.other = append(c.other, fmt.Sprintf("new column %s, under %q, with terms [%s]. It is also named above as a possible rename of column %s.",
			token(n.name), n.context, termList(n.terms), token(o.name)))

Once a protobuf fact source exists, plan.Fact.Number is real evidence here, because a proto field keeps its number through a rename. Recording the number in the snapshot and requiring it to match turns the guess into a fact.

Found by 1 model: claude

}
if err != nil {
t.Error(err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fix in a follow-up: no test runs the exported Golden function in the failing direction.

Impact: If someone removes this t.Error(err) call, or changes it to t.Log, every caller's test keeps passing while a changed encryption context reports nothing. The whole package then checks nothing, and no test in this repository fails.

Evidence: golden_test.go:45 and golden_test.go:46 are the only calls to Golden, and both match their checked-in snapshot, so both pass. Every failure case in plantest_internal_test.go calls the unexported check directly. Golden holds three steps check does not: goldenPath(t.Name()), the -update flag read, and this t.Error.

Fix: Add a test in plantest_internal_test.go that gives Golden a stand-in for *testing.T. Embedding *testing.T satisfies testing.TB, so this compiles.

// recorder stands in for *testing.T to read what Golden reports.
type recorder struct {
	*testing.T
	failed bool
	msg    string
}

func (r *recorder) Error(args ...any) { r.failed = true; r.msg = fmt.Sprint(args...) }

func TestGoldenReportsAMissingSnapshot(t *testing.T) {
	r := &recorder{T: t}
	Golden(r, individualV1(), plan.ForMessage(nil, "individuals", base))
	if !r.failed {
		t.Fatal("Golden passed with no snapshot checked in")
	}
	if !strings.Contains(r.msg, filepath.Join("testdata", t.Name()+".golden")) {
		t.Errorf("message does not name the snapshot path: %s", r.msg)
	}
}

The test expects Golden to call Error and to name the path that goldenPath(t.Name()) returns.

Found by 1 model: claude

token(n.name), termList(n.terms), termList(o.terms)))
}
if o.kind != n.kind {
c.other = append(c.other, fmt.Sprintf("column %s: its target is %s, was %s, under the same context.", token(n.name), n.kind, o.kind))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: this line says "under the same context" even when the context changed.

Impact: compare calls stored(o, n) at line 92 for every column that kept its name, including a column whose context changed and that it has just reported under CONTEXT CHANGES. The developer then reads two statements about one column that contradict each other: the first says the context moved, the second says the target changed under the same context.

Evidence: I ran check on a column that moved from plan.EQL to plan.Custom("elsewhere/v1"). The failure held both lines:

  - column medicare_number: its context is "elsewhere/v1", was "individuals/medicare_number". ...
  - column medicare_number: its target is Custom, was EQL, under the same context.

Fix: Say "under the same context" only when it is true.

if o.kind != n.kind {
	where := ", under the same context"
	if o.context != n.context {
		where = ""
	}
	c.other = append(c.other, fmt.Sprintf("column %s: its target is %s, was %s%s.", token(n.name), n.kind, o.kind, where))
}

Found by 1 model: claude

}
for _, n := range cur.plaintext {
if _, before := oldPlain[n.field]; !before && !seenPlain[n.field] {
c.other = append(c.other, fmt.Sprintf("new plaintext field %s.", token(n.field)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: no test covers the "new plaintext field" message.

Impact: If a change drops this branch or puts its message in the wrong section, no test fails. A policy that starts deciding an existing field as Plaintext would then be reported wrongly, or not reported at all.

Evidence: TestChangesAreSortedByWhatTheyCost in plantest_internal_test.go:151 covers 11 kinds of change, including "plaintext now encrypted" and "plaintext field gone". It has no case for a field the policy newly decides Plaintext. I grepped the says: values in that table and none contains new plaintext field.

Fix: Add one case to that table.

"field newly decided Plaintext": {
	before: plan.ForMessage(nil, "individuals", base),
	after: plan.ForMessage(nil, "individuals", plan.FirstOf(
		plan.When(plan.Field("id"), plan.Plaintext()),
	).OrElse(base)),
	section: "OTHER CHANGES",
	says:    []string{"new plaintext field id."},
},

The test expects only OTHER CHANGES, holding new plaintext field id.

Found by 1 model: codex

colAt, plainAt = -1, len(s.plaintext)-1
case indented && toks[0] == "context" && len(toks) == 2 && colAt >= 0:
s.columns[colAt].context = toks[1]
case indented && toks[0] == "target" && len(toks) == 2 && colAt >= 0 && (toks[1] == kindEQL || toks[1] == kindCustom):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: a column entry with no target line parses, and a table rename is then called harmless when it is not.

Impact: parse requires a table line but requires nothing inside a column entry. A column with no target line gets kind == "". compare at line 63 asks whether any old column has kind == kindEQL to decide whether a table rename moves a context. With every kind empty the answer is no, so the rename lands in OTHER CHANGES with the text "No context moves with it: the message has no EQL column". For a snapshot whose columns really are EQL, that is the opposite of the truth. The per-column context diff still reports the loss, so nothing is missed outright, but the failure then holds a reassuring sentence next to a data-loss one. Reaching this needs a snapshot edited by hand, which the file header tells people not to do, so the risk is small.

Evidence: I ran parse on a snapshot whose one column had context and terms but no target line. It returned no error, and the column's kind was the empty string.

Fix: Require a context and a target line for every column, so an incomplete snapshot fails to parse and the caller falls back to the plain diff. Add this after the loop:

for _, c := range s.columns {
	if c.kind == "" || c.context == "" {
		return snapshot{}, fmt.Errorf("column %q has no context or target line", c.name)
	}
}

Found by 1 model: claude

if id, ok := strings.CutPrefix(context, string(table)+"/"); ok && id != "" {
tries = append(tries,
try{fmt.Sprintf("plan.Identity(%q)", id), []plan.RuleOption{plan.Identity(id)}},
try{fmt.Sprintf("plan.Column(%q), plan.Identity(%q)", column, id), []plan.RuleOption{plan.Column(column), plan.Identity(id)}},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: no test checks the advice that names both pins, plan.Column(...), plan.Identity(...).

Impact: This is the advice for the most expensive case: a field renamed after its database column was already renamed. If this try stops working, pinAdvice falls through to the next answer. The developer then reads either a single pin that does not restore the context, or "No plan.Column or plan.Identity pin ... brings it back". They would re-encrypt data that this pair of pins could have kept.

Evidence: The CONTEXT CHANGES section title at compare.go:47 promises this advice ("and plan.Identity after a database column rename"). The table in plantest_internal_test.go:151 asserts plan.Column("...") alone and plan.Identity("...") alone, and never the pair.

Fix: Add one case to that table.

"database column renamed, then the field renamed": {
	before: plan.ForMessage(nil, "individuals", plan.FirstOf(
		plan.When(plan.Field("medicare_number"), gov,
			plan.Column("medicare_num"), plan.Identity("medicare_id")),
	).OrElse(base)),
	after:   plan.ForMessage(nil, "individuals", base),
	src:     individualV2(),
	section: "CONTEXT CHANGES",
	says: []string{
		`column medicare_num: no field writes its context "individuals/medicare_id" any more.`,
		`with plan.Column("medicare_num"), plan.Identity("medicare_id") keeps it.`,
	},
},

The test expects the failure to name both pins in one sentence, because only both together restore the old column and the old context.

Found by 1 model: claude

@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.

Thanks for this @coderdan! Approved.

I have left some suggestions for improving the readability of the documentation, and fixing a bug in the examples.

Comment on lines +314 to +332
Run it once with `go test -run '^TestIndividualsPolicy$' -update` to write
`testdata/TestIndividualsPolicy.golden`, and check the file in. It lists the
message's table and, for every field the policy decides, what it is stored
as: an encrypted field's column, context, index terms and facts, or a
plaintext field's name and facts.

```text
table individuals

column email
context individuals/email
terms eq match
fact fides.data_categories user.contact.email

column medicare_number
context individuals/medicare_number
terms eq
fact fides.data_categories user.government_id
```

@auxesis auxesis Oct 4, 2026 •

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.

This example is missing the 3 header lines and the target lines that plantest.Golden writes. This suggestion adds them to the example, and adds the target to the sentence above it.

You can ignore this suggestion if you apply the plain-language rewrite of the whole section, which already includes it.

Suggested change
Run it once with `go test -run '^TestIndividualsPolicy$' -update` to write
`testdata/TestIndividualsPolicy.golden`, and check the file in. It lists the
message's table and, for every field the policy decides, what it is stored
as: an encrypted field's column, context, index terms and facts, or a
plaintext field's name and facts.
```text
table individuals
column email
context individuals/email
terms eq match
fact fides.data_categories user.contact.email
column medicare_number
context individuals/medicare_number
terms eq
fact fides.data_categories user.government_id
```
Run it once with `go test -run '^TestIndividualsPolicy$' -update` to write
`testdata/TestIndividualsPolicy.golden`, and check the file in. It lists the
message's table and, for every field the policy decides, what it is stored
as: an encrypted field's column, context, target, index terms and facts, or
a plaintext field's name and facts.
```text
# Written by plantest.Golden: what the policy stores each field it decides as.
# Regenerate it with go test -update; do not edit it by hand.
# A column's context is bound into every ciphertext and query term written under it, so once a row is written it must never change.
table individuals
column email
context individuals/email
target EQL
terms eq match
fact fides.data_categories user.contact.email
column medicare_number
context individuals/medicare_number
target EQL
terms eq
fact fides.data_categories user.government_id
```

Comment on lines +299 to +342
### Checking contexts in with a golden test

Nothing on the write path notices a changed context: rename a proto or
struct field with no pin and new rows are simply written under a new one,
while the rows already written stop decrypting. The `plan/plantest` package
turns that into a test failure:

```go
import "github.com/cipherstash/stack/languages/golang/stackencrypt/plan/plantest"

func TestIndividualsPolicy(t *testing.T) {
plantest.Golden(t, source, Individuals)
}
```

Run it once with `go test -run '^TestIndividualsPolicy$' -update` to write
`testdata/TestIndividualsPolicy.golden`, and check the file in. It lists the
message's table and, for every field the policy decides, what it is stored
as: an encrypted field's column, context, index terms and facts, or a
plaintext field's name and facts.

```text
table individuals

column email
context individuals/email
terms eq match
fact fides.data_categories user.contact.email

column medicare_number
context individuals/medicare_number
terms eq
fact fides.data_categories user.government_id
```

From then on the test builds the plan as `MustPlanFor` does at startup and
fails when it no longer matches the file, sorting the changes by what they
cost. A changed context is data loss and is reported first, with the
`plan.Column` (or `plan.Identity`) pin that keeps it; a changed target, such
as different index terms or a plaintext field now encrypted, is a migration;
anything else, such as a new field, is reported last. Encrypted fields are
listed by column, not by field name, so a rename the policy pins leaves the
file unchanged and the test passes. When a change is intended, rerun with
`-update` and review the diff.

@auxesis auxesis Oct 4, 2026 •

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.

A plain-language rewrite of this section: the rule first, short sentences, numbered steps, and one sentence per line.

Suggested change
### Checking contexts in with a golden test
Nothing on the write path notices a changed context: rename a proto or
struct field with no pin and new rows are simply written under a new one,
while the rows already written stop decrypting. The `plan/plantest` package
turns that into a test failure:
```go
import "github.com/cipherstash/stack/languages/golang/stackencrypt/plan/plantest"
func TestIndividualsPolicy(t *testing.T) {
plantest.Golden(t, source, Individuals)
}
```
Run it once with `go test -run '^TestIndividualsPolicy$' -update` to write
`testdata/TestIndividualsPolicy.golden`, and check the file in. It lists the
message's table and, for every field the policy decides, what it is stored
as: an encrypted field's column, context, index terms and facts, or a
plaintext field's name and facts.
```text
table individuals
column email
context individuals/email
terms eq match
fact fides.data_categories user.contact.email
column medicare_number
context individuals/medicare_number
terms eq
fact fides.data_categories user.government_id
```
From then on the test builds the plan as `MustPlanFor` does at startup and
fails when it no longer matches the file, sorting the changes by what they
cost. A changed context is data loss and is reported first, with the
`plan.Column` (or `plan.Identity`) pin that keeps it; a changed target, such
as different index terms or a plaintext field now encrypted, is a migration;
anything else, such as a new field, is reported last. Encrypted fields are
listed by column, not by field name, so a rename the policy pins leaves the
file unchanged and the test passes. When a change is intended, rerun with
`-update` and review the diff.
### Catch a changed context with a golden test
A field's context must never change after you write data under it.
The library binds the context into every ciphertext and index term it writes for the field.
If the context changes, the rows you already wrote stop decrypting, and their index terms stop matching queries.
You get no error when a context changes.
If you rename a proto field or a Go struct field, its context changes too.
Your code then writes new rows under the new context.
To keep the old context, pin the column with `plan.Column` in the field's rule.
The `plan/plantest` package makes a changed context fail a test.
It gives you a golden test.
A golden test compares the plan with a file you commit, called the golden file.
To add the test:
1. Write a test that calls `plantest.Golden` with your source and your policy:
```go
import "github.com/cipherstash/stack/languages/golang/stackencrypt/plan/plantest"
func TestIndividualsPolicy(t *testing.T) {
plantest.Golden(t, source, Individuals)
}
```
The plantest package defines the `-update` flag.
If your test package defines its own `-update` flag, remove it, and read plantest's flag instead:
```go
func update() bool {
f := flag.Lookup("update")
return f != nil && f.Value.String() == "true"
}
```
2. Run the test once with `-update`.
This writes the golden file to `testdata/TestIndividualsPolicy.golden`:
```sh
go test -run '^TestIndividualsPolicy$' -update
```
3. Read the golden file, then commit it.
The golden file names the message's table.
It also lists each field the policy decides:
- An encrypted field shows its column, context, target, index terms and facts.
- A plaintext field shows its name and facts.
This is the golden file for the `Individuals` policy above:
```text
# Written by plantest.Golden: what the policy stores each field it decides as.
# Regenerate it with go test -update; do not edit it by hand.
# A column's context is bound into every ciphertext and query term written under it, so once a row is written it must never change.
table individuals
column email
context individuals/email
target EQL
terms eq match
fact fides.data_categories user.contact.email
column medicare_number
context individuals/medicare_number
target EQL
terms eq
fact fides.data_categories user.government_id
```
After that, each test run builds the plan the same way `MustPlanFor` does at startup.
If the plan does not match the golden file, the test fails.
The failure lists each change, with the most costly changes first:
1. **Context changes.**
These lose data.
If a rename caused the change, the failure names the `plan.Column` or `plan.Identity` pin that keeps the old context.
2. **Target changes.**
These need a migration.
For example, the index terms are different, or a plaintext field is now encrypted.
3. **Other changes.**
These do not affect rows you already wrote.
For example, the policy decides a new field.
The golden file lists encrypted fields by column, not by field name.
If you rename a field and your policy pins its column, the file stays the same and the test passes.
Change a context only before you write rows under it, or ship a migration with the change.
If you made a change on purpose, run the test again with `-update`.
Then read the diff of the golden file before you commit it.

plantest.Golden(t, source, Individuals)
}
```

@auxesis auxesis Oct 4, 2026 •

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.

A test package that imports plantest and defines its own -update flag panics at startup with "flag redefined: update". This suggestion adds a warning before the step that runs -update, and shows how to read plantest's flag instead.

You can ignore this suggestion if you apply the plain-language rewrite of the whole section, which already includes it.

Suggested change
The plantest package defines the `-update` flag.
If your test package defines its own `-update` flag, remove it, and read plantest's flag instead:
```go
func update() bool {
f := flag.Lookup("update")
return f != nil && f.Value.String() == "true"
}
```

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.

5 participants