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. Strict-default flip of D1/D2 tracked in #3438.
- 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 once telemetry is quiet.
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.
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. Should wave 2's implementation surface a genuinely new
decision (e.g. the reference-table shape, or a chunked-migration protocol), that
specific choice — not file-as-reference as a whole — would earn its own ADR.