|
1 | 1 | # ADR-0104: Field runtime value-shape as a first-class contract — spec-owned value schemas, typed action handlers, file-as-reference |
2 | 2 |
|
3 | | -- **Status**: Accepted (2026-07-22) — implementation staged per D4; phase 1 (D1) tracked on its own PR |
| 3 | +- **Status**: Accepted (2026-07-22) — staged per D4. D1 landed (#3429), D2 |
| 4 | + landed (#3432); D3 refined into two waves by the 2026-07-24 addendum below. |
| 5 | + Strict-default flip of D1/D2 tracked in #3438. |
4 | 6 | - **Date**: 2026-07-22 |
5 | 7 | - **Issue**: design follow-up generalizing #3405 / #3406 (inline lookup param |
6 | 8 | silently stripped); relates #3407 (silently dropped writes), #1878 / #1891 |
@@ -385,3 +387,106 @@ acceptance criteria (wired to the #2408 Server-Timing surface where useful): |
385 | 387 | - The three dead value-schema exports stop lying to readers — deleted or made |
386 | 388 | true. Per the ADR-0078 discipline, "exported by the spec" once again implies |
387 | 389 | "enforced somewhere". |
| 390 | + |
| 391 | +## Addendum (2026-07-24) — D3 refined into two waves; the enforcement-point principle |
| 392 | + |
| 393 | +D1 (#3429) and D2 (#3432) landed as single-repo, non-breaking (or |
| 394 | +breaking-only-for-already-broken) PRs under this ADR's umbrella. D3 |
| 395 | +(file-as-reference) is different in kind — breaking, cross-repo, protocol-major, |
| 396 | +and the only phase carrying **irreversible** risk (R4, GC deleting file bytes). |
| 397 | +A review of D3 against the platform's actual trajectory — an enterprise, |
| 398 | +Salesforce-shaped metadata platform whose **authoring surface is increasingly |
| 399 | +driven by AI**, where the governing goal is *keep the AI author from silently |
| 400 | +producing wrong metadata* — sharpened two things: D3's **value went up** (it |
| 401 | +closes the last authoring surface where an AI can write a silently-wrong value |
| 402 | +and get a success envelope — the ADR-0078 asymmetry, at the file layer), while |
| 403 | +its **irreversible-migration risk is unchanged**. So D3 is not deferred, but |
| 404 | +**decomposed** and **re-sequenced by what serves authoring correctness first**. |
| 405 | + |
| 406 | +### The enforcement-point principle (applies to D1, D2, and D3) |
| 407 | + |
| 408 | +For an AI author, the check that prevents a mistake is the one that fires at |
| 409 | +**build/validate time** (`os validate` / `os build` rejects, the way #3406 |
| 410 | +rejects a targetless inline lookup param) — a build failure the AI must fix, not |
| 411 | +a runtime warning it never sees. The runtime **warn-first** posture (D1's |
| 412 | +`OS_DATA_VALUE_SHAPE_STRICT_ENABLED`, D2's `OS_ACTION_PARAMS_STRICT_ENABLED`) |
| 413 | +exists only to protect **already-deployed data** from stranding — it is not the |
| 414 | +authoring gate. |
| 415 | + |
| 416 | +Therefore the target end-state is **two enforcement points, each with one job**: |
| 417 | + |
| 418 | +- **Build/validate time → hard reject.** Net-new metadata (the AI's output) |
| 419 | + must fail loudly at authoring. This is the primary defence against AI error. |
| 420 | +- **Runtime → warn-first, then flip.** Deployed data keeps working through the |
| 421 | + warn window; the flip to strict-by-default (tracked in #3438) closes it once |
| 422 | + telemetry is quiet. |
| 423 | + |
| 424 | +This refines D2 (today runtime-only) and reshapes the #3438 flip: the priority |
| 425 | +is adding the **build-time** rejection for value shapes and action params, not |
| 426 | +just flipping the runtime default. |
| 427 | + |
| 428 | +### The class this covers — the whole media family, not just `file` |
| 429 | + |
| 430 | +`file` is shorthand throughout D3 for the entire `FILE_REFERENCE_TYPES` class |
| 431 | +D1 already defined — **`file`, `image`, `avatar`, `video`, `audio`**. All five |
| 432 | +store the same inline blob today, all five bypass `sys_file`, and all five move |
| 433 | +to the reference model together. There is no separate story for `image`; the |
| 434 | +contract is one class. |
| 435 | + |
| 436 | +### D3 wave 1 — the value-shape contract (low-risk, no migration) |
| 437 | + |
| 438 | +Directly serves "keep the AI author correct." Ships independently of the |
| 439 | +protocol major, single-repo, no irreversible risk: |
| 440 | + |
| 441 | +- Give the media class a **declared `FileValueSchema`** — the inline form the |
| 442 | + platform stores today (`{ url, name?, size?, mimeType?, alt?, duration? }`, |
| 443 | + `url` required) — replacing D1's loose transitional union. `valueSchemaFor` |
| 444 | + for the class returns the transitional union of *this declared object* and |
| 445 | + the opaque id/url string (still accepted for import-compat), so a malformed |
| 446 | + file value (a number, an empty object, garbage) is now caught instead of |
| 447 | + waved through. Exported so wave 2 and `objectui` consume the one shape. |
| 448 | +- Per the enforcement-point principle, this shape should reject at |
| 449 | + **build/validate time** for net-new (AI-authored) metadata while staying |
| 450 | + warn-first at runtime for deployed data — the same posture D1 established for |
| 451 | + every other value type. |
| 452 | + |
| 453 | +Wave 1 is effectively a "D2.5": no `sys_file` rewiring, no migration, no |
| 454 | +protocol bump. Note what wave 1 deliberately does **not** do: it does not add |
| 455 | +`accept` / `maxSize` to `FieldSchema`. Those govern an actual upload, so |
| 456 | +enforcing them authoritatively needs the server to know the real bytes — i.e. |
| 457 | +the `sys_file` model. Adding them before that would ship exactly the inert knob |
| 458 | +ADR-0078 forbids, so they belong to wave 2. |
| 459 | + |
| 460 | +### D3 wave 2 — the storage-model migration (protocol major, full safeguards) |
| 461 | + |
| 462 | +The reference model + governance, sequenced after wave 1 and carrying the |
| 463 | +irreversible risk. Ride a planned breaking window (as ADR-0103's enum split |
| 464 | +rode v16/v17) to amortise the migration cost: |
| 465 | + |
| 466 | +- File field value becomes an opaque `sys_file` id; the expanded read form is |
| 467 | + the spec-owned `FileValueSchema` (`url` derived via `/files/:fileId`, never |
| 468 | + stored) — D1/D2 §D3 as written. |
| 469 | +- Field references join the ADR-0057 tombstone/reap GC (leak + GDPR-erasure |
| 470 | + fix); governed download reuses the attachments `authorizeFileRead`, with the |
| 471 | + anonymous capability URL demoted to an **opt-in** `acl: 'public_read'`. |
| 472 | +- `accept` / `maxSize` land on `FieldSchema` here — not in wave 1 — because now |
| 473 | + the server owns the file (`sys_file` metadata), so it can enforce declared |
| 474 | + MIME/size at upload admission and re-check at record write authoritatively, |
| 475 | + rather than trusting a client-supplied blob. |
| 476 | +- Non-negotiable acceptance gates (from Risks): **R4** GC frozen during the |
| 477 | + migration window until reference-row counting is verified; **R5** a reviewed |
| 478 | + public-posture inventory; **R6** a static scan for sub-key reads |
| 479 | + (`record.file.url` in formulas/templates/hooks/flows). |
| 480 | +- External-URL usage of `type: 'file'` is retired toward a `url` field (R7) — |
| 481 | + under AI authoring this is *desirable*: it disambiguates "managed file" from |
| 482 | + "external link" so the AI cannot conflate them. |
| 483 | + |
| 484 | +### Why this stays inside ADR-0104 rather than a new ADR |
| 485 | + |
| 486 | +D3 is already this ADR's third phase; the two-wave split and the |
| 487 | +enforcement-point principle are a **refinement of the D4 rollout**, not a new |
| 488 | +decision, so they live here. Wave 2's migration mechanics (dual-read window, |
| 489 | +`os migrate` backfill, the R4/R5/R6 gates) are specified in §D3 above and need |
| 490 | +no separate record. Should wave 2's implementation surface a genuinely new |
| 491 | +decision (e.g. the reference-table shape, or a chunked-migration protocol), that |
| 492 | +specific choice — not file-as-reference as a whole — would earn its own ADR. |
0 commit comments