ADR-0104: Field runtime value-shape as a first-class contract — spec-owned value schemas, typed action handlers, file-as-reference
- Status: Accepted (2026-07-22) — staged per D4. D1 landed (#3429), D2 landed (#3432); D3 refined into two waves by the 2026-07-24 addendum below, and wave 2's ownership model settled by the 2026-07-27 addendum. Strict-default flip of D1/D2 tracked in #3438 — the media half flips per deployment (#3681); evidence for the remaining halves decided by the 2026-07-30 addendum. Wave 2 sequence in #3459.
- Date: 2026-07-22
- Issue: design follow-up generalizing #3405 / #3406 (inline lookup param silently stripped); relates #3407 (silently dropped writes), #1878 / #1891 (liveness audit: naming drift, dead file config)
- Relates to: ADR-0078 (no silently inert metadata), ADR-0049 /
Prime Directive #10 (declared ≠ enforced), Prime Directive #12
(contract-first, one strict contract beats N dialects), ADR-0053 (date vs
datetime semantics), ADR-0057 (system data lifecycle —
sys_filetombstone/reap), ADR-0087 (protocol upgrade contract)
packages/spec owns the authoring contract for field definitions
(FieldSchema, 60+ FieldType members) but does not own the contract for what
a field's runtime value looks like. "What does a lookup value look like
on the wire? What does a file field store? Is currency a number or an
object?" has no spec answer. The knowledge exists — but as private,
hand-duplicated re-derivations in every consumer:
packages/objectql/src/validation/record-validator.ts— the write-path validator keeps its ownMULTI_CAPABLE_TYPESset (line 153) and per-type branches; every type it doesn't know is an unvalidated "opaque payload" (lines 341–344):lookup(single),file,image,json,location,address,composite,repeater,record,vectorget no shape check at all.packages/rest/src/import-coerce.ts— re-derives six of its own type sets (lines 37–57, including a copy ofMULTI_CAPABLE_TYPES) and is the only place the intended storage shape per type is even written down (header comment, lines 6–31).packages/plugins/driver-sql/src/sql-driver.ts—JSON_COLUMN_TYPES(line 59) andNUMERIC_SCALAR_TYPES(line 81) drive DDL and (de)serialization; the file itself warns these must be kept in sync by hand because drift already caused a binder crash.packages/verify/src/read-coercion.ts— the driver conformance probe covers exactly 3 of 60+ types (boolean,json,integer).
Adding one multi-capable or JSON-shaped field type today means updating four lists in three packages, or silently corrupting data.
The spec does export three per-type value schemas — and they make things worse, not better, because all three are dead and two contradict reality:
CurrencyValueSchema(field.zod.ts:151) declares{value, currency}; the validator, the SQL driver, import-coerce, and the field-zoo round-trip all treat currency as a bare number.LocationCoordinatesSchema(field.zod.ts:121) declares{latitude, longitude}; the field-zoo oracle stores{lat, lng}.AddressSchemais imported by nothing but its own tests.
A designer (human or AI) who reads the spec is actively misled. This is ADR-0078's "silently inert metadata" problem, one level down: the instance values have no contract, so drift is invisible until a renderer and a writer disagree.
Three concrete failure classes motivated this design:
- Silently stripped declarations (#3405):
ActionParamSchemaacceptstype: 'lookup'but had noreferencekey; the author's semantically correctreference: 'sys_user'was stripped by.stripparsing and the picker degraded to a paste-a-UUID text box, with zero feedback. #3406 fixed that one key; the class remains. - Untyped action handlers: the declared
params[]contract (type,required,multiple,accept,maxSize,options) informs only the client dialog. The server passesreqBody.paramsthrough raw (http-dispatcher.ts:3882; same on the MCP path, line 1093), the sandbox exposes it asinput: unknown(script-runner.ts:60), and handlers are registered as(ctx: any) => any(objectql/src/engine.ts:678). Every shipped handler does unchecked casts ((params?.selectedIds ?? []) as string[],examples/app-todo/src/actions/task.handlers.ts:63).required, option membership,maxSize— none of it is enforced where it matters. - File fields bypass the platform's own file primitive. There are two
disconnected file worlds.
Field.file/image/avatar/video/audiovalues are inline JSON blobs ({url, name, size}— a shape no spec schema defines, no validator checks) stored in the record column (sql-driver.ts:59). Meanwhileservice-storagealready ships a first-class file object —sys_filewith opaquefileId, status lifecycle, tombstone/reap GC (ADR-0057), and parent-derived download authorization — but it is wired only to the Attachments panel (sys_attachment), andattachment-lifecycle.ts:27-29explicitly notes field values "reference files from record columns the join-row count cannot see". Consequences: no reference integrity, no GC (clearing aField.imageleaks the blob forever), anonymous capability URLs for everything that isn't attachments-scoped, noaccept/maxSizeanywhere inFieldSchema(the audit: "no size/type/virus enforcement in write path"), andclient.storage.upload()doesn't even return thefileId(the/upload/completeresponse omits it,storage-routes.ts:231-241).
The common root cause: the runtime value shape of a field is nobody's contract. This ADR makes it the spec's.
A new module packages/spec/src/data/field-value.zod.ts (exported via
@objectstack/spec/data) becomes the single source of truth for runtime value
shapes. It is pure schemas/constants/derivation — no business logic, per Prime
Directive #2. It exports:
- Semantic type classes — the sets every consumer currently hand-copies,
as named constants:
STRING_VALUE_TYPES,NUMERIC_VALUE_TYPES,BOOLEAN_VALUE_TYPES,OPTION_TYPES,MULTI_CAPABLE_TYPES,REFERENCE_TYPES(lookup/master_detail/user),FILE_REFERENCE_TYPES(file/image/avatar/video/audio),STRUCTURED_JSON_TYPES, plus the temporal classes (date= calendar day,datetime= UTC instant,time= clock time — codifying ADR-0053 into the spec instead of driver comments). valueSchemaFor(field, form)— a pure function from a field definition (type,multiple,options,reference, …) to a Zod schema for its runtime value.formnames the two canonical shapes a value has:'stored'— the canonical storage/wire form: what the write path accepts after normalization, what drivers persist, what an unexpanded API read returns. E.g.date→YYYY-MM-DDstring;datetime→ ISO-8601 UTC string;select→ declared option code (array whenmultiple);lookup→ record-id string (array whenmultiple);file→ file-id string (D3).'expanded'— the enriched read form produced by$expand:lookup→ the related record object;file→ the spec-ownedFileValueSchema(D3). For types without an expansion,expanded≡stored. This names the lookup polymorphism that already exists (engine.ts:2092-2098overwrites the field in place) instead of leaving every consumer to branch ontypeof val === 'object'.
FieldValue<T>— the inferred TS types, so handlers and SDK code can speak the same shapes at compile time (D2).
Reality wins. Where the de-facto stored shape is coherent, the contract
adopts it — deployed data is a wire contract we don't get to rewrite by
editing Zod. Concretely: currency is a scalar number —
CurrencyValueSchema is deleted (tombstoned in UNKNOWN_KEY_GUIDANCE /
changelog with the FROM → TO note); location adopts the stored {lat, lng}
and LocationCoordinatesSchema is rewritten to match; AddressSchema is
either adopted by the contract and enforced, or deleted — it does not remain
exported-but-dead. An exported-but-unconsumed value schema is exactly the
inert metadata ADR-0078 forbids.
Consumers converge on the contract (each keeps its role, loses its private type lists):
record-validator.tsdelegates per-type shape checks tovalueSchemaFor(field, 'stored'), keeping its error shaping (ValidationErrorper-field codes) and its normalization helpers (normalizeMultiValueFields,coerceBooleanFields). The "opaque payload" fallback (line 341) shrinks to only the types the contract genuinely leaves open (json,code). Types that today skip validation entirely — singlelookup,file,location,address— get shape checks.import-coerce.tsderives its six sets from the spec classes; its header comment stops being the only written record of the storage contract.driver-sqlderivesJSON_COLUMN_TYPES/NUMERIC_SCALAR_TYPESmembership from the spec classes (DDL column choice remains the driver's decision; the classification moves to the spec).packages/verify/read-coercion.tsgrows from a 3-type probe to asserting the full matrix: for every field type, a stored-form write round-trips to a stored-form read on every driver.
The conformance oracle is wired to the contract. The field-zoo round-trip
(packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts) already
encodes the intended wire shapes as a hand-written MATRIX; it gains an
assertion that every MATRIX entry parses under valueSchemaFor — so the
contract and the executable oracle cannot drift apart, and a contract change
that would break the wire fails a test instead of shipping silently.
Action params are already fields-lite (ActionParamSchema mirrors FieldType,
multiple, options, accept, maxSize). D1 gives them value schemas for
free. Three changes:
- Server-side enforcement at dispatch.
handleActions(REST,http-dispatcher.ts:3797) andinvokeBusinessAction(MCP, line 1080) resolve the invoked action's declaredparams[](field-backed params resolve through the referenced object field, as the dialog already does) and validatereqBody.paramsagainst a Zod object built fromvalueSchemaFor(param, 'stored')per param:requiredenforced, option membership enforced,multiplearrays enforced, reference values must be id-shaped, unknown param keys are a validation error — the #3405 lesson: strip-and-continue manufactures false success; loud beats lenient on an internal contract (Prime Directive #12). Failures return the standard400 VALIDATION_FAILEDper-field envelope before the handler runs. Actions declaring noparamskeep today's pass-through (there is nothing to check against), so existing param-less actions are untouched. - Typed handler surface.
defineActiontoday validates config only. It gains a typed companion so registered handlers stop being(ctx: any) => any:ctx.paramsis typed by mapping each declared param throughFieldValue<T>(a type-levelParamValue<typeof action.params>), andregisterActionacceptsActionHandler<A>instead ofany. Inline L2bodyscripts can't get static types (they're strings), but their sandboxinputis the same validated object, so the runtime guarantee holds on both handler forms;ScriptContext.input's doc comment states the contract instead ofunknown-with-a-shrug. - File/image params get a real path. Today a file param arrives as
"whatever the dialog put there" and there is no upload wiring at all. With
D3, a file param's value is a
fileId: the client uploads throughclient.storagefirst, submits the id, and dispatch validates the id refers to a committedsys_filerow the caller may read, enforcing the declaredaccept/maxSizeagainst the stored file's metadata — server-side, where it was never enforced before.
FILE_REFERENCE_TYPES field values become references, not blobs:
- Stored form: an opaque
fileIdstring (array whenmultiple) intosys_file— the same shape discipline aslookup. The inline{url, name, size}blob is retired from the write path. - Expanded form: a spec-owned
FileValueSchema{ id, name, size, mimeType, url }, produced at read/expand time from thesys_filerow.urlis derived, never stored — the stable resolverGET /api/v1/storage/files/:fileId(302 to bytes) already exists precisely for<img src>embedding. - Reference integrity + GC: on record write, the engine maintains
reference rows (the
sys_attachmentpattern generalized: parent object + record id + field name + file id) so the ADR-0057 tombstone/reap machinery — which today cannot see field columns — counts field references the same way it counts attachment join rows. Clearing or overwriting a file field decrements visibly; orphaned blobs get the existing tombstone → TTL → reap → reclaim path instead of leaking forever. (Scanning JSON columns for ids was rejected: unindexable and driver-specific.) - Authorization: field-referenced files get parent-derived read checks,
reusing the attachments
authorizeFileReadverdict model — possession of a URL stops being possession of the bytes. The anonymous capability-URL carve-out remains only where the field/file explicitly declares a public posture (acl: 'public_read'— avatars, logos), an opt-in instead of the default for every non-attachment file. - Upload contract fixes:
/upload/completereturns thefileId(today it is dropped, so the simpleclient.storage.upload()helper cannot even implement this design);FieldSchemagainsaccept/maxSizefor file types (currently declarable only on action params — the field side has nothing), enforced at upload admission and re-checked at record write fromsys_filemetadata.
Migration (this is the breaking piece; per ADR-0087 it rides a protocol major):
- Dual-read window: readers normalize a legacy inline blob to the expanded
form on the fly (
{url,...}→FileValueSchemawithid: null), so deployed records keep rendering. - Write-path cutover: new writes accept only references. A write presenting an inline blob fails validation with a tombstone-style message carrying the FROM → TO prescription (upload → submit the id).
- Backfill:
os migrateingests platform-hosted legacy blobs intosys_filerows and rewrites the column to the id. Externally-hosted URLs (CDN links the platform never stored) cannot be ingested; they remain legacy-read-only values surfaced by a migration report, not silently dropped (#3407 discipline).
Each phase is independently shippable and independently valuable:
- Contract + convergence (non-breaking): land
field-value.zod.ts, converge the four consumers' type lists, extend the verify probe, wire the field-zoo oracle assertion, delete/fix the three dead value schemas (breaking only for the dead exports — changeset carries the tombstones). - Typed action handlers (breaking only for already-broken inputs):
dispatch-time param validation + typed
defineAction/registerAction. Same posture as #3406: params that fail were silently wrong before; now they are loudly wrong. Release note calls it out. - File-as-reference (protocol major, cross-repo): storage-route +
FieldSchema+ write-path + GC + authz changes here; widget changes (submitfileId, render expanded form) inobjectui; sequenced like the ADR-0103 v16 enum split — server first (old clients' inline writes are rejected with the prescriptive error), console re-pinned before GA.
- Keep per-consumer knowledge, add lint/tests to sync the lists. Rejected: a sync-checker for four hand-copies is a workaround (Prime Directive #5); the lists exist because the contract has no home — give it one.
- Own the value shapes in
objectql(runtime) instead ofspec. Rejected: the shapes are consumed by non-runtime parties — import tooling, drivers, the external UI repo, MCP/AI tool schemas, docs generation. The spec is the one package all of them already import, and value shapes are contract, not logic. - A
{ id, name }shallow form for lookups instead of the stored/expanded pair. Rejected for now: it introduces a third wire form and breaks the "reality wins" rule (nothing stores or emits it today). The stored/expanded distinction merely names the two forms that already exist. Revisitable as an additive expansion profile later. - File values as inline objects with a validated schema (keep
{url, name, size}but make the spec bless it). Rejected: it legitimizes the world with no reference integrity, no GC, and capability-URL security — hardening a blob is strictly worse than referencing the file object the platform already ships. The attachments world proves the reference model works end to end. - Enforce action params only in the client dialog (server stays lenient). Rejected: the dialog is one of three callers (REST, MCP/AI tools, scripts); AI-driven invocation is precisely the caller most likely to send a plausible-but-wrong bag, and ADR-0049 discipline says the check belongs at the enforcement point, not the courtesy surface.
Named here so the phase PRs inherit them as acceptance items, not rediscoveries.
- R1 — validation from none to some strands legacy rows. Types that were "opaque payloads" get shape checks; a malformed value written under the lax regime would block the next edit of its record on an unrelated field. Phase 1 must validate only fields present in the write (the current validator's posture, kept deliberately) and ship a stored-data audit report, not retroactive rejection.
- R2 — the codified shape may be stricter than deployed reality. The
field-zoo oracle covers the intended path, not every historical variant
(e.g. SQLite
datetimecolumns mixing INTEGER epoch and TEXT ISO, repaired only at read). For types gaining their first-ever check, phase 1 lands the contract warn-first, flipped to error in the following minor once telemetry is quiet. - R3 — unknown-param-key rejection will hit real callers. Dispatch itself
merges
recordId/objectNameintoparams(http-dispatcher.ts:3944), and programmatic integrators send loose bags. D2 needs a built-in-key allowlist and the same warn-then-error window; "unknown key" errors must name the key and the declared param list. - R4 — GC mis-deletion is irreversible data loss. During the D3 migration window (records still carrying inline blobs invisible to reference counting), file reaping is frozen; the reap guard's delete-time re-verification must count field-reference rows before any reap resumes. Hard gate, not a nice-to-have.
- R5 — tightening anonymous URLs breaks live embeds. Avatars in emails,
shared images, org logos rely on capability URLs today. The migration must
produce an explicit public-posture inventory (which existing files stay
public_read) as a reviewed deliverable; guessing defaults here is a visible-outage generator. - R6 — sub-key reads break silently. Formulas, templates, hooks, and
flows reading
record.attachment.urlget an id string after D3. These usages are metadata, so a best-effort static scan plus the migration report must surface them; the changeset carries the FROM → TO rewrite. - R7 — external-URL usage of file fields is retired. Apps using
Field.fileto hold links to externally-hosted files must migrate those tourlfields; legacy values stay read-only. Migration guide item, not a silent drop (#3407 discipline). - R8 — cross-repo sequencing. Protocol major + objectui + console re-pin
is the #3340 / #2726 failure surface; the lockstep guard
(
protocol-version.test.ts) covers the version bump, the ADR-0103 v16 sequencing pattern covers the rollout order. - R9 — spec purity.
valueSchemaForis derivation, and must stay pure schema derivation (Prime Directive #2); runtime concerns (caching, driver choices) live in consumers.
Complexity-class estimates, to be confirmed by benchmarks that ship as phase acceptance criteria (wired to the #2408 Server-Timing surface where useful):
- D1/D2 are CPU-microsecond noise if and only if validators are cached.
Zod
parseon scalar values is ~µs;z.object()construction is an order of magnitude worse.valueSchemaForresults are built at metadata registration and cached per (object, field) / per action — a test guards against per-write construction. Budgets: bulk import (10k rows × 20 fields) validation overhead < 10% vs. baseline; action dispatch p95 + < 1 ms. Hooks are unaffected (sandbox execution is ms-scale; nested engine writes pay the same µs-scale validation). - D3 is the only structural cost. Write side: reference-row maintenance
adds 1–2 row writes per changed file value, same transaction, zero cost
when no file field changes; bulk imports of file-bearing rows see measurable
write amplification. Read side: resolving fileIds goes from 0 to exactly
one batched IN query per request — an N+1 regression test is mandatory;
sys_filerows are near-immutable after commit and cache well. Downloads: authorization happens at URL issuance (attachments already pay this); the 302 resolver and browser caching absorb repeat fetches. Budget: 100-row list including file fields, p95 increase bounded by one batched query; benchmark before/after in the phase-3 PR. Reference-row GC accounting runs in the existing ADR-0057 sweep, off the request path.
- The spec becomes the single answer to "what does this field's value look like" — for the validator, drivers, import, verify, the UI repo, AI tool schemas, and docs generation. Adding a field type means writing its value schema once, and the type is born validated, importable, driver-classified, and conformance-tested.
- Action handlers move from
params: any+ defensive casts to a validated, typed input with a 400 envelope for bad requests. Existing metadata whose params were silently mis-shaped starts failing loudly — intended (ADR-0078). - File fields gain integrity, GC, and authorization by joining the existing
sys_filemachinery; the platform stops shipping two file worlds. The cost is a protocol-major migration with a dual-read window and an explicit backfill report for non-ingestable external URLs. - Cross-repo obligations:
objectuimust adopt the expandedFileValueSchemarendering and fileId submission (phase 3), and can delete its own value-shape guesswork by importing the contract. - The three dead value-schema exports stop lying to readers — deleted or made true. Per the ADR-0078 discipline, "exported by the spec" once again implies "enforced somewhere".
D1 (#3429) and D2 (#3432) landed as single-repo, non-breaking (or breaking-only-for-already-broken) PRs under this ADR's umbrella. D3 (file-as-reference) is different in kind — breaking, cross-repo, protocol-major, and the only phase carrying irreversible risk (R4, GC deleting file bytes). A review of D3 against the platform's actual trajectory — an enterprise, Salesforce-shaped metadata platform whose authoring surface is increasingly driven by AI, where the governing goal is keep the AI author from silently producing wrong metadata — sharpened two things: D3's value went up (it closes the last authoring surface where an AI can write a silently-wrong value and get a success envelope — the ADR-0078 asymmetry, at the file layer), while its irreversible-migration risk is unchanged. So D3 is not deferred, but decomposed and re-sequenced by what serves authoring correctness first.
For an AI author, the check that prevents a mistake is the one that fires at
build/validate time (os validate / os build rejects, the way #3406
rejects a targetless inline lookup param) — a build failure the AI must fix, not
a runtime warning it never sees. The runtime warn-first posture (D1's
OS_DATA_VALUE_SHAPE_STRICT_ENABLED, D2's OS_ACTION_PARAMS_STRICT_ENABLED)
exists only to protect already-deployed data from stranding — it is not the
authoring gate.
Therefore the target end-state is two enforcement points, each with one job:
- Build/validate time → hard reject. Net-new metadata (the AI's output) must fail loudly at authoring. This is the primary defence against AI error.
- Runtime → warn-first, then flip. Deployed data keeps working through the warn window; the flip to strict-by-default (tracked in #3438) closes it. (Amended by the second 2026-07-27 addendum: "once telemetry is quiet" was our telemetry deciding for their deployments. The media half now flips per deployment, when that deployment's own file-as-reference migration has verified. The reference and structured-JSON halves still await an evidence source of their own — the file migration does not vouch for them.)
This refines D2 (today runtime-only) and reshapes the #3438 flip: the priority is adding the build-time rejection for value shapes and action params, not just flipping the runtime default.
file is shorthand throughout D3 for the entire FILE_REFERENCE_TYPES class
D1 already defined — file, image, avatar, video, audio. All five
store the same inline blob today, all five bypass sys_file, and all five move
to the reference model together. There is no separate story for image; the
contract is one class.
Directly serves "keep the AI author correct." Ships independently of the protocol major, single-repo, no irreversible risk:
- Give the media class a declared
FileValueSchema— the inline form the platform stores today ({ url, name?, size?, mimeType?, alt?, duration? },urlrequired) — replacing D1's loose transitional union.valueSchemaForfor the class returns the transitional union of this declared object and the opaque id/url string (still accepted for import-compat), so a malformed file value (a number, an empty object, garbage) is now caught instead of waved through. Exported so wave 2 andobjectuiconsume the one shape. - Per the enforcement-point principle, this shape should reject at build/validate time for net-new (AI-authored) metadata while staying warn-first at runtime for deployed data — the same posture D1 established for every other value type.
Wave 1 is effectively a "D2.5": no sys_file rewiring, no migration, no
protocol bump. Note what wave 1 deliberately does not do: it does not add
accept / maxSize to FieldSchema. Those govern an actual upload, so
enforcing them authoritatively needs the server to know the real bytes — i.e.
the sys_file model. Adding them before that would ship exactly the inert knob
ADR-0078 forbids, so they belong to wave 2.
The reference model + governance, sequenced after wave 1 and carrying the irreversible risk. Ride a planned breaking window (as ADR-0103's enum split rode v16/v17) to amortise the migration cost:
- File field value becomes an opaque
sys_fileid; the expanded read form is the spec-ownedFileValueSchema(urlderived via/files/:fileId, never stored) — D1/D2 §D3 as written. - Field references join the ADR-0057 tombstone/reap GC (leak + GDPR-erasure
fix); governed download reuses the attachments
authorizeFileRead, with the anonymous capability URL demoted to an opt-inacl: 'public_read'. accept/maxSizeland onFieldSchemahere — not in wave 1 — because now the server owns the file (sys_filemetadata), so it can enforce declared MIME/size at upload admission and re-check at record write authoritatively, rather than trusting a client-supplied blob.- Non-negotiable acceptance gates (from Risks): R4 GC frozen during the
migration window until reference-row counting is verified; R5 a reviewed
public-posture inventory; R6 a static scan for sub-key reads
(
record.file.urlin formulas/templates/hooks/flows). - External-URL usage of
type: 'file'is retired toward aurlfield (R7) — under AI authoring this is desirable: it disambiguates "managed file" from "external link" so the AI cannot conflate them.
The 2026-07-24 addendum left one thing open, and it turned out to be the
decision the rest of wave 2 hangs off: when a field holds a sys_file id,
what is the relationship between file and record? Implementing the reference
rows forced the answer.
Enterprise platforms solve file lifecycle two ways:
- Junction-for-everything (Salesforce
ContentDocumentLink, SAP GOS): every reference is a row in one polymorphic link table; a file is shared, and dies when the last link does. Sharing is the feature. - Column-as-reference (Dataverse File/Image columns): the column is the reference; lifecycle follows the row; no sharing.
ObjectStack already runs the first for its attachments surface (sys_attachment
- the ADR-0057 tombstone/reap), and that is right — attaching one document to several records is exactly what that surface is for, and the user performs the attach explicitly.
Field references take the second model. A product.image is a property of
that product, and properties are copied, not shared. Concretely: at most one
(object, record, field) slot owns a given sys_file; the owner is recorded on
sys_file.ref_object / ref_id / ref_field; writing an already-owned id into
a second slot copies the bytes into a fresh sys_file rather than sharing
the row.
Sharing is more storage-efficient and matches the better-known lineage. We take exclusivity anyway, because under AI-authored metadata the two models fail differently and the difference is not symmetric.
The naive generated clone — insert({...src, id: undefined}) — spreads a file
id into a second record. Every model must let that code work; rejecting it
would force AI authors to learn a bespoke file API, which is precisely the kind
of special case that produces mistakes. So the real question is what happens
after it works:
| worst case | recoverable? | |
|---|---|---|
| Shared + reference-counted | file's readable-by set becomes the union of every referrer's — copying a private record's id into a world-readable record silently widens access; and a single miscount authorises an irreversible byte delete | no |
| Exclusive + copy-on-claim | duplicated bytes | yes — it costs money, not data |
Read authorisation for attachment files already derives from the parent record. Under sharing, "the parent record" is ambiguous, and the safe reading of an ambiguous ACL is the union — which means generated code can leak data while containing no visible error. Exclusivity removes the ambiguity by construction: one file, one parent, one ACL.
The storage cost this concedes belongs at a different layer anyway: dedup the
bytes, not the rows. Content-hash dedup behind the storage adapter (etag is
already carried on sys_file) recovers the savings while leaving every row its
own owner and its own ACL — the lifecycle never has to reason about sharing.
A genuinely shared library asset should be modelled as its own object with a
lookup, or attached through the attachments surface; steering AI authors there
is the correct outcome, not a limitation.
A file becomes collectable only because the platform observed its one owner let go — never because a scan inferred that nobody references it.
The existing attachment path already obeys this (the tombstone is set by the hook that watched the join row die, not by a sweep that found none). Exclusive ownership extends the same discipline to fields, and eliminates counting entirely: with no count, no miscount can authorise a delete. It also defuses the cold-start hazard a new ledger would otherwise carry — a freshly-built reference table knows nothing of references that predate it, so trusting its zeroes would mass-delete history.
The 2026-07-24 plan packaged the write cutover and the GC enable together, and placed the backfill after. That order is wrong: a ledger may not authorise deletion until it has been backfilled and reconciled. Wave 2 therefore sequences as:
- Ownership bookkeeping —
ref_*columns, claim/release on the write path, copy-on-claim. Records ownership; deletes nothing. Dormant until a field actually holds an id, so it lands safely ahead of the cutover. - Governed download — read authorisation derived from the one owning
record; anonymous capability URL demoted to opt-in
acl: 'public_read'. - Backfill — converts legacy inline blobs and own-resolver URLs to
sys_filereferences, which the claim hooks then own; external URLs are reported, never re-hosted. Dry-run by default, idempotent. - Write cutover (protocol major) — stored form narrows to an id;
accept/maxSizeenforced. Still deletes nothing. - Reconciliation soak —
verify-referencescompares what records actually hold against recorded ownership, and splits disagreements by whether they can cause data loss: blocking (a held file nothing owns; a file held by a slot that does not own it; one file held by two slots) versus advisory (owned but no longer held — fails toward retention; unreferenced committed files — storage cost only). - GC enable — gated, irreversible. Relaxing the
scope === 'attachments'tombstone guardrail and extending thesys_filereap guard's sweep-time re-verify to the ownership columns must ship in the same change. Half of it is worse than none: tombstoning released files while the guard still re-verifies onlysys_attachment— always empty for a field file — turns every release into a guaranteed byte delete rather than a risky one.
Backfill precedes the cutover for the same reason it precedes collection, one step further back: narrowing the accepted stored form while records still hold legacy values would reject any update that rewrites such a field, breaking working apps until the backfill catches up. Backfilling first leaves the cutover nothing legacy to reject.
R4's acceptance gate is now executable rather than aspirational: step 5's reconciliation must report zero blocking discrepancies before step 6 may act. (The original wording — "for ≥7 consecutive days on real tenant data" — assumed a tenant we operate and observe; see the 2026-07-27 platform-form addendum below, which relocates the same evidence requirement to the one place that can satisfy it.) R5 (public-posture inventory) and R6 (sub-key read scan) stand.
Steps 4 and 6 are the two that can break something. Step 4 is a declared protocol major and breaks loudly — a rejected write, recoverable. Step 6 breaks silently and permanently — deleted bytes. They are therefore held to different standards: step 4 rides the planned v17 window paired with the client adoption that consumes the new form, while step 6 additionally requires the reconciliation evidence above, and neither the enable nor the migration run should be performed without an explicit human decision.
The gate above was written as an operations runbook: run the backfill on the
tenant, watch verifyFileReferences stay clean for a week, then flip the
switch. Every clause of that sentence assumes we run the tenant, we
observe it, and we flip.
ObjectStack is a development platform. Third-party developers build metadata apps on it and ship versions onward; each deployment decides for itself when to upgrade, and its data is not visible to us — nor should it be. There is no observation window of ours that anyone else's data can wait inside. A gate phrased as one is not strict, merely unlocatable: it can be satisfied by no deployment at all, including the deployments whose bytes are at stake.
R4's substance is unchanged: a ledger may not be given the power to delete bytes until it has been shown to agree with reality. What changes is who produces the evidence — from us, once, to each deployment, for itself.
That rules out a one-shot script (a script whose output nobody must read is not a gate) and rules in a data-migration step with a self-check, whose passing result is recorded on the deployment:
os migrate files-to-references
1. backfill (dry run by default; --apply writes)
2. verifyFileReferences (reconcile the ledger against what records hold)
3. zero blocking findings → record sys_migration { id: 'adr-0104-file-references',
verified_at, blocking: 0 }
4. collection + strict enforcement read THAT ROW, never the version number
- Upgrading does not start deleting. Installing a new version is not consent; running the migration and passing its self-check is.
- A third-party developer need not understand R4. They run one command. Green means done; red names the record holding a file nobody owns.
- Not run, or not passed → files live forever. Wasted storage, zero data
loss: "fail toward retention" holds on the deployment axis too. The same
rule applies to a regression — a later failing run clears
verified_at, so a deployment whose data has drifted closes its own gate. - Each deployment gets its own 30-day tombstone window. Nobody waits inside anyone else's soak. The irreversible moment is day 30 after that deployment enabled collection, not release day.
- A dry run changes nothing — not the data, and not the flag either, even
when the self-check would pass.
--applyis the only writing mode, so "did this run change my posture" never depends on what the run found.
The strict-by-default flip for value-shape and action-param validation (#3438) was scheduled as "flip in a later minor once telemetry is quiet" — again our telemetry, deciding for their deployments. A deployment that has not finished backfilling would have every media-field update rejected the moment it upgraded: a working app broken by a decision made on its behalf.
Both therefore read the same flag. Strict enforcement becomes effective for a deployment when that deployment has completed and verified its migration — not when a version number arrives. One flag, not two gates that can disagree: they are gating on the same fact.
Amendment while implementing this (2026-07-27, later still). "#3438 reads
the flag" was too broad: #3438 is three things, and the flag only covers one.
The flag asserts this deployment's file-field values have been migrated and
reconciled. That is evidence about media value shapes and nothing else —
it says nothing about whether a lookup id or a location payload is well
formed, and nothing at all about D2's action parameters. Gating those on it
would be borrowing evidence for a fact it does not cover, which is the same
error one layer down: an authority answering a question it was not asked.
So the split is:
| evidence | status | |
|---|---|---|
D1 media (file/image/avatar/video/audio) |
the adr-0104-file-references flag |
flips per deployment |
| D1 references + structured JSON | none yet — needs its own | stays warn-first |
| D2 action params | unrelated to any data migration | stays warn-first |
The last two are not blocked on the mechanism — it exists and works. They are
blocked on someone deciding what would constitute evidence that a deployment's
location values are sound, which is a different question from the one this
addendum answers.
Whether an external URL (https://cdn…) should become an explicit url
field is a modelling decision only the app's author can make (R7). It also
cannot block: an external URL is not a sys_file and can never enter
collection. It is reported as advisory, never as a gate failure.
D3 is already this ADR's third phase; the two-wave split and the
enforcement-point principle are a refinement of the D4 rollout, not a new
decision, so they live here. Wave 2's migration mechanics (dual-read window,
os migrate backfill, the R4/R5/R6 gates) are specified in §D3 above and need
no separate record. Wave 2's implementation did surface two genuinely new
decisions — the exclusive-ownership model, and relocating the evidence gate to
the deployment — and both are recorded as 2026-07-27 addenda above rather than
separate ADRs, because each settles how D3's already-accepted "field values
point into sys_file" reaches production rather than revisiting whether to do
it. A future choice that stands on its own (say, a chunked-migration protocol,
or byte-layer content dedup) would earn its own ADR.
Addendum (2026-07-30) — evidence for the remaining flips: a scan gate for references / structured JSON; the action-param flip rides a declared major
The 2026-07-27 amendment split #3438 into three and settled only the first:
media value shapes flip per deployment, on the adr-0104-file-references flag
(#3681). The other two were left "blocked on someone deciding what would
constitute evidence." This addendum decides — differently for the two, because
their facts have different epistemic reach — and fixes the release vehicle for
each around the v17 major.
One timeline fact frames everything: warn-first itself first reaches stable deployments in 17.0. D1 and D2 ride the v17 train (their changesets sit in the same pre-mode window as this addendum), so no stable deployment has served a single day of the warn window yet. Whatever the right gate is, 17.0 cannot flip any default version-wide without deleting the warn window R2/R3 promised — independent of the per-deployment argument.
The fact strict enforcement needs is: no stored value of the covered classes
fails valueSchemaFor(field, 'stored'). The covered classes are exactly the
validator's own non-media branch — REFERENCE_VALUE_TYPES (single-value
lookup / master_detail / user / tree) and STRUCTURED_JSON_TYPES
(location / address / composite / repeater / record / vector;
json's derived schema is deliberately z.unknown(), so it can yield no
findings). Unlike caller behaviour, data at rest is enumerable: a scan that
walks every row is complete evidence, with the same epistemic standing the
file reconciliation has. So the gate takes the #3617 shape, minus the
backfill:
os migrate value-shapes
1. scan every object's covered fields against valueSchemaFor(field, 'stored')
2. report violations per (object, field, type): count, sample record ids,
first parse issue — the per-deployment form of R1's stored-data audit
3. zero blocking findings + --apply → record sys_migration
{ id: 'adr-0104-value-shapes', verified_at, blocking: 0 }
4. strict enforcement of the covered classes reads THAT row, never the
version number
- There is nothing to convert. A malformed
locationpayload, or an expanded object stored where an id belongs, is an application-data fix only its owner can make: the command reports and prescribes; the operator fixes and re-runs until green. (A mechanical normaliser — say, collapsing a stored expanded-lookup object to itsid— is deliberately not built until real reports show the shape common enough to justify one; a normaliser that guesses is how silently-wrong values got here.) With no conversions,--apply's only write is the flag row — which keeps the #3617 invariant intact: a dry run changes nothing, and whether a run changed the deployment's posture never depends on what the run found. - The scanner and the validator share one predicate. Same class sets,
same cached
valueSchemaFor, same skip rules (system/readonly/ lifecycle columns are never validated, so they are never scanned): the scan must count exactly what strict mode would reject on that record's next rewrite, and nothing else. Imported, not re-derived — hand-copied value knowledge is the disease this ADR exists to cure. - Gate consumption mirrors the media half verbatim: the engine reads the
flag once per process (memoized), an object declaring no covered field never
consults it,
ValidateRecordOptionsgains the parallel option besidemediaValueShapeStrict, and a later failing run clearsverified_at— a regression closes its own gate. - Precedence, and the one new env var:
OS_ALLOW_LAX_VALUE_SHAPES(anOS_ALLOW_*escape hatch per Prime Directive #9, scoped to these classes — media keeps its ownOS_ALLOW_LAX_MEDIA_VALUES) beatsOS_DATA_VALUE_SHAPE_STRICT_ENABLED(unchanged: still the all-class opt-in) beats the flag. Same contradictory-config rule asmediaStrictEffective, for the same reason: wrongly lax costs a warning, wrongly strict stops a working app.
Today nothing records a flag except a migration run, which leaves a gap at the other end of a deployment's life: a deployment born on ≥17 starts lax and stays lax until its operator thinks to run a migration that is, for them, a no-op. The warn regime would never die out — every new deployment re-enters it.
So the bootstrap that creates an empty datastore records both rows
(adr-0104-file-references, adr-0104-value-shapes) at creation time. This
is not version-gating in disguise: the fact recorded — no legacy value
exists here — is observed (the store is empty by construction at the
moment of creation), the same observed-transition discipline the GC gate runs
on. An existing database upgrading to 17 is non-empty at boot, gets no
attestation, and keeps producing its evidence by scan. A later legacy import
into a born-strict deployment is rejected loudly at the write path — the
correct outcome; an operator who must temporarily admit such data has the
escape hatch, and re-verifies by re-running the scan. The exact seam is the
implementing PR's decision (candidates: the system seed that first creates
sys_migration, or the engine's schema bootstrap), with one hard
requirement: it fires only for a store it is itself creating from empty,
never for one it found.
Born-strict deployments also make the platform's own dogfood the standing canary for R2 (a codified shape stricter than some legitimate client's writes): showcase/CRM boots are fresh datastores, so they enforce from birth, and a false rejection fails our suites before any customer sees it.
What per-deployment evidence would have to prove is: no caller of this deployment still depends on the lax contract. Callers are not enumerable — an integration that fires monthly, a script in a cron nobody remembers, an AI agent constructing each call fresh. Data at rest can be scanned to completion; future calls cannot. A "quiet for N days" flag would assert a completeness it cannot have — the unlocatable-gate error of the 2026-07-27 addendum, reproduced one layer down. A gate must not claim evidence that cannot exist, and we decline to build the theatre of one.
What remains is the failure-mode calculus that already sequenced wave 2: a strict rejection here is loud (a 400 naming the offending param and the declared list), caller-facing (a developer or an AI mid-integration — not an end user stranded in a form above data they cannot fix), recoverable (fix the call), and reversible (the escape hatch below). No data is stranded, no byte is deleted. That is precisely the class of break wave 2's step 4 was allowed to ride a declared major for, while step 6 waits on evidence.
So, owned explicitly rather than smuggled: the D2 flip is a version flip —
on the major after the warn window, not this one. 17.0 ships warn-first
(first exposure) and announces the coming default in its release notes; 18.0
flips actionParamsStrict() to default-true. The v17→v18 gap is every
deployment's own warn window, served on its own upgrade schedule; the warn
line already names OS_ACTION_PARAMS_STRICT_ENABLED=1, which is the
per-deployment act of opting into tomorrow's default today. Version-wide
flips remain wrong where per-deployment evidence is possible (D1); where it
is impossible in principle, a declared major with a served warn window and an
escape hatch is what honest looks like. The alternative — warn forever —
permanently un-enforces the declared contract at exactly the surface (AI/MCP
callers) D2 was built for, where a 400 is corrective feedback the caller
consumes and a server-side warn is feedback nobody sees.
Mechanics at 18.0: OS_ACTION_PARAMS_STRICT_ENABLED stays accepted — it
becomes a redundant opt-in to the default; its semantics never change, so no
readEnvWithDeprecation rename, the same conclusion the media half reached.
A new OS_ALLOW_LAX_ACTION_PARAMS opt-out wins over everything (the
contradictory-config rule again), and the dogfood contract suite
(action-params-contract.dogfood.test.ts) flips its duals so the default
path is the strict one and the escape hatch is the explicitly-set case.
| vehicle | change |
|---|---|
| 17.0 | warn-first first ships (D1 non-media + D2). os migrate value-shapes + the adr-0104-value-shapes gate wiring. Fresh-datastore attestation of both flags. Release notes announce the 18.0 D2 default. No version-wide default changes. |
| each deployment, ≥17.0 | runs os migrate value-shapes --apply (or is born attested) → its own D1 non-media classes flip to strict. |
| 18.0 | D2 strict by default + OS_ALLOW_LAX_ACTION_PARAMS; dogfood duals flip. |
#3438 then tracks two work items instead of an open question: the scan migration (command, engine wiring, creation-time attestation) targeting the 17.0 train, and the D2 flip parked for the 18.0 train with its release-note announcement landing now.