Skip to content

Commit 98868f7

Browse files
yujiyokooclaude
andcommitted
docs(python): close the review threads on ADR-0001
The ADR promised a per-caller tenant through the cipher's extend, and the engine refuses that for every EQL-typed field (TargetError::Extended, TargetError::ContextField), so the first version has none and the route that fits is the ZeroKMS lock context, #1067. The decorator's stamp is read from cls.__dict__ so a subclass cannot ride on its parent's check; the field list is the library's own, which drops ClassVar, InitVar and relationships and keeps rule A for real columns; the attribute name is recorded as permanent once a row is written, with the override deferred to #1308; decision 9 claims only repr, str and logging and lists the serialisation paths it leaves open; the hand-written-method rule is the method's source file, and Cipher re-checks the methods by identity so a base-class constructor cannot hide a wrong decorator order; decisions 2 and 3 now state the same contract as decision 9. The findings doc names the real extractor (eql_v4.eq_term), links the guide's PR #1287 and says this merges after it, restarts from next, and re-reads decision 9 and backlog item 5 against what next produces. The ledger drops a workspace id and two home-directory paths. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 3b256b0 commit 98868f7

3 files changed

Lines changed: 139 additions & 33 deletions

File tree

‎docs/plans/2026-10-13-python-sdk-tracer-findings.md‎

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
> Python and toolchain section (pyo3 0.29, maturin-only `extension-module`,
1212
> libpython on the loader path, `Pending` as `IntoFuture`, width narrowing,
1313
> the psycopg plumbing) still holds. The restart plan reads
14-
> `packages/stack-encrypt/docs/language-integration.md` on `next` first.
14+
> `packages/stack-encrypt/docs/language-integration.md` first; that guide
15+
> is PR #1287, open against `next` as this header is written, and this
16+
> document merges after it.
1517
1618
**Status:** a proto-prototype, stopped by decision on 2026-10-13 after 7 of 9
1719
tasks, to be restarted fresh. The branch is kept as a reference under
1820
`prototype/python-sdk-tracer`. **Do not merge it.** Read this file, then
19-
the Python SDK discovery ticket, then start again from `main`.
21+
the Python SDK discovery ticket, then start again from `next`.
2022

2123
The code is disposable. What follows is not: it is what the code proved,
2224
and most of it would take days to rediscover.
@@ -66,7 +68,8 @@ design choice.
6668
no EQL type is built from them yet"; the ORE types because "EQL stores a
6769
block ORE term and the engine derives a CLLW ORE term; the two are
6870
different algorithms"; `Text` because it "follows once TextEq is proven
69-
end-to-end in PostgreSQL". **Consequence for the discovery ticket:** decision 9 lists
71+
end-to-end in PostgreSQL". (Superseded: both are producible on `next`;
72+
see open question 1.) **Consequence for the discovery ticket:** decision 9 lists
7073
`equality`, `match`, `ore` as the engine features the four tasks need, and
7174
backlog item 5 is "`match` and `ore` queries". Neither is a Python change;
7275
both wait on the engine. Amend the ticket before anyone builds against it.
@@ -138,7 +141,7 @@ design choice.
138141
for the same plaintext are *different* JSON and an index on the column
139142
itself never matches. The index EQL v3 is built for is a functional one on
140143
the extracted term —
141-
`create index … using hash (eql_v3_internal.eq_term(col))` (or `btree`:
144+
`create index … using hash (eql_v3.eq_term(col))` (or `btree`:
142145
the term's type rides on `bytea`'s default operator classes, so both work
143146
and only `=` means anything) — and the bundle marks the extractor
144147
`IMMUTABLE` and inlinable so the domain's `=` operator folds into the
@@ -251,11 +254,15 @@ the engine *through the shell* against the engine bare); the record as
251254

252255
## Open questions for the restart
253256

