Reserved number: 0476, assigned to batch stackra-330 (STA-1466) for the data-layer P2 round of
2026-10-28. It settles the merge that
0465
decision 5 deferred.
Context
0465 decision 5 kept two column-encryption paths.EncryptedFields (@stackra/model: the HasEncryptedFields trait and
EncryptedFieldsSubscriber) seals a model field transparently in the unit of work, bound to the context
<model key>.<field>. ScopeKeyCast (@stackra/ai) sealed the ai message content by hand in the conversation store,
bound to <schema>.<table>.<column>@<scopeId>, so a value copied into another scope’s row did not open. A merge
needed, first, a scope binding in EncryptedFields and a re-seal job. The re-seal job and the key-usage report landed
with stackra-312 (EncryptedFieldsResealService). The owner asked for the merge on 2026-10-28 (data-layer
decision 12).
Decision
HasEncryptedFields({ scope })binds the owning scope. The option names the property holding the id of the scope that owns the row (tenantIdon aBelongsToTenantmodel). Each value of such a model is sealed and opened in<model key>.<field>@<scope id>, the id read from the row;encryptedFieldContext(meta, field, scopeId)is the only place the context is made, shared by the subscriber and the re-seal job. A row whose scope property holds no id is refused, never sealed in the unbound context. An update that writes the scope property seals every field again under the new scope.scopecombines withsubjectandlookup. The blind index stays bound to<model key>.<field>: it is made under the scope’s key already, andwhereEncryptedhas no row to read a scope from.- The ai tables use it, and
ScopeKeyCastis removed.MessagedeclaresHasEncryptedFields({ scope: "tenantId" }, "content");PostgresConversationStoreDriverwrites and reads plain JSON through the repository, and the subscriber seals it.ScopeKeyCastand its export from@stackra/ai/nodeare deleted. There is one column-encryption path; a reviewer refuses a second cast. - No crypto migration of existing ai rows. The context changes from
ai.messages.content@<scope>toai.message.content@<scope>. No release shipped sealed ai rows (0.1.0 is not published), so no job moves them. A host holding such rows from a pre-release build drops or re-creates them;EncryptedFieldsResealServicere-seals key rotations only, never a context change.
Consequences
- One sealing path for every model, with one context function, one subscriber and one re-seal job; the ai store is smaller and no longer needs a cipher of its own.
- The ai service’s boot must register
EncryptedFieldsSubscriber. Gen already does so for a member that declares aHasEncryptedFieldsmodel or depends on a package whose manifest has one (stackra-262); the ai package’s manifest listsMessagewith the trait once it is generated again. - A scope-bound value cannot be moved between scopes by copying it; a subtree move (
repath) of such rows would need them opened and sealed again. Today the ai tables are append-only, so they do not move. - Adding
scopeto a model that already has sealed rows breaks those rows the same way; do it before data exists.
Enforcement
Tests beside the code (stackra-330):encrypted-field-context.function.test.ts (the @<scope id> context and the
empty-id refusal), encrypted-scope-of.function.test.ts, has-encrypted-fields.trait.test.ts (the scope option and
its refusals), encrypted-fields.subscriber.test.ts (a value copied into another scope’s row does not open; a scope
change reseals; a row without a scope id is refused), encrypted-fields-reseal.service.test.ts (the scope-bound
re-seal), and postgres-conversation-store.driver.test.ts and the @stackra/ai/node barrel test (no ScopeKeyCast).
Supersedes and sources
Supersedes: 0465 decision 5 (two paths). Related: 0188 (decision 2: data keys per environment and scope), 0100 (decision 9: conversations are encrypted), 0212. Sources: owner answers to the data-layer questionnaire, 2026-10-28 (decision 12); noteshandover/gap-analysis/data-layer/governance.md (gap 8); Linear STA-1466.