Skip to content

feat(stack-encrypt)!: dynamic::record lowers into the plan builder - #1093

Merged
coderdan merged 14 commits into
mainfrom
feat/stack-encrypt-record-lowering
Oct 7, 2026
Merged

coderdan merged 14 commits into
mainfrom
feat/stack-encrypt-record-lowering

Conversation

@coderdan

@coderdan coderdan commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Data-driven record encryption now uses the same plan builder as typed Rust encryption, giving the Go runtime and Rust one place to enforce field rules, batch key requests, and shape encrypted output. Previously, the two paths implemented those rules separately and could produce different stored bytes for the same apparent field type. This PR unifies execution while preserving the existing data-plan encoding; Rust records that share stored rows with a binding must use dynamic::Value fields.

Changes

  • Shared execution: dynamic::record parses a data plan and converts it into the existing builder's encryption, indexing, and passthrough operations. Its separate execution loop, batching, and output shaping are removed.
  • Runtime values and indexes: Adds dynamic::Value as a builder field, runtime-sized index sets, and index operations selected from the supplied value. Empty index sets are rejected.
  • Field context: Requires labels with at least two plain segments. The shared prefix identifies the record context, the final segment identifies the field, and caller extensions are applied through the builder. Invalid labels and duplicate encrypted-field identities are rejected.
  • Stored encoding: Every data-plan field keeps the tagged Value encoding. A declared type checks value kinds and permits indexes; adding it to an existing field does not change that field's stored bytes.
  • Breaking Rust signatures: Encryption and decryption return a pending operation after synchronous plan validation. Adds Output::Passthrough; the Go runtime awaits the operation and reports plan errors as encoding errors.
  • Compatibility fixture: Adds deterministic records produced by the typed builder and data-plan path. Each path opens the other's records and produces identical search-index bytes.

Verification

The existing PR description reports the following checks. This description edit did not rerun them.

  • Rust: cargo fmt --all --check; cargo clippy --workspace --all-targets --all-features -- -D warnings; mise x --env test -- cargo nextest run --workspace --all-features (968 passed).
  • Documentation: mise run test:doc and mise run doc, with warnings treated as errors.
  • Go runtime: mise run wasm:wasi-check, mise run wasm:guest:build, and mise run wasm:guest:test (51 passed).
  • Go: From languages/golang, go build ./..., go vet ./..., and go test ./... passed. Live tests were skipped without credentials.
  • Fuzzing: mise run fuzz:check-record -- -runs=0 and mise run fuzz:plan-build -- -runs=0 replayed the existing input corpora.
  • Compatibility: The record fixture checks that both execution paths can open each other's output and derive identical search-index bytes.

Related

Review notes

  • Start with Plan::lower in dynamic/record.rs, then the context rules and tests/record_lowering.rs fixture test.
  • Encoding is the main design tradeoff. Bare Rust u32 and String fields still do not share data-plan field encoding, although their search-index bytes agree. Automatically detecting the encoding is unsafe: a bare string starting with a newline can also look like a tagged string and decrypt to the wrong value without an error. Sharing rows therefore requires dynamic::Value fields until the encoding is unified or authenticated as part of the encryption context.
  • Mutation-testing caveat: Testing only changed lines may report surviving mutations in defensive branches for term-count mismatch, internal prefix errors, and invalid UTF-8 fallback. The original description identifies these branches as unreachable from a well-formed plan.

Summary by CodeRabbit

  • New Features

    • Added support for defining record encryption and decryption from data-driven plans, including passthrough fields and runtime-sized index sets.
    • Added dynamic values and term generation for supported index operations.
    • Record outputs created through data-driven plans can be opened by corresponding typed Rust workflows, and vice versa.
  • Behavior Changes

    • Planned field contexts now require labels with at least two plain segments. Invalid labels and conflicting field assignments are rejected during record operations.
    • Record encryption and decryption now defer key requests until execution and batch them across fields and rows.
    • Empty runtime-sized index sets now return an error.

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

changeset-bot Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 0713d1d

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

Review in Change Stack →

Warning

Review limit reached

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.

Next included review available in 38 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d00affd3-9a5c-40ad-9f46-a4517b45ef07
📥 Commits

Reviewing files that changed from the base of the PR and between bb91a4e and 0713d1d.

📒 Files selected for processing (7)
  • languages/golang/stackencrypt/README.md
  • languages/golang/stackencrypt/plan/plantest/compare.go
  • languages/golang/stackencrypt/plan/plantest/plantest_internal_test.go
  • languages/golang/stackencrypt/plan/plantest/snapshot.go
  • languages/golang/stackencrypt/plan/plantest/testdata/TestPolicies/individuals.golden
  • packages/stack-encrypt/CHANGELOG.md
  • packages/stack-encrypt/src/plan/mod.rs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 58e5b45b-5fac-43c0-aec3-2c67406346b7
📥 Commits

