Reserved number: 0465, the next free after 0464 (drafted at 127cee9 on
feat/c6-showcase-figentra-brand) on every branch and worktree of stackra/core on 2026-10-28.
Decision 5 superseded 2026-10-29 by
0476
(stackra-330):
HasEncryptedFields({scope}) binds the owning scope, the ai tables use it, and
ScopeKeyCast is removed, so there is one column-encryption path.Context
The gap analysis of 2026-10-28 (noteshandover/gap-analysis) found jobs done twice in core, each with its own store
or config. Owner decision D10 put the cleanup into 0.1.0: merge what is the same job, and record in one ADR what stays
apart and why. Five pairs were named:
- Object storage.
FileSystem(@stackra/filesystem: memory, local, S3/R2, GCS, Encore bucket, Electron) andMediaDisk(@stackra/media: memory and S3/R2 only), with two S3 drivers. Media could not run on GCS (the KSA lane), an Encore bucket or a local folder, although infra provisions a files bucket there. - Vectors.
search.vector_itemsbehindVectorIndex(pgvector, HNSW, scope-filtered), whichretrieve()already reads, andai.embeddings, a second pgvector table with anEmbeddingmodel that nothing wrote. - Sentry.
SentryConfig(errors.sentry: DSN by secret name,sampleRate, environment, release) andTelemetryConfig(logger.telemetry: adsnvalue anderrors.sampleRate, beside trace, replay and vitals). - Audit. The
AuditTrailport of@stackra/actions(oneAuditEntryRecordedper action call, sent through the outbox) and@stackra/access-log(@AccessLoggedreads of personal data, a per-scope SHA-256 hash chain, memory, Postgres and outbox sinks, retention). - Column encryption.
EncryptedFieldsof@stackra/modelseals a field with the authenticated context<model key>.<field>;ScopeKeyCastof@stackra/aiseals the ai tables with<schema>.<table>.<column>@<scopeId>. Both use the oneFieldCipher.
FlagsFake beside MemoryFlagProviderDriver; 36 *.fake.ts files
beside 123 memory-*.driver.ts files) and lint rules (@stackra/oxlint-config says “add or change lint rules here”,
@stackra/coding-standard says “the house lint rules”, and packages ship ./lint plugins).
Decision
- One object-storage port:
FileSystem.FileSystemgains two implemented members:move(from, to)(default: read, write with the media type, delete; the memory, local, S3 and GCS disks use the store’s own move) andsignUpload(path, { contentType, expiresIn })(default: aTypeError; the memory, S3/R2, GCS and Encore bucket disks sign aPUT).SignedUploadandUploadRequestmove to@stackra/filesystem/contracts;UploadRequestcarries no metadata, because an Encore bucket cannot sign any.EncoreBucketgainssignedUploadUrl.MediaDisk, its memory and S3 drivers,MediaObjectand themedia.disksetting are removed;MediaLibrarytakesFileSystemand writes the upload’s bucket key and expiry beside it asuploads/<id>.json, which the client’s signed URL cannot reach, instead of signed metadata. The claim still checks the stored type and size against the bucket. - One vector store:
VectorIndex.search.vector_itemsis the platform’s vector store.@stackra/airemoves theEmbeddingmodel and ships migrationai.0002_drop_embeddings; no data moves, since nothing wrote the table. Conversation memory stores throughVectorIndex.upsertwithconversationId,messageId,embedderandcontentHashas item metadata, in the caller’s scope; message text stays sealed inai.messages. - One Sentry source:
SentryConfig. It owns the DSN (by secret name) and the share of errors reported, for server and client reporters.TelemetryConfigdropsdsnanderrors.sampleRate, keepserrors.enabled,errors.nativeCrashes, trace and replay sampling, vitals, budgets and Lighthouse, and a client reporter reads the DSN and the rate fromSentryConfig.@stackra/loggertakes no dependency on@stackra/errorsfor this. - Audit stays two stores, on purpose. They answer different questions and fail differently:
AuditTrail(actions) records writes: every action call, allowed or refused, after its unit of work, as an integration event. It keeps no store and no chain in core: the audit service chains and stores it (ADR 0212 decision 4). It must never block a call.@stackra/access-logrecords reads of personal data: who saw which classified record, chained per scope in core so a gap is provable, with retention, and it fails closed for configured classes and privileged surfaces (ADR 0409). It runs on the read path. Both feed the audit service. Merging them would put a hash chain and a fail-closed rule on every write, or drop them from reads. A later batch may implementAuditTrailwith an access-log sink on the same chain (gap analysis data R5, P1); that is new capability, not this cleanup.
- Column encryption stays two paths; no crypto migration now. The authenticated contexts differ
(
<model key>.<field>vs<schema>.<table>.<column>@<scopeId>), so a value sealed by one does not open under the other, and moving the ai tables would mean re-sealing every row. They stay:EncryptedFields(@stackra/model) is the default for a model field: transparent to the ORM, bound to the field.ScopeKeyCast(@stackra/ai) is for the ai tables only: it binds the scope, so a sealed message copied into another scope’s row does not open. New code usesEncryptedFields; nothing new adoptsScopeKeyCast. A merge needs, first, a scope binding inEncryptedFieldsand a re-seal job (gap analysis security P2, the key-rotationreencryptjob), then its own ADR.
- Test doubles. A port’s test double is its
memorydriver (Memory<Name>Driver,src/core/drivers/, chosen by.useDrivers("memory")). A*Fake(src/testing/) exists only for a service or helper that is not a port, or that wraps one with test setters, asFlagsFakewrapsFlagReader. No mass rename: existing fakes stay; new code follows the rule, and a fake that only restates a port’s memory driver is removed when its package is next touched. - Lint-rule homes.
@stackra/oxlint-config(packages/tooling/oxlint) holds presets and rule levels only, never rule code.@stackra/coding-standardholds the house rule code (the oxlint JS plugin). A package’s./lintplugin holds a rule that needs that package’s own types or registry (@stackra/feature-flags/lint). Each package’s summary says so; no rule moves now.
Consequences
- Media runs on every disk that can sign an upload (memory, S3/R2, GCS, Encore bucket) and claims on every disk. The
local and Electron disks refuse
createUpload; development uses the memory disk. On an Encore bucket the client’s content type is not signed, so the claim’s type check is the only guard there. - An upload signed before the change has no record and its claim is refused as incomplete; it expires within
media.uploadExpiresIn. A runwaymedia.diskvalue is ignored;filesystem.diskpicks the store. - Gen’s
PLATFORM_BINDINGSrow bindsMediaLibraryoverFileSystemfrom@stackra/filesystem/contracts, so a service that injects it needs no wiring of its own. The Encore bucket disk signs an upload through theEncoreBucketport’ssignedUploadUrl, which Encore’s own bucket object already has; gen writes no bucket adapter of its own, so nothing sits between them to forward it. - The template’s telemetry overrides that set
errors.sampleRatemust drop it. - Two audit stores and two column-encryption paths remain, each with one stated job; a reviewer refuses a third.
Enforcement
Tests beside the code:file-system.port.test.ts (the default move and signUpload), each disk’s driver test (its
move and signUpload), media-library.service.test.ts (the record, the claim, a disk that cannot sign), the media
and filesystem barrel tests (exact export sets, no MediaDisk), media-config-schema.constant.test.ts (no disk),
0002-drop-embeddings.migration.test.ts, and the logger telemetry schema and config tests (no dsn, no
errors.sampleRate). Decisions 4 to 7 are review rules.
Sources
Noteshandover/gap-analysis: data.md (media disk drivers, R2, R5), security-identity.md (redundancy 1, the audit
note), ai-automation.md (R3), devx-ops.md (R1, R4, R6), cross-redundancy.md (19). Related: ADR
0222,
0234, 0160,
0100,
0184,
0212,
0409,
0188,
0266,
0269,
0300. STA-1384, batch stackra-226.