254-
1. Amend the discovery ticket's decision 9 and backlog item 5 for the catalog gap, with
255-
the dependencies named precisely: `ore` → #1132 and the `vitaminc-ore`
256-
proposal (link above); the non-text kinds → #1130 and the same
257-
proposal; `match` → a TextMatch EQL type over the existing term, not yet
258-
ticketed.
257+
1. Re-read the discovery ticket's decision 9 and backlog item 5 against
258+
`next`, not against the catalog gap this document recorded on 13
259+
October. On `next` 40 of the 51 EQL v4 types are producible
260+
(`languages/golang/encrypt/eql/eql_gen.go` says which): `Text`,
261+
`TextMatch`, `TextSearch` (#1274) and the block ORE `Ord` types (#1280)
262+
among them, both merged on 2026-10-19. The 11 that are not are the
263+
order terms for `date`, `timestamp` and `decimal`, whose ordering is
264+
not decided, and the two JSON types (#1060). Amend the ticket from
265+
that list, and name only the dependencies that remain.
259266
2. Which of those lands first, and does the week-1 restart wait for any of
260267
them? (Nothing in the six-step path needs them; `TextEq` is enough to
261268
prove the path, which it did.)
@@ -271,15 +278,21 @@ the engine *through the shell* against the engine bare); the record as
271278
7. The model contract: write it down as a `Protocol` (annotated fields,
272279
attribute access, kwargs constructor), and check SQLAlchemy declarative
273280
models against it before the layer is designed.
274-
8. Indexing guidance for the SDK docs: the functional hash index on
275-
`eql_v3_internal.eq_term(col)` is the one a `TextEq` column wants; the
276-
tracer never created one, and a user copying its SQL would not either.
281+
8. Indexing guidance for the SDK docs: the functional hash index on the
282+
extracted term is the one a `TextEq` column wants; on the v4 bundle the
283+
restart installs that is `eql_v4.eq_term(col)` on a
284+
`public.eql_v4_text_eq` column (`eql_v3.eq_term` on v3; there is no
285+
`_internal` extractor). The tracer never created one, and a user
286+
copying its SQL would not either.
277287

278288
## Where the record is
279289

280-
- This branch: `prototype/python-sdk-tracer` — commits
281-
`6c3bc191f..` (Tasks 1–7), each message self-contained.
282-
- The plan: `docs/plans/2026-10-08-python-sdk-tracer.md` (revised in place).
290+
- The prototype branch: `prototype/python-sdk-tracer` — commits
291+
`6c3bc191f..` (Tasks 1–7), each message self-contained. This document
292+
and the ledger land on `next`; the files below exist only on that
293+
branch.
294+
- The plan: `docs/plans/2026-10-08-python-sdk-tracer.md` on the prototype
295+
branch (revised in place there).
283296
- The ledger: `docs/plans/2026-10-13-python-sdk-tracer-ledger.md` — every
284297
ruling with its cost, copied from the gitignored SDD workspace.
285-
- How to run it: `languages/python/README.md`.
298+
- How to run it: `languages/python/README.md` on the prototype branch.

‎docs/plans/2026-10-13-python-sdk-tracer-ledger.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ interception fails CPython's cert verification with `OSStatus -26276`, the
101101
same error `gh` gives), and the repo's node scripts need node >= 20.11 for
102102
`import.meta.dirname` — the shell's `node` and `mise x -- node` are both
103103
20.3.1, so `lint-no-eql-registry-pins.mjs` was run with
104-
`/Users/yuji/.local/share/mise/installs/node/24.11.1/bin/node` and passes
104+
`$(mise where node)/bin/node` (node 24.11.1) and passes
105105
with the new `eql-bindings` path dependency.
106106

107107
Task 2: Ruling: the plan's kind names are wrong — `"Utf8Str"` → `"string"`,
@@ -209,7 +209,7 @@ libpython, which is fine and is what every non-wheel build wants.
209209

