|
3 | 3 | - **Status**: Accepted (2026-07-22) — staged per D4. D1 landed (#3429), D2 |
4 | 4 | landed (#3432); D3 refined into two waves by the 2026-07-24 addendum below, |
5 | 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 | + Strict-default flip of D1/D2 tracked in #3438 — the media half flips per |
| 7 | + deployment (#3681); evidence for the remaining halves decided by the |
| 8 | + 2026-07-30 addendum. Wave 2 sequence in #3459. |
7 | 9 | - **Date**: 2026-07-22 |
8 | 10 | - **Issue**: design follow-up generalizing #3405 / #3406 (inline lookup param |
9 | 11 | silently stripped); relates #3407 (silently dropped writes), #1878 / #1891 |
@@ -716,3 +718,155 @@ separate ADRs, because each settles *how* D3's already-accepted "field values |
716 | 718 | point into `sys_file`" reaches production rather than revisiting whether to do |
717 | 719 | it. A future choice that stands on its own (say, a chunked-migration protocol, |
718 | 720 | or byte-layer content dedup) would earn its own ADR. |
| 721 | + |
| 722 | +## Addendum (2026-07-30) — evidence for the remaining flips: a scan gate for references / structured JSON; the action-param flip rides a declared major |
| 723 | + |
| 724 | +The 2026-07-27 amendment split #3438 into three and settled only the first: |
| 725 | +media value shapes flip per deployment, on the `adr-0104-file-references` flag |
| 726 | +(#3681). The other two were left "blocked on someone deciding what would |
| 727 | +constitute evidence." This addendum decides — differently for the two, because |
| 728 | +their facts have different epistemic reach — and fixes the release vehicle for |
| 729 | +each around the v17 major. |
| 730 | + |
| 731 | +One timeline fact frames everything: **warn-first itself first reaches stable |
| 732 | +deployments in 17.0.** D1 and D2 ride the v17 train (their changesets sit in |
| 733 | +the same pre-mode window as this addendum), so no stable deployment has served |
| 734 | +a single day of the warn window yet. Whatever the right gate is, 17.0 cannot |
| 735 | +flip any default version-wide without deleting the warn window R2/R3 promised |
| 736 | +— independent of the per-deployment argument. |
| 737 | + |
| 738 | +### D1 references + structured JSON: the evidence can exist, so build it |
| 739 | + |
| 740 | +The fact strict enforcement needs is: *no stored value of the covered classes |
| 741 | +fails `valueSchemaFor(field, 'stored')`.* The covered classes are exactly the |
| 742 | +validator's own non-media branch — `REFERENCE_VALUE_TYPES` (single-value |
| 743 | +`lookup` / `master_detail` / `user` / `tree`) and `STRUCTURED_JSON_TYPES` |
| 744 | +(`location` / `address` / `composite` / `repeater` / `record` / `vector`; |
| 745 | +`json`'s derived schema is deliberately `z.unknown()`, so it can yield no |
| 746 | +findings). Unlike caller behaviour, data at rest is enumerable: a scan that |
| 747 | +walks every row is **complete** evidence, with the same epistemic standing the |
| 748 | +file reconciliation has. So the gate takes the #3617 shape, minus the |
| 749 | +backfill: |
| 750 | + |
| 751 | +``` |
| 752 | +os migrate value-shapes |
| 753 | + 1. scan every object's covered fields against valueSchemaFor(field, 'stored') |
| 754 | + 2. report violations per (object, field, type): count, sample record ids, |
| 755 | + first parse issue — the per-deployment form of R1's stored-data audit |
| 756 | + 3. zero blocking findings + --apply → record sys_migration |
| 757 | + { id: 'adr-0104-value-shapes', verified_at, blocking: 0 } |
| 758 | + 4. strict enforcement of the covered classes reads THAT row, never the |
| 759 | + version number |
| 760 | +``` |
| 761 | + |
| 762 | +- **There is nothing to convert.** A malformed `location` payload, or an |
| 763 | + expanded object stored where an id belongs, is an application-data fix only |
| 764 | + its owner can make: the command reports and prescribes; the operator fixes |
| 765 | + and re-runs until green. (A mechanical normaliser — say, collapsing a stored |
| 766 | + expanded-lookup object to its `id` — is deliberately *not* built until real |
| 767 | + reports show the shape common enough to justify one; a normaliser that |
| 768 | + guesses is how silently-wrong values got here.) With no conversions, |
| 769 | + `--apply`'s only write is the flag row — which keeps the #3617 invariant |
| 770 | + intact: a dry run changes nothing, and whether a run changed the |
| 771 | + deployment's posture never depends on what the run found. |
| 772 | +- **The scanner and the validator share one predicate.** Same class sets, |
| 773 | + same cached `valueSchemaFor`, same skip rules (`system` / `readonly` / |
| 774 | + lifecycle columns are never validated, so they are never scanned): the scan |
| 775 | + must count exactly what strict mode would reject on that record's next |
| 776 | + rewrite, and nothing else. Imported, not re-derived — hand-copied value |
| 777 | + knowledge is the disease this ADR exists to cure. |
| 778 | +- **Gate consumption mirrors the media half verbatim**: the engine reads the |
| 779 | + flag once per process (memoized), an object declaring no covered field never |
| 780 | + consults it, `ValidateRecordOptions` gains the parallel option beside |
| 781 | + `mediaValueShapeStrict`, and a later failing run clears `verified_at` — a |
| 782 | + regression closes its own gate. |
| 783 | +- **Precedence, and the one new env var**: `OS_ALLOW_LAX_VALUE_SHAPES` (an |
| 784 | + `OS_ALLOW_*` escape hatch per Prime Directive #9, scoped to these classes — |
| 785 | + media keeps its own `OS_ALLOW_LAX_MEDIA_VALUES`) beats |
| 786 | + `OS_DATA_VALUE_SHAPE_STRICT_ENABLED` (unchanged: still the all-class |
| 787 | + opt-in) beats the flag. Same contradictory-config rule as |
| 788 | + `mediaStrictEffective`, for the same reason: wrongly lax costs a warning, |
| 789 | + wrongly strict stops a working app. |
| 790 | + |
| 791 | +### Fresh datastores attest at creation — both flags |
| 792 | + |
| 793 | +Today nothing records a flag except a migration run, which leaves a gap at |
| 794 | +the other end of a deployment's life: a deployment born on ≥17 starts **lax** |
| 795 | +and stays lax until its operator thinks to run a migration that is, for them, |
| 796 | +a no-op. The warn regime would never die out — every new deployment re-enters |
| 797 | +it. |
| 798 | + |
| 799 | +So the bootstrap that creates an empty datastore records both rows |
| 800 | +(`adr-0104-file-references`, `adr-0104-value-shapes`) at creation time. This |
| 801 | +is not version-gating in disguise: the fact recorded — *no legacy value |
| 802 | +exists here* — is **observed** (the store is empty by construction at the |
| 803 | +moment of creation), the same observed-transition discipline the GC gate runs |
| 804 | +on. An existing database upgrading to 17 is non-empty at boot, gets no |
| 805 | +attestation, and keeps producing its evidence by scan. A later legacy import |
| 806 | +into a born-strict deployment is rejected loudly at the write path — the |
| 807 | +correct outcome; an operator who must temporarily admit such data has the |
| 808 | +escape hatch, and re-verifies by re-running the scan. The exact seam is the |
| 809 | +implementing PR's decision (candidates: the system seed that first creates |
| 810 | +`sys_migration`, or the engine's schema bootstrap), with one hard |
| 811 | +requirement: it fires only for a store it is itself creating from empty, |
| 812 | +never for one it found. |
| 813 | + |
| 814 | +Born-strict deployments also make the platform's own dogfood the standing |
| 815 | +canary for R2 (a codified shape stricter than some legitimate client's |
| 816 | +writes): showcase/CRM boots are fresh datastores, so they enforce from birth, |
| 817 | +and a false rejection fails our suites before any customer sees it. |
| 818 | + |
| 819 | +### D2 action params: the evidence cannot exist, so the honest gate is a declared major |
| 820 | + |
| 821 | +What per-deployment evidence would have to prove is: *no caller of this |
| 822 | +deployment still depends on the lax contract.* Callers are not enumerable — |
| 823 | +an integration that fires monthly, a script in a cron nobody remembers, an AI |
| 824 | +agent constructing each call fresh. Data at rest can be scanned to |
| 825 | +completion; future calls cannot. A "quiet for N days" flag would assert a |
| 826 | +completeness it cannot have — the unlocatable-gate error of the 2026-07-27 |
| 827 | +addendum, reproduced one layer down. A gate must not claim evidence that |
| 828 | +cannot exist, and we decline to build the theatre of one. |
| 829 | + |
| 830 | +What remains is the failure-mode calculus that already sequenced wave 2: a |
| 831 | +strict rejection here is **loud** (a 400 naming the offending param and the |
| 832 | +declared list), **caller-facing** (a developer or an AI mid-integration — not |
| 833 | +an end user stranded in a form above data they cannot fix), **recoverable** |
| 834 | +(fix the call), and **reversible** (the escape hatch below). No data is |
| 835 | +stranded, no byte is deleted. That is precisely the class of break wave 2's |
| 836 | +step 4 was allowed to ride a declared major for, while step 6 waits on |
| 837 | +evidence. |
| 838 | + |
| 839 | +So, owned explicitly rather than smuggled: **the D2 flip is a version flip — |
| 840 | +on the major *after* the warn window, not this one.** 17.0 ships warn-first |
| 841 | +(first exposure) and announces the coming default in its release notes; 18.0 |
| 842 | +flips `actionParamsStrict()` to default-true. The v17→v18 gap is every |
| 843 | +deployment's own warn window, served on its own upgrade schedule; the warn |
| 844 | +line already names `OS_ACTION_PARAMS_STRICT_ENABLED=1`, which is the |
| 845 | +per-deployment act of opting into tomorrow's default today. Version-wide |
| 846 | +flips remain wrong where per-deployment evidence is *possible* (D1); where it |
| 847 | +is impossible in principle, a declared major with a served warn window and an |
| 848 | +escape hatch is what honest looks like. The alternative — warn forever — |
| 849 | +permanently un-enforces the declared contract at exactly the surface (AI/MCP |
| 850 | +callers) D2 was built for, where a 400 is corrective feedback the caller |
| 851 | +consumes and a server-side warn is feedback nobody sees. |
| 852 | + |
| 853 | +Mechanics at 18.0: `OS_ACTION_PARAMS_STRICT_ENABLED` stays accepted — it |
| 854 | +becomes a redundant opt-in to the default; its semantics never change, so no |
| 855 | +`readEnvWithDeprecation` rename, the same conclusion the media half reached. |
| 856 | +A new `OS_ALLOW_LAX_ACTION_PARAMS` opt-out wins over everything (the |
| 857 | +contradictory-config rule again), and the dogfood contract suite |
| 858 | +(`action-params-contract.dogfood.test.ts`) flips its duals so the *default* |
| 859 | +path is the strict one and the escape hatch is the explicitly-set case. |
| 860 | + |
| 861 | +### What rides where |
| 862 | + |
| 863 | +| vehicle | change | |
| 864 | +|---|---| |
| 865 | +| 17.0 | warn-first first ships (D1 non-media + D2). `os migrate value-shapes` + the `adr-0104-value-shapes` gate wiring. Fresh-datastore attestation of both flags. Release notes announce the 18.0 D2 default. **No version-wide default changes.** | |
| 866 | +| each deployment, ≥17.0 | runs `os migrate value-shapes --apply` (or is born attested) → its own D1 non-media classes flip to strict. | |
| 867 | +| 18.0 | D2 strict by default + `OS_ALLOW_LAX_ACTION_PARAMS`; dogfood duals flip. | |
| 868 | + |
| 869 | +#3438 then tracks two work items instead of an open question: the scan |
| 870 | +migration (command, engine wiring, creation-time attestation) targeting the |
| 871 | +17.0 train, and the D2 flip parked for the 18.0 train with its release-note |
| 872 | +announcement landing now. |
0 commit comments