@@ -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).
1171172 . ** 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.
1411684 . ** 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.
1521845 . ** 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.
269357The rig is not committed.
270358
271359Not 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