Skip to content

Commit 3de9f6f

Browse files
committed
docs(adr): ADR-0104 — field runtime value-shape as a first-class contract
Design for three coupled gaps with one root cause (the runtime value shape of a field is nobody's contract): - D1: spec-owned valueSchemaFor(field) + semantic type classes; converge the four hand-duplicated type lists (record-validator, import-coerce, driver-sql, verify); reconcile the three dead value-schema exports; wire the field-zoo oracle to the contract. - D2: typed action handlers — declared params validated at REST/MCP dispatch, typed ctx.params via FieldValue<T>, file params become fileId references. - D3: file-as-reference — Field.file/image values store sys_file ids instead of inline {url} blobs, joining the existing lifecycle/GC/authz machinery. - D4: three-phase rollout (non-breaking convergence -> param enforcement -> protocol-major file migration). Generalizes the #3405/#3406 silently-stripped-param incident; relates #3407, #1878/#1891. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd
1 parent b132181 commit 3de9f6f

1 file changed

Lines changed: 314 additions & 0 deletions

File tree

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
# ADR-0104: Field runtime value-shape as a first-class contract — spec-owned value schemas, typed action handlers, file-as-reference
2+
3+
- **Status**: Proposed
4+
- **Date**: 2026-07-22
5+
- **Issue**: design follow-up generalizing #3405 / #3406 (inline lookup param
6+
silently stripped); relates #3407 (silently dropped writes), #1878 / #1891
7+
(liveness audit: naming drift, dead file config)
8+
- **Relates to**: ADR-0078 (no silently inert metadata), ADR-0049 /
9+
Prime Directive #10 (declared ≠ enforced), Prime Directive #12
10+
(contract-first, one strict contract beats N dialects), ADR-0053 (date vs
11+
datetime semantics), ADR-0057 (system data lifecycle — `sys_file`
12+
tombstone/reap), ADR-0087 (protocol upgrade contract)
13+
14+
## Context
15+
16+
`packages/spec` owns the authoring contract for field **definitions**
17+
(`FieldSchema`, 60+ `FieldType` members) but does not own the contract for what
18+
a field's **runtime value** looks like. "What does a `lookup` value look like
19+
on the wire? What does a `file` field store? Is `currency` a number or an
20+
object?" has no spec answer. The knowledge exists — but as **private,
21+
hand-duplicated re-derivations** in every consumer:
22+
23+
- `packages/objectql/src/validation/record-validator.ts` — the write-path
24+
validator keeps its own `MULTI_CAPABLE_TYPES` set (line 153) and per-type
25+
branches; every type it doesn't know is an unvalidated "opaque payload"
26+
(lines 341–344): `lookup` (single), `file`, `image`, `json`, `location`,
27+
`address`, `composite`, `repeater`, `record`, `vector` get **no shape check
28+
at all**.
29+
- `packages/rest/src/import-coerce.ts` — re-derives six of its own type sets
30+
(lines 37–57, including a copy of `MULTI_CAPABLE_TYPES`) and is the only
31+
place the intended storage shape per type is even written down (header
32+
comment, lines 6–31).
33+
- `packages/plugins/driver-sql/src/sql-driver.ts``JSON_COLUMN_TYPES`
34+
(line 59) and `NUMERIC_SCALAR_TYPES` (line 81) drive DDL and (de)serialization;
35+
the file itself warns these must be kept in sync by hand because drift
36+
already caused a binder crash.
37+
- `packages/verify/src/read-coercion.ts` — the driver conformance probe covers
38+
exactly 3 of 60+ types (`boolean`, `json`, `integer`).
39+
40+
Adding one multi-capable or JSON-shaped field type today means updating four
41+
lists in three packages, or silently corrupting data.
42+
43+
The spec does export three per-type value schemas — and they make things
44+
worse, not better, because **all three are dead and two contradict reality**:
45+
46+
- `CurrencyValueSchema` (`field.zod.ts:151`) declares `{value, currency}`;
47+
the validator, the SQL driver, import-coerce, and the field-zoo round-trip
48+
all treat currency as a **bare number**.
49+
- `LocationCoordinatesSchema` (`field.zod.ts:121`) declares
50+
`{latitude, longitude}`; the field-zoo oracle stores `{lat, lng}`.
51+
- `AddressSchema` is imported by nothing but its own tests.
52+
53+
A designer (human or AI) who reads the spec is actively misled. This is
54+
ADR-0078's "silently inert metadata" problem, one level down: the *instance
55+
values* have no contract, so drift is invisible until a renderer and a writer
56+
disagree.
57+
58+
Three concrete failure classes motivated this design:
59+
60+
1. **Silently stripped declarations** (#3405): `ActionParamSchema` accepts
61+
`type: 'lookup'` but had no `reference` key; the author's semantically
62+
correct `reference: 'sys_user'` was stripped by `.strip` parsing and the
63+
picker degraded to a paste-a-UUID text box, with zero feedback. #3406 fixed
64+
that one key; the class remains.
65+
2. **Untyped action handlers**: the declared `params[]` contract (`type`,
66+
`required`, `multiple`, `accept`, `maxSize`, `options`) informs **only the
67+
client dialog**. The server passes `reqBody.params` through raw
68+
(`http-dispatcher.ts:3882`; same on the MCP path, line 1093), the sandbox
69+
exposes it as `input: unknown` (`script-runner.ts:60`), and handlers are
70+
registered as `(ctx: any) => any` (`objectql/src/engine.ts:678`). Every
71+
shipped handler does unchecked casts
72+
(`(params?.selectedIds ?? []) as string[]`,
73+
`examples/app-todo/src/actions/task.handlers.ts:63`). `required`,
74+
option membership, `maxSize` — none of it is enforced where it matters.
75+
3. **File fields bypass the platform's own file primitive.** There are two
76+
disconnected file worlds. `Field.file` / `image` / `avatar` / `video` /
77+
`audio` values are inline JSON blobs (`{url, name, size}` — a shape no spec
78+
schema defines, no validator checks) stored in the record column
79+
(`sql-driver.ts:59`). Meanwhile `service-storage` already ships a
80+
first-class file object — `sys_file` with opaque `fileId`, status
81+
lifecycle, tombstone/reap GC (ADR-0057), and parent-derived download
82+
authorization — but it is wired **only** to the Attachments panel
83+
(`sys_attachment`), and `attachment-lifecycle.ts:27-29` explicitly notes
84+
field values "reference files from record columns the join-row count cannot
85+
see". Consequences: no reference integrity, no GC (clearing a `Field.image`
86+
leaks the blob forever), anonymous capability URLs for everything that
87+
isn't attachments-scoped, no `accept`/`maxSize` anywhere in `FieldSchema`
88+
(the audit: "no size/type/virus enforcement in write path"), and
89+
`client.storage.upload()` doesn't even return the `fileId` (the
90+
`/upload/complete` response omits it, `storage-routes.ts:231-241`).
91+
92+
The common root cause: **the runtime value shape of a field is nobody's
93+
contract.** This ADR makes it the spec's.
94+
95+
## Decision
96+
97+
### D1 — Spec owns the value-shape contract: `valueSchemaFor(field)`
98+
99+
A new module `packages/spec/src/data/field-value.zod.ts` (exported via
100+
`@objectstack/spec/data`) becomes the single source of truth for runtime value
101+
shapes. It is pure schemas/constants/derivation — no business logic, per Prime
102+
Directive #2. It exports:
103+
104+
1. **Semantic type classes** — the sets every consumer currently hand-copies,
105+
as named constants: `STRING_VALUE_TYPES`, `NUMERIC_VALUE_TYPES`,
106+
`BOOLEAN_VALUE_TYPES`, `OPTION_TYPES`, `MULTI_CAPABLE_TYPES`,
107+
`REFERENCE_TYPES` (`lookup` / `master_detail` / `user`),
108+
`FILE_REFERENCE_TYPES` (`file` / `image` / `avatar` / `video` / `audio`),
109+
`STRUCTURED_JSON_TYPES`, plus the temporal classes (`date` = calendar day,
110+
`datetime` = UTC instant, `time` = clock time — codifying ADR-0053 into the
111+
spec instead of driver comments).
112+
2. **`valueSchemaFor(field, form)`** — a pure function from a field definition
113+
(`type`, `multiple`, `options`, `reference`, …) to a Zod schema for its
114+
runtime value. `form` names the two canonical shapes a value has:
115+
- **`'stored'`** — the canonical storage/wire form: what the write path
116+
accepts after normalization, what drivers persist, what an unexpanded API
117+
read returns. E.g. `date``YYYY-MM-DD` string; `datetime` → ISO-8601
118+
UTC string; `select` → declared option code (array when `multiple`);
119+
`lookup` → record-id string (array when `multiple`); `file` → file-id
120+
string (D3).
121+
- **`'expanded'`** — the enriched read form produced by `$expand`:
122+
`lookup` → the related record object; `file` → the spec-owned
123+
`FileValueSchema` (D3). For types without an expansion, `expanded`
124+
`stored`. This names the lookup polymorphism that already exists
125+
(`engine.ts:2092-2098` overwrites the field in place) instead of leaving
126+
every consumer to branch on `typeof val === 'object'`.
127+
3. **`FieldValue<T>`** — the inferred TS types, so handlers and SDK code can
128+
speak the same shapes at compile time (D2).
129+
130+
**Reality wins.** Where the de-facto stored shape is coherent, the contract
131+
adopts it — deployed data is a wire contract we don't get to rewrite by
132+
editing Zod. Concretely: `currency` **is a scalar number**
133+
`CurrencyValueSchema` is deleted (tombstoned in `UNKNOWN_KEY_GUIDANCE` /
134+
changelog with the FROM → TO note); `location` adopts the stored `{lat, lng}`
135+
and `LocationCoordinatesSchema` is rewritten to match; `AddressSchema` is
136+
either adopted by the contract and enforced, or deleted — it does not remain
137+
exported-but-dead. An exported-but-unconsumed value schema is exactly the
138+
inert metadata ADR-0078 forbids.
139+
140+
**Consumers converge on the contract** (each keeps its role, loses its private
141+
type lists):
142+
143+
- `record-validator.ts` delegates per-type shape checks to
144+
`valueSchemaFor(field, 'stored')`, keeping its error shaping
145+
(`ValidationError` per-field codes) and its normalization helpers
146+
(`normalizeMultiValueFields`, `coerceBooleanFields`). The "opaque payload"
147+
fallback (line 341) shrinks to only the types the contract genuinely leaves
148+
open (`json`, `code`). Types that today skip validation entirely — single
149+
`lookup`, `file`, `location`, `address` — get shape checks.
150+
- `import-coerce.ts` derives its six sets from the spec classes; its header
151+
comment stops being the only written record of the storage contract.
152+
- `driver-sql` derives `JSON_COLUMN_TYPES` / `NUMERIC_SCALAR_TYPES` membership
153+
from the spec classes (DDL column choice remains the driver's decision; the
154+
*classification* moves to the spec).
155+
- `packages/verify/read-coercion.ts` grows from a 3-type probe to asserting
156+
the full matrix: for every field type, a stored-form write round-trips to a
157+
stored-form read on every driver.
158+
159+
**The conformance oracle is wired to the contract.** The field-zoo round-trip
160+
(`packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts`) already
161+
encodes the intended wire shapes as a hand-written MATRIX; it gains an
162+
assertion that every MATRIX entry `parse`s under `valueSchemaFor` — so the
163+
contract and the executable oracle cannot drift apart, and a contract change
164+
that would break the wire fails a test instead of shipping silently.
165+
166+
### D2 — Typed action handlers: declared params become the enforced, typed handler input
167+
168+
Action params are already fields-lite (`ActionParamSchema` mirrors `FieldType`,
169+
`multiple`, `options`, `accept`, `maxSize`). D1 gives them value schemas for
170+
free. Three changes:
171+
172+
1. **Server-side enforcement at dispatch.** `handleActions` (REST,
173+
`http-dispatcher.ts:3797`) and `invokeBusinessAction` (MCP, line 1080)
174+
resolve the invoked action's declared `params[]` (field-backed params
175+
resolve through the referenced object field, as the dialog already does)
176+
and validate `reqBody.params` against a Zod object built from
177+
`valueSchemaFor(param, 'stored')` per param: `required` enforced, option
178+
membership enforced, `multiple` arrays enforced, reference values must be
179+
id-shaped, **unknown param keys are a validation error** — the #3405
180+
lesson: strip-and-continue manufactures false success; loud beats lenient
181+
on an internal contract (Prime Directive #12). Failures return the standard
182+
`400 VALIDATION_FAILED` per-field envelope *before* the handler runs.
183+
Actions declaring no `params` keep today's pass-through (there is nothing
184+
to check against), so existing param-less actions are untouched.
185+
2. **Typed handler surface.** `defineAction` today validates config only. It
186+
gains a typed companion so registered handlers stop being
187+
`(ctx: any) => any`: `ctx.params` is typed by mapping each declared param
188+
through `FieldValue<T>` (a type-level `ParamValue<typeof action.params>`),
189+
and `registerAction` accepts `ActionHandler<A>` instead of `any`. Inline
190+
L2 `body` scripts can't get static types (they're strings), but their
191+
sandbox `input` is the *same validated object*, so the runtime guarantee
192+
holds on both handler forms; `ScriptContext.input`'s doc comment states the
193+
contract instead of `unknown`-with-a-shrug.
194+
3. **File/image params get a real path.** Today a file param arrives as
195+
"whatever the dialog put there" and there is no upload wiring at all. With
196+
D3, a file param's value is a `fileId`: the client uploads through
197+
`client.storage` first, submits the id, and dispatch validates the id
198+
refers to a committed `sys_file` row the caller may read, enforcing the
199+
declared `accept` / `maxSize` against the stored file's metadata —
200+
server-side, where it was never enforced before.
201+
202+
### D3 — File-as-reference: field values point into `sys_file`
203+
204+
`FILE_REFERENCE_TYPES` field values become **references, not blobs**:
205+
206+
- **Stored form**: an opaque `fileId` string (array when `multiple`) into
207+
`sys_file` — the same shape discipline as `lookup`. The inline
208+
`{url, name, size}` blob is retired from the write path.
209+
- **Expanded form**: a spec-owned `FileValueSchema`
210+
`{ id, name, size, mimeType, url }`, produced at read/expand time from the
211+
`sys_file` row. `url` is **derived, never stored** — the stable resolver
212+
`GET /api/v1/storage/files/:fileId` (302 to bytes) already exists precisely
213+
for `<img src>` embedding.
214+
- **Reference integrity + GC**: on record write, the engine maintains
215+
reference rows (the `sys_attachment` pattern generalized: parent object +
216+
record id + field name + file id) so the ADR-0057 tombstone/reap machinery
217+
— which today cannot see field columns — counts field references the same
218+
way it counts attachment join rows. Clearing or overwriting a file field
219+
decrements visibly; orphaned blobs get the existing tombstone → TTL → reap →
220+
reclaim path instead of leaking forever. (Scanning JSON columns for ids was
221+
rejected: unindexable and driver-specific.)
222+
- **Authorization**: field-referenced files get parent-derived read checks,
223+
reusing the attachments `authorizeFileRead` verdict model — possession of a
224+
URL stops being possession of the bytes. The anonymous capability-URL
225+
carve-out remains only where the field/file explicitly declares a public
226+
posture (`acl: 'public_read'` — avatars, logos), an *opt-in* instead of the
227+
default for every non-attachment file.
228+
- **Upload contract fixes**: `/upload/complete` returns the `fileId` (today it
229+
is dropped, so the simple `client.storage.upload()` helper cannot even
230+
implement this design); `FieldSchema` gains `accept` / `maxSize` for file
231+
types (currently declarable only on action params — the field side has
232+
nothing), enforced at upload admission and re-checked at record write from
233+
`sys_file` metadata.
234+
235+
**Migration** (this is the breaking piece; per ADR-0087 it rides a protocol
236+
major):
237+
238+
- *Dual-read window*: readers normalize a legacy inline blob to the expanded
239+
form on the fly (`{url,...}``FileValueSchema` with `id: null`), so
240+
deployed records keep rendering.
241+
- *Write-path cutover*: new writes accept only references. A write presenting
242+
an inline blob fails validation with a tombstone-style message carrying the
243+
FROM → TO prescription (upload → submit the id).
244+
- *Backfill*: `os migrate` ingests platform-hosted legacy blobs into
245+
`sys_file` rows and rewrites the column to the id. Externally-hosted URLs
246+
(CDN links the platform never stored) cannot be ingested; they remain
247+
legacy-read-only values surfaced by a migration report, not silently
248+
dropped (#3407 discipline).
249+
250+
### D4 — Rollout order
251+
252+
Each phase is independently shippable and independently valuable:
253+
254+
1. **Contract + convergence** (non-breaking): land `field-value.zod.ts`,
255+
converge the four consumers' type lists, extend the verify probe, wire the
256+
field-zoo oracle assertion, delete/fix the three dead value schemas
257+
(breaking only for the dead exports — changeset carries the tombstones).
258+
2. **Typed action handlers** (breaking only for already-broken inputs):
259+
dispatch-time param validation + typed `defineAction`/`registerAction`.
260+
Same posture as #3406: params that fail were silently wrong before; now
261+
they are loudly wrong. Release note calls it out.
262+
3. **File-as-reference** (protocol major, cross-repo): storage-route +
263+
`FieldSchema` + write-path + GC + authz changes here; widget changes
264+
(submit `fileId`, render expanded form) in `objectui`; sequenced like the
265+
ADR-0103 v16 enum split — server first (old clients' inline writes are
266+
rejected with the prescriptive error), console re-pinned before GA.
267+
268+
## Alternatives considered
269+
270+
- **Keep per-consumer knowledge, add lint/tests to sync the lists.** Rejected:
271+
a sync-checker for four hand-copies is a workaround (Prime Directive #5);
272+
the lists exist because the contract has no home — give it one.
273+
- **Own the value shapes in `objectql` (runtime) instead of `spec`.** Rejected:
274+
the shapes are consumed by non-runtime parties — import tooling, drivers,
275+
the external UI repo, MCP/AI tool schemas, docs generation. The spec is the
276+
one package all of them already import, and value shapes are contract, not
277+
logic.
278+
- **A `{ id, name }` shallow form for lookups instead of the stored/expanded
279+
pair.** Rejected for now: it introduces a third wire form and breaks the
280+
"reality wins" rule (nothing stores or emits it today). The stored/expanded
281+
distinction merely *names* the two forms that already exist. Revisitable as
282+
an additive expansion profile later.
283+
- **File values as inline objects with a validated schema** (keep
284+
`{url, name, size}` but make the spec bless it). Rejected: it legitimizes
285+
the world with no reference integrity, no GC, and capability-URL security —
286+
hardening a blob is strictly worse than referencing the file object the
287+
platform already ships. The attachments world proves the reference model
288+
works end to end.
289+
- **Enforce action params only in the client dialog** (server stays lenient).
290+
Rejected: the dialog is one of three callers (REST, MCP/AI tools, scripts);
291+
AI-driven invocation is precisely the caller most likely to send a
292+
plausible-but-wrong bag, and ADR-0049 discipline says the check belongs at
293+
the enforcement point, not the courtesy surface.
294+
295+
## Consequences
296+
297+
- The spec becomes the single answer to "what does this field's value look
298+
like" — for the validator, drivers, import, verify, the UI repo, AI tool
299+
schemas, and docs generation. Adding a field type means writing its value
300+
schema once, and the type is born validated, importable, driver-classified,
301+
and conformance-tested.
302+
- Action handlers move from `params: any` + defensive casts to a validated,
303+
typed input with a 400 envelope for bad requests. Existing metadata whose
304+
params were silently mis-shaped starts failing loudly — intended (ADR-0078).
305+
- File fields gain integrity, GC, and authorization by joining the existing
306+
`sys_file` machinery; the platform stops shipping two file worlds. The cost
307+
is a protocol-major migration with a dual-read window and an explicit
308+
backfill report for non-ingestable external URLs.
309+
- Cross-repo obligations: `objectui` must adopt the expanded `FileValueSchema`
310+
rendering and fileId submission (phase 3), and can delete its own value-shape
311+
guesswork by importing the contract.
312+
- The three dead value-schema exports stop lying to readers — deleted or made
313+
true. Per the ADR-0078 discipline, "exported by the spec" once again implies
314+
"enforced somewhere".

0 commit comments

Comments
 (0)