Skip to content

Commit 8d36ff2

Browse files
committed
docs(plans): apply Dan's decisions of 2026-10-05 to the Go SDK design
Five decisions from a discussion with Dan Draper, recorded on #1070. The guest returns the EQL value. The data plan names the EQL type as a target, and the guest build that holds the EQL types runs that type's own Rust plan and returns the finished value. Go stores it and assembles nothing, so the EQL encoding lives once, in eql-bindings, and the EQL fixture has nothing to test. Two guest builds: encrypt embeds the one without the EQL types, encrypt/eql embeds the one with them, and a generated file that names an EQL type imports encrypt/eql, so the link is forced by code the user compiles. The size of the larger build is measured before the SDK ships two builds or one. This reverses one bullet of ADR-0007; the amendment and the "EQL v4 types as field targets" section say so. WithGuest joins the Removed list, because with it a program could link the smaller build beside EQL types and fail at run time. The whole struct crosses the binding, both ways. Passthrough fields cross with their values and come back. Heavier on the wire; nothing is rebuilt from parts on either side, so generated code is simpler. Decision 9 of the plan holds. The generated examples send every field and read every field back. One request for several types is deferred, not open. It moves from "Open questions" to a "Deferred" section. se_encrypt and se_decrypt are removed, not kept for a host that does not exist. Each amended ADR opens with a note that a later ADR changed the decision, so a reader of ADR-0004 to ADR-0007 sees the change before the body. Claude-Session: https://claude.ai/code/session_014jiJy4WhYH2LXojK2jSoxi
1 parent b5ec61b commit 8d36ff2

12 files changed

Lines changed: 169 additions & 93 deletions

‎docs/plans/2026-10-04-plan-builder.md‎

Lines changed: 49 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -617,8 +617,21 @@ A library that maps one struct field to one column needs a model for this layout
617617
### EQL types
618618

619619
An EQL type is the Go type of one EQL column.
620+
It holds the EQL value as the JSON bytes that Postgres stores.
620621
`eql-codegen` writes the package `encrypt/eql` from the EQL catalog.
621622
It is a package in the Go module, and not a module of its own.
623+
624+
The guest builds every EQL value.
625+
For a field with `encrypt_into`, the declaration names the EQL type, and the guest runs that type's own Rust plan.
626+
It returns the finished value, and generated code stores it as it is.
627+
No Go code assembles an EQL value.
628+
629+
The guest comes in two builds.
630+
`encrypt` embeds the build without the EQL types, and `encrypt/eql` embeds the build with them.
631+
A generated file that names an EQL type imports `encrypt/eql`, so a program with EQL types links the build that has them.
632+
When the program starts, `encrypt/eql` registers its build, and that registration cannot fail.
633+
634+
The size of the build with the EQL types is measured before the SDK ships two builds or one.
622635
The same catalog gives the Rust and TypeScript types, so a type has one name in every language, such as `TextEq`.
623636
The JSON type is the exception: its Go name is `JSON`.
624637

@@ -840,7 +853,9 @@ The compiler then finds a removed field and a field with a new type, and CI find
840853
The generator loads the package with `golang.org/x/tools/go/packages` and reads types, not text.
841854
It runs none of the package's code.
842855
It ignores its own output file when it loads the package, so a stale file does not stop it.
856+
843857
It checks each declaration with the engine: it runs the guest that the SDK embeds, and it holds no copy of the engine's rules.
858+
It asks that guest for the EQL types it holds: each name, its plaintext type, its indexes and its query forms.
844859
The same input always gives the same file: fields keep their declared order, and the file carries no version and no time.
845860

846861
`stashgen` stops with an error, and writes no file, for each of these:
@@ -947,23 +962,23 @@ No function in the SDK or in generated code panics for a declaration, and none h
947962
### What crosses the binding
948963

949964
The engine does all encryption, decryption and term derivation.
950-
A field crosses the binding only when its value does.
951-
Generated code sends the engine a declaration and a value for each sealed field and each indexed field.
952-
It sends nothing for a passthrough field, and it copies that value to the generated type itself.
953-
It sends nothing for a field that is left out.
954965

