|
| 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