Reviewing files that changed from the base of the PR and between bf38e17 and bb91a4e.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • packages/stack-encrypt/CHANGELOG.md
  • packages/stack-encrypt/src/dynamic/record.rs
  • packages/stack-encrypt/tests/record_lowering.rs

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Dynamic record plans now lower through the shared Rust plan engine and use pending encryption and decryption operations. The changes add passthrough fields, dynamic value and index support, and structured field-label validation across Rust and Go. Tests compare terms and cross-opening with typed Rust chains.

Changes

Dynamic record lowering and field labels

Layer / File(s) Summary
Field-label contract and Go plan validation
languages/golang/stackencrypt/context.go, languages/golang/stackencrypt/label.go, languages/golang/stackencrypt/record.go, languages/golang/stackencrypt/plan/*, languages/golang/stackencrypt/*_test.go, languages/golang/stackencrypt/fuzz/fuzz_targets/check_record.rs
Go plan fields and custom targets now use validated multi-segment labels. Tests and fuzz inputs cover accepted labels, context extensions, and rejected shapes.
Go guest record calls
languages/golang/stackencrypt/guest/src/*, languages/golang/stackencrypt/guest/tests/native_ops.rs, languages/golang/stackencrypt/guest_test.go
Guest record operations map plan errors to encoding status and use structured contexts. Tests cover probe and record contexts, including plans rejected at record-call time.
Dynamic record plan lowering
packages/stack-encrypt/src/dynamic/record.rs, packages/stack-encrypt/src/dynamic/mod.rs, packages/stack-encrypt/src/plan/mod.rs
Dynamic record plans now lower fields into the shared plan engine and return pending operations. The plan supports passthrough outputs and validates field labels, extensions, and stored-record shapes.
Dynamic values and index operations
packages/stack-encrypt/src/dynamic/value.rs, packages/stack-encrypt/src/dynamic/term.rs, packages/stack-encrypt/src/target/*, packages/stack-encrypt/src/plan/error.rs, packages/stack-encrypt/tests/index.rs
Rust adds Value and TermBytes, runtime IndexSpec dispatch, ordered context extensions, and runtime-sized Vec index sets. Empty index sets fail with PlanError::EmptyIndexes before a key request.
Record interoperability fixture
packages/stack-encrypt/tests/common/mod.rs, packages/stack-encrypt/tests/record_lowering.rs, packages/stack-encrypt/tests/fixtures/*, packages/stack-encrypt/Cargo.toml, packages/stack-encrypt/CHANGELOG.md, biome.json
Integration tests compare typed-chain and data-plan record terms and cross-opening. A deterministic test key source and committed fixture support those checks.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~60 minutes

Change: Feature · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant DataPlan
  participant DynamicRecord
  participant PlanBuilder
  participant KeysetCipher
  participant PlanOpener
  DataPlan->>DynamicRecord: provide plan and field values
  DynamicRecord->>PlanBuilder: lower fields into plan operations
  PlanBuilder->>KeysetCipher: run pending encryption
  KeysetCipher-->>DynamicRecord: return record outputs
  DynamicRecord->>PlanOpener: open record with plan
  PlanOpener-->>DynamicRecord: return field values
Loading

Merge Risk: ⚪ Minimal · up to bb91a

Dynamic record encryption now runs through the shared plan engine, and tests check that its records cross-open with typed Rust records. No outstanding issue was found that should block merging.

Security Architecture Review

Security architecture risk: 🔵 Low · up to bb91a

The inspected paths preserve ciphertext authentication and keyset restrictions while consolidating encryption rules. The main risks are breaking label requirements and conditional Rust/Go compatibility. Historical-data migration and interrupted-operation behavior are not fully demonstrated.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • observed — Encryption remains scoped to a keyset cipher. Decryption through Scope::Keyset rejects foreign-keyset requests, while Scope::Client permits requests across the client's accessible keysets. This scope distinction already existed in the base record executor.

Security Findings and Attack Paths

  • observed — The inspected stored-tree substitution path remains blocked: a writer cannot replace a planned ciphertext c subtree with passthrough plaintext, including nested passthrough nodes, because recursive validation rejects it before constructing decryption work. Legitimate passthrough is accepted only for fields explicitly planned that way and provides no authentication.

Trust Boundaries and Controls

  • observed — Field labels remain cryptographic identity inputs rather than merely output names. The cross-path test swaps two same-typed fields and requires opening to fail through the key source, exercising label binding independently of type checks. Declared sealed-field kinds are checked after authenticated opening.

Resilience and Maintainability Implications

  • inferred — Pending owns per-operation requests and is consumed on awaiting. Failed assemblies discard requests, and record results are returned only after settlement and complete adaptation, containing partial local failures. The base already used the same settlement mechanism, so an introduced cancellation-related security regression is not established. Remote side effects and cleanup after interrupted KMS calls remain unverified.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning [#1059] dynamic::record lowers plans into the shared builder, retains runtime scalar-to-index dispatch, returns Pending after synchronous validation, and adds the updated cross-opening fixture wit… Make primitive Rust fields and data-plan fields use compatible stored encodings, or bind the encoding to the authenticated context so incompatible readers fail closed. Add cross-opening tests for primitive fields.
Docstring Coverage ⚠️ Warning Docstring coverage is 70.26% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 232 functions across 26 files. (1 skipped… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the main change: dynamic::record now lowers into the shared plan builder.
Out of Scope Changes check ✅ Passed The dynamic value and term support, context and index validation, guest updates, Go label handling, and compatibility fixture support [#1059] plan lowering, shared field rules, execution, or its updat…
Full details: Linked Issues check

Explanation

[#1059] dynamic::record lowers plans into the shared builder, retains runtime scalar-to-index dispatch, returns Pending after synchronous validation, and adds the updated cross-opening fixture with matching term bytes. The guest also awaits the operation. However, the issue requires the tagged-versus-untagged divergence to close. The PR uses tagged dynamic::Value for data-plan leaves, but primitive Rust u32 and String fields still use different encodings and do not interoperate with those leaves. The PR description defers this compatibility decision to #1082, so the issue’s encoding requirement remains unmet.

Full details: Docstring Coverage

Explanation

Docstring coverage is 70.26% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 232 functions across 26 files. (1 skipped: 1 unsupported.)

  • 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-06T08:07:01.270670Z 7243fa1 PR opened
🔒 Security Review ✅ Completed 2026-10-06T08:09:42.738294Z 7243fa1 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.

@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: 7243fa1f75

ℹ️ 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 packages/stack-encrypt/src/dynamic/record.rs
@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
60 0 54 0

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

coderdan added a commit that referenced this pull request Oct 6, 2026
Codex, on PR #1093: the lowering refuses every plan field whose context is
not a label of at least two plain segments, but NewPlan and PlanFromTags
still accepted a one-part Context (MustContext("c"), the context= tag), a
one-segment label, bytes or an integer, and Context.With on a field, so
such a plan built fine and every record call then failed with ErrEncoding.
The mistake belongs at the earliest stage (Go principle 1), so the Go side
now refuses at plan construction what the lowering refuses.

Context.fieldLabel is the rule, one place: a planned field's Context must
be a flat list of plain text parts, two or more, and is read as that Label.
A one-part context, an extended context and a part that is not text are
refused with the field named and the accepted form in the message; a flat
list built with NewContext("users").With("age") is the label it spells, as
the lowering reads it. NewPlan applies it to every field; PlanFromTags
refuses the context= tag outright, naming label= as the tag to use, after
the option loop so a repeated or doubled option is still reported as what
the author wrote.

plan.Custom took one arbitrary text part and bound it with NewContext; it
now parses its argument as a Label, so a Custom target binds a label of
two or more plain segments and a one-segment or unplain one is refused at
Build. plantest reads a Custom column's context the same way. The golden
files do not change: a Custom context was always rendered as the text it
was written as, which is a label's String() too.

Two of the lowering's rules stay with the record call, documented on
NewPlan: every field of a plan must sit under one table, and no two fields
may bind one label. NewPlan cannot hold them without refusing policies the
plan package and its golden tests pin (a Custom target beside a table's
EQL columns; several fields under one Custom context), and that package is
replaced by the next PR in the stack.

Docs follow: Context, NewContext, MustContext, FieldPlan.Context, NewPlan,
the stash tag table, Label's naming table and plan.Custom now say which
contexts a planned field binds and which a probe takes. Tests cover each
refusal; the tests that used the one-part form are rewritten to labels.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
@coderdan
coderdan force-pushed the feat/stack-encrypt-record-lowering branch from 4aaa3b7 to 9dd4a74 Compare October 6, 2026 08:16
@coderdan
coderdan requested review from cipherstash-bot and removed request for cipherstash-bot October 6, 2026 08:17

@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

One finding must change before merge. A row sealed with no "type" opens with an extra leading newline, and no error, after its plan declares "type": "string". Before merge, do these three things:

  1. Decide the migration rule for #1082. The rule must stop the Go generator from adding "type": "string" or "type": "uint32" to a column with stored rows.
  2. Correct the decrypt rustdoc and the CHANGELOG to match.
  3. Add tests to mod given_the_leaf_encoding that check what an untyped row opens to under a string plan and under a uint32 plan.

The other findings can wait for a follow-up.

The lowering itself reads well. Plan::lower, the context split and the guest changes match the description. The record fixture test is a good proof that the typed chain and the data plan open each other's records. Two of the four source reviews found no issues.

Other findings not posted as comments

  • Optional: the_fixture_records_are_bound_to_their_labels (packages/stack-encrypt/tests/record_lowering.rs:477) swaps a uint32 field with a string field and asserts only is_err(). A bare u32 reader can refuse a string leaf because of its length, not because of its label. Swap email and notes (both string) instead. Then assert Err(stack_encrypt::Error::Kms(_)). This shows that the key source refused the label, as the test's doc comment says.
  • Optional: the identity branch in Plan::lower (packages/stack-encrypt/src/dynamic/record.rs:475) is checked only on the built plan. This branch runs when a record key differs from the last segment of its label. Every Go plan takes it, for example key Age with label users/age. No test seals such a field. So no test checks that the leaf opens under the label, or that its term equals the label's probe.
  • Optional: no guest test runs ops::decrypt_record to a PlanError::FieldType and checks for STATUS_ENCODING. a_plan_refusal_is_the_callers_input (languages/golang/stackencrypt/guest/src/status.rs:82) checks the new mapping only with PlanError::NoContext.
  • Optional: the committed fixture (packages/stack-encrypt/tests/record_lowering.rs:65) holds no field with an extended context and no field without "type". The Go binding sends both today. So the planned Go reader of this fixture cannot check either case.
How this review was made
Agent Model Review type Result
claude claude-opus-5-5 test-gap 4 found, 4 posted
claude claude-opus-5-5 rust 3 found, 3 posted
codex gpt-5.6-terra test-gap 0 found, 0 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. 0 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: 1 earlier and 0 later pull request(s) (#1090, *️⃣ #1093). *️⃣ marks this pull request.

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

Comment thread packages/stack-encrypt/src/dynamic/record.rs Outdated
Comment thread packages/stack-encrypt/src/dynamic/term.rs Outdated
Comment thread packages/stack-encrypt/src/dynamic/record.rs Outdated
Comment thread packages/stack-encrypt/src/dynamic/record.rs Outdated
Comment thread packages/stack-encrypt/src/dynamic/term.rs
Comment thread languages/golang/stackencrypt/record.go
coderdan added a commit that referenced this pull request Oct 6, 2026
…ver its type

cipherstash-bot, on PR #1093: a row sealed with no "type" opened under a
plan that later declared "type": "string" as the same text with a leading
U+000A, and no error. The typed string field sealed a bare String leaf and
the untyped one vitaminc's tagged leaf, 0x0A then the UTF-8; a bare String
reader accepts the tag as a line feed, and the opened value is still a
string, so the kind check passed too. A uint32 field failed instead, with
Error::Aead, which the guest reports as tampering. The two encodings cannot
be told apart by inspection: a bare string that begins with U+000A is a
valid tagged string, and a tagged string is a valid bare one. So no reader
can refuse the other's leaf, and a type declared after rows exist was a
silent migration.

The lowering now seals every field as a Value, the tagged leaf, whatever
its declared type. The type admits indexes and checks kinds, as before,
and decides nothing about the bytes, so declaring one later changes no
leaf and a binding that starts sending "type" (#1082) re-encrypts nothing;
the tests pin both directions. The typed-leaf lowering, Leaf and the
Index<u32> and Index<String> impls of IndexSpec go with it; a lone
IndexSpec is an index set of one, so a Rust chain over Value fields names
its indexes as the data plan does.

What this gives up, and where it is recorded: a Rust u32 or String field
under the same label shares a data field's terms and not its leaf. A Rust
record whose rows a binding must open declares Value fields, which is the
same declaration as the data plan's, and the fixture now proves the
lowering against such a chain. One leaf encoding both authors read, or the
encoding bound into the leaf's context so the wrong reader fails closed,
is a change to the Rust chain's bytes and is left to #1082 as a decision,
named in the module docs and the CHANGELOG. The String-plaintext custody
finding (record.rs:1092) is moot by the same change: no plaintext leaves
its Protected buffer.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
coderdan added a commit that referenced this pull request Oct 6, 2026
…seShape

cipherstash-bot, on PR #1093: the output adapters read the record the
engine built with FieldValues::take, whose refusals are PlanError::NotInValue
and PlanError::FieldType; the ? turned those into Error::Plan, which the
guest reports as STATUS_ENCODING, "your input is wrong". Only a bug in this
module can make the engine's record disagree with the plan it was built
from, so the host would have looked for a fault in its own data and found
none. The adapters now take slots through one helper that reports a missing
or mistyped slot as Error::ResponseShape — what a miscounted term list in
the same file already is — which the guest maps to STATUS_INTERNAL. The
only Error::Plan the adapters still raise is the kind check on an opened
value, which is about the caller's data. A unit test drives both adapters
with a missing and a mistyped slot.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
coderdan added a commit that referenced this pull request Oct 6, 2026
…pair

cipherstash-bot, on PR #1093: no test ran the ORE and OPE arms over a
typed uint32 or string field, nor a non-default match index over a typed
string, and nothing ran the failure branch of IndexSpec's public Index
impl. The typed-leaf arms are gone with the one-encoding fix, but the
concern stands for the Index<Value> dispatch and the output key each term
rides under: an arm calling the wrong operation still compiles, and the
stored terms would then match no probe, with no error. A table test seals a
typed uint32 and string field under each index the kind admits (equality,
ORE, OPE, default and wide match) and checks the stored term is the one
dynamic::term derives under the field's context. A second test runs
IndexSpec as an Index<Value> directly over three pairs the scheme refuses
and checks the run fails with the dynamic Error::Term, naming the index,
inside Error::Other.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
coderdan added a commit that referenced this pull request Oct 6, 2026
…fused there

cipherstash-bot, on PR #1093: NewPlan documents two rules it leaves to the
call — every field under one table, no two fields under one label — and no
Go test showed the call refusing them. One does now, on an uninitialised
guest: both plans build, and EncryptRecords and DecryptRecord each refuse
them as ErrEncoding before looking for a cipher. If the guest's refusal
ever stops being the caller's input, this is the test that says so.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
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
coderdan requested a review from auxesis October 6, 2026 23:16
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 force-pushed the feat/stack-encrypt-record-lowering branch from 6413470 to eb3b168 Compare October 6, 2026 23:20
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
coderdan force-pushed the feat/stack-encrypt-record-lowering branch from eb3b168 to 3e159eb Compare October 7, 2026 00:36
Base automatically changed from fix/cip-4301-oidc-per-user-tokens to main October 7, 2026 00:55
@coderdan
coderdan force-pushed the feat/stack-encrypt-record-lowering branch from 3e159eb to bf38e17 Compare October 7, 2026 00:55
auxesis added a commit that referenced this pull request Oct 7, 2026
… key source can refuse

cipherstash-bot, on PR #1093: the_fixture_records_are_bound_to_their_labels
swapped age (uint32) and notes (string) and asserted only is_err(). Both
fields declare a "type", so the per-field type check fails that decrypt
whether or not the key source refuses the label, and the test could not
tell the two apart. It now swaps email and notes, both string fields, so
the type check passes and only the label binding can refuse, and it
requires Error::Kms. With the deterministic source's descriptor check
disabled the test fails with Error::Aead; is_err() would have passed.

Claude-Session: https://claude.ai/code/session_016CfyFnsnw5NWKPojCQRxM9
coderdan and others added 12 commits October 6, 2026 20:44
Closes the second executor ADR-0007 names. dynamic::record used to walk a
data plan itself: it called the term functions and the seal path per
field, collected the pendings and merged them with Pending::all, and never
touched the Encryption descriptions the derive and a Rust chain run. The
rules therefore existed twice, and had drifted. It is now a lowering: the
parsed plan is built into the same Plan::context(c).fields() a Rust chain
writes, with encrypt, encrypt_index, index or passthrough per field, and
run through KeysetCipher::run and the plan's opener. The module's own
per-field loop, batching and output shaping are gone; what remains around
the engine is an adapter from the wire value to FieldValues and back, and
the fail-closed checks a binding needs before it has a cipher.

A plan yields a Pending synchronously, so encrypt and decrypt lose their
async: each checks and converts its input with no cipher and returns the
plan's Pending, whose failure is the crate's Error. The guest awaits it in
the same block_on it always had, and classifies Error::Plan as the
caller's input (STATUS_ENCODING), which is what it is.

Three decisions the ADR left to the implementation, and why:

Context. A fields plan has one context and seals every field under
<context>/<identity>; the data grammar gave each field its whole context.
A field's "context" must now be the field's label (a list of at least two
plain segments, what a Go label= tag sends), optionally extended by scalar
parts nested to the left as Go's ExtendContext nests them; the last
segment is the identity, the rest the plan's context, and every field of a
plan must share the prefix and the extension, which becomes the chain's
.extend(..). A bare text part, an integer or a one-segment label is
refused: the plan cannot express a field outside the record context, and
the derive lost that in #1073 for the same reason. The grammar on the
wire is otherwise unchanged, and so is the stored record shape.

Leaf encoding. A derive over a bare u32 sealed four untagged bytes while
a data plan sealed vitaminc's tagged FfiValue leaf, and the old rustdoc
recorded that as by design. The declared "type" is the data form of the
chain's ::<F>: a field typed uint32 or string lowers to a u32 or String
field and runs exactly the operations encrypt_index::<u32> runs, so the
derive and a data plan interchange ciphertexts and terms (pinned by a
test that opens each with the other). Every other kind, and an untyped
field, seals the tagged encoding, because vitaminc gives those kinds no
bare Rust leaf (u64, bool and the floats have no Encrypt) and an untyped
field has no type to name; that half is transitional until "type" is
required (#1082).

What the builder lacked, added once for every author rather than in the
record module: Vec<I> as an index set sized at run time (an empty one is
PlanError::EmptyIndexes when it runs); IndexSpec as an Index of u32, of
String and of the new dynamic::Value, each with TermBytes as its term, so
an index named as data runs through indexed() like one named as a type;
Value, an FfiValue as a plan field's plaintext, Clone by deep copy into
fresh Protected payloads, since the borrowed engine clones what it
consumes and FfiValue deliberately is not Clone; DeclaredContext::with,
so an extension of several parts nests to the left as NonEmpty::with
does; and a crate-internal chosen() constructor, the one description
whose operation is picked by the value it is handed, which is the one
step that stays dynamic. dynamic::term runs the same dispatch.

Output::Passthrough joins the wire grammar ("passthrough", a field's only
output) so a record can carry a field whole, as the ADR's amendment says
generated Go code will. Two sealed fields under one label are now refused
(the builder's SharedIdentity); the executor accepted them.

The guest's native tests spell their plan contexts as labels; the fuzz
target for check_record generates label-shaped contexts so plans still
parse. The Go module's API and exports are unchanged; only the Rust
behind se_encrypt_record and se_decrypt_record changed.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
ADR-0007 as amended on 2026-10-06 makes a shared fixture, not a snapshot,
the proof that the typed chain and the data-plan lowering are one engine:
each opens the records the other sealed, and both derive the same bytes
for each term. tests/fixtures/record_lowering.json holds one record from
each author under one declaration, with their term bytes, and
tests/record_lowering.rs opens each with the other, today's records and
the committed ones alike. The README beside it gives the schema a Go test
reads later, once generated Go code is the third author; the Go reader is
not in this change.

FakeDataKeySource hands out random keys and remembers them in-process, so
a committed record sealed under it could never open again. The fixture
is sealed under a DeterministicSource in tests/common instead: every key
and tag is SHA-256 over a seed, the leaf's descriptor and its IV, so a
reader with the seed re-derives the key from what the leaf stores, and a
leaf moved under another field's label is refused as ZeroKMS would refuse
it (a test moves one). The index key is the fake's, deterministic per
keyset, so the fixture's terms are the terms every other test derives.
sha2 joins the dev-dependencies for it, at the version stack-kms's fake
already uses.

Regenerate with STACK_ENCRYPT_UPDATE_FIXTURES=1. Only the sealed bytes
change between runs, since every leaf carries a fresh nonce; the test
fails if the plan, the plaintext or the terms do.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
Two checks failed on the first push of this branch.

The plan module's rustdoc linked `crate::dynamic::record`, which exists
only with the `dynamic` feature, so `cargo doc --no-default-features`
(the WASI job's no-http docs step) failed on a broken intra-doc link. The
sentence now names the module without a link.

Biome's formatter wanted the record fixture's JSON arrays on one line. The
fixture is generated by the `record_lowering` test under
`STACK_ENCRYPT_UPDATE_FIXTURES=1`, so it joins the other generated files
that `biome.json` excludes rather than being hand-formatted after each
regeneration.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
Codex, on PR #1093: the lowering refuses every plan field whose context is
not a label of at least two plain segments, but NewPlan and PlanFromTags
still accepted a one-part Context (MustContext("c"), the context= tag), a
one-segment label, bytes or an integer, and Context.With on a field, so
such a plan built fine and every record call then failed with ErrEncoding.
The mistake belongs at the earliest stage (Go principle 1), so the Go side
now refuses at plan construction what the lowering refuses.

Context.fieldLabel is the rule, one place: a planned field's Context must
be a flat list of plain text parts, two or more, and is read as that Label.
A one-part context, an extended context and a part that is not text are
refused with the field named and the accepted form in the message; a flat
list built with NewContext("users").With("age") is the label it spells, as
the lowering reads it. NewPlan applies it to every field; PlanFromTags
refuses the context= tag outright, naming label= as the tag to use, after
the option loop so a repeated or doubled option is still reported as what
the author wrote.

plan.Custom took one arbitrary text part and bound it with NewContext; it
now parses its argument as a Label, so a Custom target binds a label of
two or more plain segments and a one-segment or unplain one is refused at
Build. plantest reads a Custom column's context the same way. The golden
files do not change: a Custom context was always rendered as the text it
was written as, which is a label's String() too.

Two of the lowering's rules stay with the record call, documented on
NewPlan: every field of a plan must sit under one table, and no two fields
may bind one label. NewPlan cannot hold them without refusing policies the
plan package and its golden tests pin (a Custom target beside a table's
EQL columns; several fields under one Custom context), and that package is
replaced by the next PR in the stack.

Docs follow: Context, NewContext, MustContext, FieldPlan.Context, NewPlan,
the stash tag table, Label's naming table and plan.Custom now say which
contexts a planned field binds and which a probe takes. Tests cover each
refusal; the tests that used the one-part form are rewritten to labels.

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

The per-PR mutants gate reported seven survivors in the lowering's new
code: TermBytes::as_bytes and its AsRef impl could return an empty slice
or a one-byte one, and Indexes<S> for Vec<I>::specs could return an empty
list, with no test the wiser. as_bytes and AsRef are the read side of a
dynamic term, the pair every other term type offers, so they stay and a
test now reads them: a term derived through IndexSpec's Index<u32> and
through its Index<Value> dispatch is, through as_bytes, as_ref and
into_bytes alike, the 32 PRF bytes the typed EqualityTerm holds. A Vec of
indexes reports exactly its indexes' specs in order (one, two, a repeated
one), derives its terms in that order byte for byte the tuple's, and an
empty one is refused when it runs with PlanError::EmptyIndexes before any
key request, which the earlier tests did not reach.

cargo mutants -p stack-encrypt, filtered to the three functions: 9 mutants,
8 caught, 1 unviable.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
`IndexSpec: Index<u32>` exists only with the `dynamic` feature, so the
no-default-features test build in the WASI job failed to compile the new
`tests/index.rs` case. The test now runs only when the feature is on, which
is the only build that has a `Vec` index set to exercise.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
…ver its type

cipherstash-bot, on PR #1093: a row sealed with no "type" opened under a
plan that later declared "type": "string" as the same text with a leading
U+000A, and no error. The typed string field sealed a bare String leaf and
the untyped one vitaminc's tagged leaf, 0x0A then the UTF-8; a bare String
reader accepts the tag as a line feed, and the opened value is still a
string, so the kind check passed too. A uint32 field failed instead, with
Error::Aead, which the guest reports as tampering. The two encodings cannot
be told apart by inspection: a bare string that begins with U+000A is a
valid tagged string, and a tagged string is a valid bare one. So no reader
can refuse the other's leaf, and a type declared after rows exist was a
silent migration.

The lowering now seals every field as a Value, the tagged leaf, whatever
its declared type. The type admits indexes and checks kinds, as before,
and decides nothing about the bytes, so declaring one later changes no
leaf and a binding that starts sending "type" (#1082) re-encrypts nothing;
the tests pin both directions. The typed-leaf lowering, Leaf and the
Index<u32> and Index<String> impls of IndexSpec go with it; a lone
IndexSpec is an index set of one, so a Rust chain over Value fields names
its indexes as the data plan does.

What this gives up, and where it is recorded: a Rust u32 or String field
under the same label shares a data field's terms and not its leaf. A Rust
record whose rows a binding must open declares Value fields, which is the
same declaration as the data plan's, and the fixture now proves the
lowering against such a chain. One leaf encoding both authors read, or the
encoding bound into the leaf's context so the wrong reader fails closed,
is a change to the Rust chain's bytes and is left to #1082 as a decision,
named in the module docs and the CHANGELOG. The String-plaintext custody
finding (record.rs:1092) is moot by the same change: no plaintext leaves
its Protected buffer.

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

cipherstash-bot, on PR #1093: the output adapters read the record the
engine built with FieldValues::take, whose refusals are PlanError::NotInValue
and PlanError::FieldType; the ? turned those into Error::Plan, which the
guest reports as STATUS_ENCODING, "your input is wrong". Only a bug in this
module can make the engine's record disagree with the plan it was built
from, so the host would have looked for a fault in its own data and found
none. The adapters now take slots through one helper that reports a missing
or mistyped slot as Error::ResponseShape — what a miscounted term list in
the same file already is — which the guest maps to STATUS_INTERNAL. The
only Error::Plan the adapters still raise is the kind check on an opened
value, which is about the caller's data. A unit test drives both adapters
with a missing and a mistyped slot.

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

cipherstash-bot, on PR #1093: no test ran the ORE and OPE arms over a
typed uint32 or string field, nor a non-default match index over a typed
string, and nothing ran the failure branch of IndexSpec's public Index
impl. The typed-leaf arms are gone with the one-encoding fix, but the
concern stands for the Index<Value> dispatch and the output key each term
rides under: an arm calling the wrong operation still compiles, and the
stored terms would then match no probe, with no error. A table test seals a
typed uint32 and string field under each index the kind admits (equality,
ORE, OPE, default and wide match) and checks the stored term is the one
dynamic::term derives under the field's context. A second test runs
IndexSpec as an Index<Value> directly over three pairs the scheme refuses
and checks the run fails with the dynamic Error::Term, naming the index,
inside Error::Other.

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

cipherstash-bot, on PR #1093: NewPlan documents two rules it leaves to the
call — every field under one table, no two fields under one label — and no
Go test showed the call refusing them. One does now, on an uninitialised
guest: both plans build, and EncryptRecords and DecryptRecord each refuse
them as ErrEncoding before looking for a cipher. If the guest's refusal
ever stops being the caller's input, this is the test that says so.

Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
… key source can refuse

cipherstash-bot, on PR #1093: the_fixture_records_are_bound_to_their_labels
swapped age (uint32) and notes (string) and asserted only is_err(). Both
fields declare a "type", so the per-field type check fails that decrypt
whether or not the key source refuses the label, and the test could not
tell the two apart. It now swaps email and notes, both string fields, so
the type check passes and only the label binding can refuse, and it
requires Error::Kms. With the deterministic source's descriptor check
disabled the test fails with Error::Aead; is_err() would have passed.

Claude-Session: https://claude.ai/code/session_016CfyFnsnw5NWKPojCQRxM9
…not #1082

The dynamic::record module docs and the CHANGELOG left the remaining part
of #1059 item 4 — a Rust u32 or String field and a data-plan field seal
different leaf bytes under one label — to #1082. #1082 is a different
problem (an indexed field with no "type" storing two terms for one value),
never mentions leaf encoding, and #1096 closes it, which would have left
the decision pointing at a closed issue that never tracked it. #1118 now
holds it: the two hazards (a bare String reader returns "\nalice"; a u32
mismatch fails as Error::Aead, which the Go guest reports as tampering)
and the two ways to close them. Both docs point there.

Claude-Session: https://claude.ai/code/session_016CfyFnsnw5NWKPojCQRxM9
@coderdan
coderdan force-pushed the feat/stack-encrypt-record-lowering branch from 769d8f5 to bb91a4e Compare October 7, 2026 03:44

@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

Two things must change before merge. First, Go plan.Custom now encrypts under a different context for the same string, and the golden file does not show the change. Second, the plan docs must say that a bare String field decrypts a binding's row to the wrong text, with no error. Also decide when #1082 lands, because each release adds rows that need a migration. The other findings can wait for a follow-up.

The lowering does what the description says. A reviewer encrypted rows with the old executor at 5dd37db08 and decrypted them at this head. All four cases decrypted to their plaintext, and every term matched. The comment on CHANGELOG.md lists the cases.

Other findings not posted as comments

  • Optional: Go sends no "type" for any field, so every Go index derives its term from the type tag of each value. A Go int always crosses as Int64, so Go agrees with itself. A writer that sends Int32 for the same column derives a different term, and a query then misses with no error. The docs mark this as transitional until #1082. packages/stack-encrypt/src/dynamic/record.rs:535
  • Optional: the query path is still outside the plan. Go Cipher.Term takes its own context and its own index kind, so a query can disagree with the plan that wrote the row. SDK principle 4 says that one declaration serves the write, the query and the read. This PR unifies the write and the read. languages/golang/stackencrypt/cipher.go:99
  • Optional: a passthrough field that decrypts to the wrong type fails with Error::Record, but an encrypted field fails with PlanError::FieldType. The condition is the same, so one error is easier for a caller to handle. packages/stack-encrypt/src/dynamic/record.rs:1207
  • Optional: an empty Vec index set fails only when the operation runs, with PlanError::EmptyIndexes. A Rust chain can name a Vec directly, and the compiler does not catch vec![] as it catches (). A non-empty type, such as NonEmpty<Vec<I>>, moves the check to compile time. packages/stack-encrypt/src/target/index.rs:439
  • Optional: four notes from the earlier review are still open. No test encrypts a field whose key differs from the last segment of its label. No guest test maps PlanError::FieldType to STATUS_ENCODING. The fixture holds no extended field and no untyped field. The swap test asserts only is_err(). The fixture that the comment on CHANGELOG.md asks for covers the first and the third note.
How this review was made
Agent Model Review type Result
claude claude-opus-5-5 crypto API usability, SDK principles 10 found, 6 posted

Synthesis: one model ran this review, so no findings were merged.

Plain language: claude-opus-5-5 wrote every comment to ASD-STE100, ISO 24495-1:2023 and the language rules of the Slipstream Learner Guide.

Verification: a reviewer ran one test across the base and this head. The comment on CHANGELOG.md gives the result.

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

Context loaded: the description, the earlier reviews and their replies, docs/sdk-design-principles.md, and the crypto API usability review prompt.

Comment thread languages/golang/stackencrypt/plan/policy.go
Comment thread packages/stack-encrypt/src/plan/mod.rs
Comment thread packages/stack-encrypt/CHANGELOG.md
Comment thread packages/stack-encrypt/src/target/context.rs
Comment thread languages/golang/stackencrypt/record.go
Comment thread packages/stack-encrypt/src/dynamic/record.rs

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

Nice one @coderdan!

One last round of feedback from @cipherstash-bot needs to be addressed, but otherwise this looks to be in good shape.

…e shows

plan.Custom("notes/v1") now binds the label ["notes", "v1"] rather than the
one text part "notes/v1". The snapshot wrote both as `notes/v1`, so the
golden file did not change and a reviewer saw nothing. A context line is
now the label's segments, `context ["individuals-notes", "v1"]`, and parse
refuses the old joined spelling rather than guess which shape it meant.

An EQL column and a Custom column with the same segments now bind the same
context, so the kind is no longer part of contextKey: switching a column
from EQL to Custom("<table>/<column>") is a target change under the same
context, not the data loss the snapshot used to report.

The CHANGELOG entry that names the context= tag names the Custom change.
…ields

The "binding lowers into the same builder" section said the data path is
not a second executor, which is true of the engine and not of the stored
bytes: a data plan seals the tagged Value leaf, and a bare String reader
opens it as "\nalice" with no error. A developer writing a Rust chain reads
this module, not dynamic::record, so the rule and #1118 are named here.
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.

stack-encrypt: dynamic::record is a second executor that already seals differently from the derive — make it a lowering into the plan builder

3 participants