966+
Generated code sends the engine the whole struct: every field with its value, passthrough fields included.
967+
The engine returns the whole struct the same way.
968+
Nothing is rebuilt from parts on either side.
969+
A field that is left out does not cross.
955970
So every field the engine is told of has a value, and every value has a field.
956-
The generated file still names every field, so a reviewer reads the whole declaration.
957971

958-
Every call to the guest carries a declaration.
959-
The guest's exports that take a value and no declaration have no caller in Go.
972+
The declaration crosses with the struct.
973+
It names each field's context and indexes, and the EQL type of a field with `encrypt_into`.
974+
The guest returns the finished EQL value for that field.
960975

961-
Generated code assembles an EQL value from the engine's ciphertext and terms.
962-
Two fixtures that both test suites read guard the bytes:
976+
Every call to the guest carries a declaration.
977+
The guest's value exports, `se_encrypt` and `se_decrypt`, are removed.
963978

964-
- **The EQL fixture:** encode in Rust, decode and encode again in Go, and compare.
965-
- **The record fixture:** the Rust chain and generated Go code each open the records that the other encrypted.
966-
Both derive the same bytes for each term.
979+
One fixture that both test suites read guards the bytes.
980+
The Rust chain and generated Go code each open the records that the other encrypted.
981+
Both derive the same bytes for each term.
967982

968983
The package `encrypt/gensupport` holds what only generated code calls.
969984
No function in it panics.
@@ -1013,6 +1028,7 @@ The existing Go package has never been released, so these are removed, not depre
10131028
- `EncryptedRecord` and `EncryptedField`: a generated type replaces them.
10141029
- `Cipher.Term`: the query methods of a field entry replace it.
10151030
- `RecordOption`, `WithPlan` and `ExtendContext`: `Cipher.Extend` replaces the last.
1031+
- `WithGuest`: the import of `encrypt/eql` chooses the guest build.
10161032
- `TermKind`: `Index` replaces it, for generated code.
10171033
- `Plan`, `FieldPlan`, `NewPlan` and `Plan.Validate`: the generator replaces them.
10181034
- `PlanFromTags`, and every other function that reads `stash` tags at run time.
@@ -1135,9 +1151,9 @@ the existing SQL bundle and its `eql_v3_*` domains, which this section does
11351151
not change. Depends on #971 (`TextEq` / `TextEqQuery` through stack-encrypt
11361152
in `eql-bindings`, and `Identifier` as a two-segment `Label`).
11371153

1138-
**The engine never returns an EQL type.** It returns standard outputs:
1139-
ciphertexts, terms and passthrough values. Each language's typed layer
1140-
assembles EQL types from them:
1154+
**The engine returns an EQL type only when a plan names it as a target.**
1155+
Otherwise it returns standard outputs: ciphertexts, terms and passthrough
1156+
values. Each language names the target its own way:
11411157

