|
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 | 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 | + landed (#3432); D3 refined into two waves by the 2026-07-24 addendum below, |
| 5 | + and wave 2's ownership model settled by the 2026-07-27 addendum. |
| 6 | + Strict-default flip of D1/D2 tracked in #3438; wave 2 sequence in #3459. |
6 | 7 | - **Date**: 2026-07-22 |
7 | 8 | - **Issue**: design follow-up generalizing #3405 / #3406 (inline lookup param |
8 | 9 | silently stripped); relates #3407 (silently dropped writes), #1878 / #1891 |
@@ -481,12 +482,119 @@ rode v16/v17) to amortise the migration cost: |
481 | 482 | under AI authoring this is *desirable*: it disambiguates "managed file" from |
482 | 483 | "external link" so the AI cannot conflate them. |
483 | 484 |
|
| 485 | +## Addendum (2026-07-27) — wave 2's ownership model: field references are exclusive |
| 486 | + |
| 487 | +The 2026-07-24 addendum left one thing open, and it turned out to be the |
| 488 | +decision the rest of wave 2 hangs off: **when a field holds a `sys_file` id, |
| 489 | +what is the relationship between file and record?** Implementing the reference |
| 490 | +rows forced the answer. |
| 491 | + |
| 492 | +### Two lineages, and why we take one of each |
| 493 | + |
| 494 | +Enterprise platforms solve file lifecycle two ways: |
| 495 | + |
| 496 | +- **Junction-for-everything** (Salesforce `ContentDocumentLink`, SAP GOS): every |
| 497 | + reference is a row in one polymorphic link table; a file is shared, and dies |
| 498 | + when the last link does. Sharing is the feature. |
| 499 | +- **Column-as-reference** (Dataverse File/Image columns): the column *is* the |
| 500 | + reference; lifecycle follows the row; no sharing. |
| 501 | + |
| 502 | +ObjectStack already runs the first for its attachments surface (`sys_attachment` |
| 503 | ++ the ADR-0057 tombstone/reap), and that is right — attaching one document to |
| 504 | +several records is exactly what that surface is for, and the user performs the |
| 505 | +attach explicitly. |
| 506 | + |
| 507 | +**Field references take the second model.** A `product.image` is a *property of |
| 508 | +that product*, and properties are copied, not shared. Concretely: at most one |
| 509 | +`(object, record, field)` slot owns a given `sys_file`; the owner is recorded on |
| 510 | +`sys_file.ref_object` / `ref_id` / `ref_field`; writing an already-owned id into |
| 511 | +a second slot **copies the bytes into a fresh `sys_file`** rather than sharing |
| 512 | +the row. |
| 513 | + |
| 514 | +### Why exclusivity, decided on failure modes rather than elegance |
| 515 | + |
| 516 | +Sharing is more storage-efficient and matches the better-known lineage. We take |
| 517 | +exclusivity anyway, because under AI-authored metadata the two models fail |
| 518 | +differently and the difference is not symmetric. |
| 519 | + |
| 520 | +The naive generated clone — `insert({...src, id: undefined})` — spreads a file |
| 521 | +id into a second record. Every model must let that code *work*; rejecting it |
| 522 | +would force AI authors to learn a bespoke file API, which is precisely the kind |
| 523 | +of special case that produces mistakes. So the real question is what happens |
| 524 | +*after* it works: |
| 525 | + |
| 526 | +| | worst case | recoverable? | |
| 527 | +|---|---|---| |
| 528 | +| 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 | |
| 529 | +| Exclusive + copy-on-claim | duplicated bytes | yes — it costs money, not data | |
| 530 | + |
| 531 | +Read authorisation for attachment files already derives from the parent record. |
| 532 | +Under sharing, "the parent record" is ambiguous, and the safe reading of an |
| 533 | +ambiguous ACL is the union — which means generated code can leak data while |
| 534 | +containing no visible error. Exclusivity removes the ambiguity by construction: |
| 535 | +one file, one parent, one ACL. |
| 536 | + |
| 537 | +The storage cost this concedes belongs at a different layer anyway: **dedup the |
| 538 | +bytes, not the rows.** Content-hash dedup behind the storage adapter (`etag` is |
| 539 | +already carried on `sys_file`) recovers the savings while leaving every row its |
| 540 | +own owner and its own ACL — the lifecycle never has to reason about sharing. |
| 541 | +A genuinely shared library asset should be modelled as its own object with a |
| 542 | +lookup, or attached through the attachments surface; steering AI authors there |
| 543 | +is the correct outcome, not a limitation. |
| 544 | + |
| 545 | +### The safety principle this buys: observed transition, never inferred absence |
| 546 | + |
| 547 | +> A file becomes collectable only because the platform **observed its one owner |
| 548 | +> let go** — never because a scan **inferred** that nobody references it. |
| 549 | +
|
| 550 | +The existing attachment path already obeys this (the tombstone is set by the |
| 551 | +hook that watched the join row die, not by a sweep that found none). Exclusive |
| 552 | +ownership extends the same discipline to fields, and eliminates counting |
| 553 | +entirely: with no count, no miscount can authorise a delete. It also defuses the |
| 554 | +cold-start hazard a new ledger would otherwise carry — a freshly-built reference |
| 555 | +table knows nothing of references that predate it, so trusting its zeroes would |
| 556 | +mass-delete history. |
| 557 | + |
| 558 | +### Consequent resequencing of wave 2 |
| 559 | + |
| 560 | +The 2026-07-24 plan packaged the write cutover and the GC enable together, and |
| 561 | +placed the backfill after. That order is wrong: **a ledger may not authorise |
| 562 | +deletion until it has been backfilled and reconciled.** Wave 2 therefore |
| 563 | +sequences as: |
| 564 | + |
| 565 | +1. **Ownership bookkeeping** — `ref_*` columns, claim/release on the write path, |
| 566 | + copy-on-claim. Records ownership; deletes nothing. Dormant until a field |
| 567 | + actually holds an id, so it lands safely ahead of the cutover. |
| 568 | +2. **Governed download** — read authorisation derived from the one owning |
| 569 | + record; anonymous capability URL demoted to opt-in `acl: 'public_read'`. |
| 570 | +3. **Write cutover** (protocol major) — stored form narrows to an id; |
| 571 | + `accept` / `maxSize` enforced. Still deletes nothing. |
| 572 | +4. **Backfill** — `os migrate` converts legacy inline blobs to `sys_file` rows |
| 573 | + and claims them; external URLs reported, not converted. |
| 574 | +5. **Reconciliation soak** — `os storage verify-references` compares what |
| 575 | + records actually hold against recorded ownership, reporting *over-claim* |
| 576 | + (safe: file retained longer than needed), *under-claim* (**blocking**: a held |
| 577 | + file with no owner would be treated as free), and unclaimed orphans. |
| 578 | +6. **GC enable** — *gated, irreversible*. Relaxing the `scope === 'attachments'` |
| 579 | + tombstone guardrail and extending the `sys_file` reap guard's sweep-time |
| 580 | + re-verify to the ownership columns **must ship in the same change**. Half of |
| 581 | + it is worse than none: tombstoning released files while the guard still |
| 582 | + re-verifies only `sys_attachment` — always empty for a field file — turns |
| 583 | + every release into a guaranteed byte delete rather than a risky one. |
| 584 | + |
| 585 | +R4's acceptance gate is now executable rather than aspirational: step 6 may not |
| 586 | +merge until step 5 reports **zero under-claims for ≥7 consecutive days** on real |
| 587 | +tenant data. R5 (public-posture inventory) and R6 (sub-key read scan) stand. |
| 588 | + |
484 | 589 | ### Why this stays inside ADR-0104 rather than a new ADR |
485 | 590 |
|
486 | 591 | D3 is already this ADR's third phase; the two-wave split and the |
487 | 592 | enforcement-point principle are a **refinement of the D4 rollout**, not a new |
488 | 593 | decision, so they live here. Wave 2's migration mechanics (dual-read window, |
489 | 594 | `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. |
| 595 | +no separate record. Wave 2's implementation did surface one genuinely new |
| 596 | +decision — the exclusive-ownership model — and it is recorded as the 2026-07-27 |
| 597 | +addendum above rather than a separate ADR, because it settles *how* D3's |
| 598 | +already-accepted "field values point into `sys_file`" behaves rather than |
| 599 | +revisiting whether to do it. A future choice that stands on its own (say, a |
| 600 | +chunked-migration protocol, or byte-layer content dedup) would earn its own ADR. |
0 commit comments