210210
Task 4: Note (environment): `cargo test` here needs
211211
`DYLD_FALLBACK_LIBRARY_PATH` pointing at the interpreter's `LIBDIR`
212-
(`/Users/yuji/.local/share/mise/installs/python/3.14.2/lib`), because the
212+
(`$(mise where python)/lib`, Python 3.14.2), because the
213213
test binary links libpython by rpath and mise's install is not on the
214214
default search path. Linux CI uses `LD_LIBRARY_PATH`. Task 8 must set it in
215215
the workflow or the Rust tests fail at load with
@@ -297,7 +297,7 @@ Task 5: complete (commits bc785b2..8085642, tests: ./languages/python/.venv/bin/
297297
Task 6: Ruling (plan revised, with the user, 2026-10-13): NO committed
298298
fixture. The plan's `tests/fixtures/record.json` carried an equality term
299299
that is an HMAC under the workspace keyset, so the committed bytes would pass
300-
only in the workspace that generated them — the user's `TCDLFX5CQ6T5LKCP`
300+
only in the workspace that generated them — the author's `<workspace-id>`
301301
here, not CI's. The Rust side is now produced AT TEST TIME by a `parity`
302302
bin (`seal` / `open`) under the same credentials as the Python side, so the
303303
comparison holds in any workspace and nothing keyed to a keyset is

‎languages/python/docs/adr/0001-a-declaration-is-checked-when-the-class-is-defined.md‎

Lines changed: 107 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -115,19 +115,46 @@ Option 2.
115115
The decorator is the one that also works with the bases the first
116116
user's classes already have (option 3).
117117
2. **One decorator, and it carries the context.**
118-
`@stash.model("users")`, with one argument: the table.
118+
`@stash.model("users")`, with one required argument: the table.
119+
Its one option, `redact=False`, is decision 9's and changes no label.
119120
The engine's target context is exactly a table and a column, and the
120121
column is the field's name, so the class needs nothing more.
122+
That name is an input to the key ZeroKMS derives and to the
123+
authentication tag, so it is permanent once a row is written: a rename
124+
in Python changes both, and no stored row opens.
125+
A rename is a migration, and the first version has none.
126+
A field that names its column part itself, so the attribute can move
127+
while the label stays, is deferred with the Go SDK's #1308, so both
128+
SDKs get one answer, including what `i.c` then holds.
129+
Where the library shows a column name that differs from the attribute,
130+
SQLAlchemy's `mapped_column("email_addr")`, the decorator refuses at the
131+
class statement, since `i.c` would not match the SQL column.
132+
A Pydantic alias is a JSON name, not a column, and is left alone.
121133
The name is open to the reviewers; the alternatives are
122134
`@stash.encrypted(...)` and `@stash.declare(...)`.
123135
The argument is documented once as the engine's `context`.
124136
A record that names its own tenant (the engine's `context_field`) is
125137
a record-level setting in the plan grammar, so if the SDK supports it,
126138
it is a second decorator argument and not a field marker.
127-
The first version does not support it; a tenant attaches to the cipher.
128-
3. **The decorator does four things and nothing else.**
129-
It reads the hints, builds the field map, asks the engine to check the
130-
plan, and stores the result on the class under one dunder attribute.
139+
The first version has no per-caller tenant, by either route.
140+
On `next` the engine refuses both for a field that names an EQL type,
141+
because the field's label becomes the EQL value's `i` and must be
142+
exactly a table and a column: an extended cipher is refused with
143+
`TargetError::Extended`, and a plan with `context_field` with
144+
`TargetError::ContextField` (`packages/stack-encrypt/src/dynamic/record.rs`).
145+
Every sealed marker in this ADR names an EQL type, so the refusal
146+
covers every sealed field.
147+
The route that fits an EQL field is the ZeroKMS lock context, bound
148+
into the key and not into the label, which stack-encrypt does not send
149+
yet (#1067).
150+
3. **The decorator does five things and nothing else.**
151+
It reads the field list, builds the field map, asks the engine to check
152+
the plan, stores the result on the class under one dunder attribute,
153+
and writes the print methods of decision 9.
154+
It stores it in the class's own `__dict__`, so a subclass does not
155+
inherit it: a subclass is decorated itself, and its decorator checks
156+
every field the subclass has, inherited or added, since
157+
`get_type_hints` returns both.
131158
It makes no network request and needs no credentials.
132159

133160
Two checks run, and they are not the same check:
@@ -141,24 +168,49 @@ Option 2.
141168
4. **The call site takes the class.** `Cipher(User)`.
142169
`Cipher` refuses a class the decorator has not seen, with the remedy in
143170
the message.
171+
It looks for the decorator's attribute in `cls.__dict__`, not through
172+
`getattr`, so an undecorated subclass of a decorated class is refused
173+
with the same message.
144174
No public name is called a plan or returns one.
145175

146176
`DeclarationError` and that refusal are built from structured facts:
147177
the field name and the engine's `Reason`, as the language-integration
148-
guide gives them, never by parsing the engine's message text.
178+
guide (`packages/stack-encrypt/docs/language-integration.md`, PR #1287,
179+
open on `next` as this ADR is written) gives them, never by parsing
180+
the engine's message text.
149181
No message carries a value.
150182
A dataclass default is a value, so a message names the field and not
151183
its default.
152184
5. **The model contract is a `Protocol`, not a dataclass.**
153185
A model class must give:
154-
- field hints that `typing.get_type_hints(cls, include_extras=True)`
155-
returns, each carrying its stash markers in `Annotated`;
186+
- a field list, each field carrying its stash markers in `Annotated`;
156187
- a constructor that accepts every declared field as a keyword argument;
157188
- attribute access by field name on an instance.
158189

190+
The field list is the library's own where one exists:
191+
`dataclasses.fields`, `attrs.fields`, Pydantic's `model_fields`, and
192+
SQLAlchemy's mapper columns.
193+
Those lists exist because the decorator is outermost, and they leave
194+
out what is not a column: `ClassVar`, `InitVar`, Pydantic's
195+
`model_config`, and a SQLAlchemy relationship.
196+
For a class with no such list, the fields are the hints that
197+
`typing.get_type_hints(cls, include_extras=True)` returns, minus
198+
`ClassVar` and `InitVar`.
199+
A field's hint is evaluated at decoration.
200+
One that cannot be evaluated then, a forward reference under
201+
`from __future__ import annotations` or a deferred annotation on 3.14,
202+
is a `DeclarationError` naming the field; a relationship never reaches
203+
that point, since it is not in the column list.
204+
Skipping a relationship covers only this class: the related class is
205+
checked by its own decorator, or not at all, and `Cipher(Post)` refuses
206+
an undecorated `Post` as decision 4 says.
207+
159208
The first two are checked at decoration.
160209
The constructor check reads `inspect.signature(cls)` and accepts a
161210
`**kwargs` parameter.
211+
A base that supplies one, Pydantic or SQLAlchemy, passes that check in
212+
any decorator order, so decision 9 adds a second order check at
213+
`Cipher(User)`.
162214
The third is checked only when a row is read; Python cannot check it
163215
earlier.
164216
`is_dataclass` is not the guard: it rejects classes that meet the
@@ -186,6 +238,12 @@ Option 2.
186238
`Passthrough` is the one hand-written marker: a field stored as it is,
187239
and the engine's word for it (principle G2).
188240
Markers are names, not calls.
241+
The decorator finds a marker by identity, not by name: it walks the
242+
`Annotated` metadata and keeps the objects that are stash markers, so
243+
another library's metadata on the same field is ignored, and a marker
244+
from another package that is also called `TextEq` is not a stash marker
245+
and leaves the field unmarked, which rule A refuses by field name.
246+
Two stash markers on one field is a refusal: one field, one marker.
189247

190248
| Field | Spelling |
191249
| --- | --- |
@@ -200,7 +258,8 @@ Option 2.
200258
That spelling needs a Python copy of the catalog's kind-and-index
201259
table, which is what G1 forbids, so it was dropped at review.
202260

203-
9. **A model never prints a sealed value, by default.**
261+
9. **A model does not print a sealed value through `repr`, `str` or
262+
`logging`, by default.**
204263
The decorator writes `__repr__` and `__str__` on the class it
205264
decorates and on the encrypted type the SDK returns.
206265
Both print the passthrough fields and `[sealed]` in place of each
@@ -211,13 +270,42 @@ Option 2.
211270
`repr=False` is needed, and none is read.
212271
Python's `logging` calls `__str__`, so logs are covered by the same
213272
methods.
273+
Serialisation is not printing and is not covered: `dataclasses.asdict`,
274+
`attrs.asdict`, Pydantic's `model_dump` and `model_dump_json`, `vars`,
275+
a NamedTuple's `_asdict`, iteration and indexing all return the
276+
plaintext, because the user asked for the data, and that is how a row
277+
reaches an ORM.
278+
A log line that formats one of those is the user's print, not the
279+
model's.
280+
The linter rule below should report those calls on a model when it
281+
reports a print.
214282
`@stash.model("users", redact=False)` is the class-level opt-out.
215283
A class that defines its own `__repr__` or `__str__` by hand is
216284
refused at the class statement with the remedy named (`redact=False`,
217285
or remove the method); the decorator does not replace a method the
218286
user wrote.
219287
The methods `dataclass`, attrs and Pydantic generate are replaced,
220288
since the decorator is outermost.
289+
The rule that tells the two apart looks only at `cls.__dict__`.
290+
A method there whose code comes from the class's own module file
291+
(`__code__.co_filename`) is hand-written and refused; one from another
292+
file, the standard library for `dataclass` or attrs' generated source,
293+
is generated and replaced.
294+
A method the user assigns from another module is therefore replaced,
295+
not refused; that is the one known miss, and `redact=False` is its
296+
answer.
297+
An inherited method is not in `cls.__dict__` and is neither refused
298+
nor touched: Pydantic's `BaseModel.__repr__`, or a hand-written one on
299+
a SQLAlchemy `Base`, stays for undecorated classes, and the decorated
300+
class's own method shadows it, as a subclass override does.
301+
The decorator keeps the methods it wrote in the result it stores under
302+
the dunder attribute (decision 3), and `Cipher(User)` checks by
303+
identity that `cls.__dict__` still holds them; a decorator that ran
304+
later and replaced one means this decorator was not outermost, and
305+
`Cipher` refuses with the wrong-order message.
306+
That closes the case where a base class supplies a `**kwargs`
307+
constructor and the constructor check in decision 5 passes in any
308+
order.
221309
This is Go's `stashgen -redact` made the default, which Python can do
222310
because the decorator holds the user's class and a generator does not.
223311
A linter rule that reports a print of an opted-out model is open
@@ -269,8 +357,11 @@ defined, does not depend on the plan's wire form.
269357
The rig is not committed.
270358

271359
Not run: the error text a user sees, which the restart builds from the
272-
engine's structured reason fields; and the replacement of the
273-
`__repr__` that attrs and Pydantic generate, which the probe did not cover.
360+
engine's structured reason fields; the replacement of the `__repr__`
361+
that attrs and Pydantic generate, which the probe did not cover; and the
362+
hand-written-method rule of decision 9 across Python versions, run on
363+
3.14.2 only, where `dataclass`'s `__repr__` reports `reprlib.py` as its
364+
file and a hand-written one reports the class's module.
274365

275366
## Consequences
276367

@@ -295,9 +386,11 @@ engine's structured reason fields; and the replacement of the
295386
wrong order with the fix.
296387
- A class the user does not own gains one dunder attribute.
297388
That is what `dataclass` and `attrs` do too.
298-
- One context per class.
299-
What varies per caller, such as a tenant, attaches to the cipher through
300-
the engine's extend (principle G4), not to the class.
389+
- One context per class, and no per-caller part in the first version.
390+
Principle G4 puts what varies per caller on the cipher, not on the
391+
class, but the engine refuses an extended cipher for an EQL-typed field
392+
(decision 2), so per-caller tenant or per-user locking on these fields
393+
waits on the ZeroKMS lock context (#1067).
301394
- `Plan`, `PlanBuilder` and `PlanSpec` leave the public surface.
302395
The engine's plan grammar still exists behind the decorator; the user
303396
never names it.

0 commit comments

Comments
 (0)