11421158
- **Rust**, through the EQL type's own `EncryptFrom` impl, named with the
11431159
typed verb. A field target may be any `EncryptFrom` type, so an EQL type
@@ -1151,23 +1167,25 @@ assembles EQL types from them:
11511167
.build()?;
11521168
```
11531169

1154-
- **Go**, through code the generator in #1070 writes. The generated code asks
1155-
the guest for the standard outputs of the field's indexes and assembles the
1156-
EQL type in Go.
1170+
- **Go**, through the data plan. A field names the EQL type as its target,
1171+
the guest runs that type's own plan, and the generated code stores the
1172+
value the guest returns. (Decided 2026-10-05: the first version of this
1173+
section had Go assemble the EQL type from standard outputs.)
11571174

1158-
So there is **no registry** of EQL types, **no target name in the data
1159-
grammar**, and no `Target<S>` trait. The WASI guest stays EQL-free: it runs
1160-
data plans and returns standard outputs, exactly as for any other field. A
1161-
shape mismatch (the outputs do not fit the EQL type) is a run-time error in
1162-
Go, and codegen makes it unreachable in practice.
1175+
The data grammar has a **target** field form, exclusive with the output
1176+
verbs, and no `Target<S>` trait. A dispatch that `eql-codegen` generates
1177+
resolves the name in the guest build that holds the EQL types; the guest build
1178+
without them refuses a target name. `stashgen` writes a target name only
1179+
beside the import that supplies the dispatch, so that refusal is unreachable
1180+
in practice.
11631181

11641182
An EQL type is still a target that wraps an index's output; the index itself
11651183
stays in stack-encrypt. The JSON type is the clearest case: the `Json` index
11661184
produces the searchable document, and EQL's JSON type frames it.
11671185

1168-
**The cost: the EQL byte encoding lives twice**, in `eql-bindings` (Rust) and
1169-
in a Go EQL package. A standing cross-language fixture guards it: encode in
1170-
Rust, decode and re-encode in Go, and compare the bytes.
1186+
**The cost: a second guest build**, with the EQL types in it. Its size is
1187+
measured before the SDK ships two builds or one. The EQL encoding lives once,
1188+
in `eql-bindings`.
11711189

11721190
Match and JSON index options need a wire form first (Additions, item 7): the
11731191
Go side derives its terms from a data plan, and a dropped option there is a
@@ -1213,27 +1231,16 @@ Then:
12131231
- Lock and audit context themselves; this plan only leaves them a place.
12141232
- Named term accessors on `Encrypted<Terms>` (decision 7).
12151233

1216-
## Open questions
1234+
## Deferred
12171235

12181236
- **One request for several types.**
1219-
One call covers one type today, because the guest takes one declaration in a call.
1237+
One call covers one type, because the guest takes one declaration in a call.
12201238
One request for several types needs the engine to run several plans under one key request.
12211239
It also needs a guest export that takes several declarations, each with its values.
12221240
The Go call for it is designed after that work.
1223-
- **The history in this plan.**
1224-
"Why the first draft was dropped" and the rejected names under "Decisions" are history.
1225-
The design principles keep history out of a design document, so those parts move to ADR-0007.
1226-
Dan Draper owns that move.
1227-
- **Query building in Go.**
1228-
The SDK gives the values for a search, and the program writes the SQL.
1229-
A design for building that SQL is separate work.
1230-
- **A `go vet` check.**
1231-
It reports a struct with sealed fields that a program prints, and an `Encrypt` call inside a loop.
1232-
Its design is separate work.
1233-
- **A change to a declaration over time.**
1234-
This design covers one version of a declaration.
1235-
Reading data that an older declaration wrote needs its own design.
1236-
A policy has `Identity` for a column with a new name, and tags have no word for it yet.
1241+
1242+
## Open questions
1243+
12371244
- **Converging the TypeScript schema builder onto the plan grammar**, so
12381245
`@cipherstash/stack` stops being a second engine beside stack-encrypt.
12391246
Out of scope here; recorded so a TS binding does not grow an executor.

‎docs/plans/2026-10-04-plan-builder/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ The `users` example uses `TextEq`, which is the one EQL type the engine produces
3939
| A generated file from another version does not compile | Run. A file that names an unknown version constant fails `go build`. |
4040
| The files `stashgen` writes | Not run. `stashgen` does not exist, and the five `_stash.go` files are written by hand. |
4141
| The SDK can be built with these signatures | Not run. |
42-
| The record fixture and the EQL fixture | Not run. Neither fixture exists. |
42+
| The record fixture | Not run. It does not exist. |
43+
| The guest returns an EQL value for a field with `encrypt_into` | Not run. The target form and the dispatch do not exist. |
4344
| `stashgen` checks a declaration with the embedded guest | Not run. |
4445
| The code works with a database, GORM or pgx | Not run. Nothing here has connected to a database. |
4546
| The EQL types, and how generated code assembles them | Not run. `eql-codegen` does not write Go yet, and the examples use a stub of `eql.TextEq`. |

‎docs/plans/2026-10-04-plan-builder/accounts/account_stash.go‎

Lines changed: 35 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/plans/2026-10-04-plan-builder/contacts/contactstash_stash.go‎

Lines changed: 6 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/plans/2026-10-04-plan-builder/individuals/individual_stash.go‎

Lines changed: 21 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)