Skip to content

Commit d285eda

Browse files
committed
docs(adr): ADR-0104 addendum revision — D2 flips strict-by-default in 17.0, not 18.0 (#3438)
Maintainer review of the 2026-07-30 addendum surfaced the amortisation argument the first draft missed: every 16.x deployment performs one planned, tested 16→17 upgrade anyway, and spreading this ADR's breaks across two majors charges that ceremony twice. The revision asks, per half, what withholding the version-wide flip actually protects: - D2 action params: nothing — no records at stake, rejections are loud 400s to the party who can fix them, R3's dangerous half is already closed by ACTION_PARAM_BUILTIN_KEYS, and v17 itself ships harsher zero-window flips (allowExport, undeclared-handler refusal). So strict becomes the 17.0 default; OS_ACTION_PARAMS_STRICT_ENABLED (RC-only, never on latest) is deleted before GA rather than shipped as a redundant knob; OS_ALLOW_LAX_ACTION_PARAMS is the single escape hatch. - D1 references + structured JSON: records — the read-modify-write hazard over data written years ago, one slice of it authored faithfully against the spec's own wrong LocationCoordinatesSchema. The per-deployment scan gate stays, and gains upgrade-path advertisement (os migrate meta epilogue + boot-log line) so clean deployments get 'one upgrade, done' by default. The earlier draft's 'no warn window served yet' framing is corrected in place: it bounds which gates are available, it does not argue for postponement. Design doc only — releases nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zgA8CQMJeJbFEjnbib1Uv
1 parent 8c2db68 commit d285eda

2 files changed

Lines changed: 98 additions & 49 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
---
22
---
33

4-
docs(adr): ADR-0104 addendum — evidence for the remaining #3438 strict flips (D1 references/structured JSON gate on a per-deployment `os migrate value-shapes` scan; fresh datastores attest both flags at creation; the D2 action-param flip rides the 18.0 major with an escape hatch) — releases nothing.
4+
docs(adr): ADR-0104 addendum — evidence for the remaining #3438 strict flips (D1 references/structured JSON gate on a per-deployment `os migrate value-shapes` scan, advertised on the 16→17 upgrade path; fresh datastores attest both flags at creation; D2 action params flip strict-by-default in 17.0, `OS_ACTION_PARAMS_STRICT_ENABLED` deleted before it reaches `latest`, `OS_ALLOW_LAX_ACTION_PARAMS` as the escape hatch) — releases nothing.

docs/adr/0104-field-runtime-value-shape-contract.md

Lines changed: 97 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -719,21 +719,32 @@ point into `sys_file`" reaches production rather than revisiting whether to do
719719
it. A future choice that stands on its own (say, a chunked-migration protocol,
720720
or byte-layer content dedup) would earn its own ADR.
721721

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
722+
## Addendum (2026-07-30) — evidence for the remaining flips: a scan gate for references / structured JSON; the action-param default flips in 17.0
723723

724724
The 2026-07-27 amendment split #3438 into three and settled only the first:
725725
media value shapes flip per deployment, on the `adr-0104-file-references` flag
726726
(#3681). The other two were left "blocked on someone deciding what would
727727
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.
728+
their facts have different epistemic reach — and lands both on the v17 major.
730729

731-
One timeline fact frames everything: **warn-first itself first reaches stable
730+
One timeline fact frames the choice: **warn-first itself first reaches stable
732731
deployments in 17.0.** D1 and D2 ride the v17 train (their changesets sit in
733732
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.
733+
a single day of the warn window yet. That does *not* settle the question, and
734+
an earlier draft of this addendum wrongly treated it as if it did. It bounds
735+
one option only: no deployment can have *already* produced warn-window
736+
evidence, so a gate that requires such evidence must either produce it fresh
737+
(D1, below) or be declined on the merits (D2, below). It is not an argument
738+
for postponement — a break served later is not a break served gently.
739+
740+
The governing consideration is the one D4 already stated and the 2026-07-24
741+
addendum repeated: **ride a planned breaking window to amortise the migration
742+
cost.** Every deployment on 16.x must perform a substantial, tested 16→17
743+
upgrade regardless. Spreading this ADR's own breaking changes across two
744+
majors charges that ceremony twice, and the second charge buys something only
745+
where it buys real safety. So the question is asked per half — *what does
746+
withholding the version-wide flip actually protect here?* — and the answers
747+
differ.
737748

738749
### D1 references + structured JSON: the evidence can exist, so build it
739750

@@ -745,8 +756,30 @@ validator's own non-media branch — `REFERENCE_VALUE_TYPES` (single-value
745756
`json`'s derived schema is deliberately `z.unknown()`, so it can yield no
746757
findings). Unlike caller behaviour, data at rest is enumerable: a scan that
747758
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:
759+
file reconciliation has.
760+
761+
What the per-deployment gate protects here — the amortisation question from
762+
the preamble — is **records, not callers**. The failure mode of a wrong flip
763+
is read-modify-write: an application reads a record, edits one field, writes
764+
the whole thing back, and the malformed `location` payload it never touched
765+
rides along — so the application's own *correct* code starts failing, above
766+
data possibly written years ago, at a scale ("which of my million rows?") the
767+
operator cannot even enumerate without the scanner. And one slice of that
768+
data is **our fault**: the spec exported `LocationCoordinatesSchema`
769+
declaring `{latitude, longitude}` while reality stored `{lat, lng}` (this
770+
ADR's own Context section) — an app written faithfully against our published
771+
contract holds exactly the values a version-wide flip would reject.
772+
Punishing spec-faithful authors for having believed the spec is not a
773+
migration; it is a breach. That is what the gate buys, and it is worth
774+
buying.
775+
776+
The cost, meanwhile, rounds to zero for the deployments that are clean —
777+
which is most of them: the scan runs inside the same 16→17 upgrade ceremony
778+
as `os migrate files-to-references`, and a clean deployment that runs it is
779+
strict *immediately*, one upgrade, done — indistinguishable from a
780+
version-wide flip. The gate's only observable difference is on dirty data,
781+
where it converts a stream of production failures into a report with row
782+
ids. So the gate takes the #3617 shape, minus the backfill:
750783

751784
```
752785
os migrate value-shapes
@@ -787,6 +820,14 @@ os migrate value-shapes
787820
opt-in) beats the flag. Same contradictory-config rule as
788821
`mediaStrictEffective`, for the same reason: wrongly lax costs a warning,
789822
wrongly strict stops a working app.
823+
- **The upgrade path advertises the scan, so "one upgrade, done" is the
824+
default experience rather than a discovery.** `os migrate meta --from 16`
825+
the command every 16→17 upgrade already runs — ends by naming the two data
826+
migrations (`files-to-references`, `value-shapes`) with their gate status,
827+
and a deployment booting ≥17 with covered fields and no
828+
`adr-0104-value-shapes` row logs one line at startup saying warn-mode is in
829+
effect and which command ends it. A gate nobody is told about is served by
830+
nobody.
790831

791832
### Fresh datastores attest at creation — both flags
792833

@@ -816,7 +857,7 @@ canary for R2 (a codified shape stricter than some legitimate client's
816857
writes): showcase/CRM boots are fresh datastores, so they enforce from birth,
817858
and a false rejection fails our suites before any customer sees it.
818859

819-
### D2 action params: the evidence cannot exist, so the honest gate is a declared major
860+
### D2 action params: the evidence cannot exist, so strict is the 17.0 default
820861

821862
What per-deployment evidence would have to prove is: *no caller of this
822863
deployment still depends on the lax contract.* Callers are not enumerable —
@@ -827,46 +868,54 @@ completeness it cannot have — the unlocatable-gate error of the 2026-07-27
827868
addendum, reproduced one layer down. A gate must not claim evidence that
828869
cannot exist, and we decline to build the theatre of one.
829870

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.
871+
With a per-deployment gate ruled out, the choice is the release vehicle, and
872+
the amortisation question answers it. What would parking the flip on a later
873+
major protect? Not records — no data is at stake, and the D1 read-modify-write
874+
hazard has no analogue here. Not end users — a strict rejection is **loud**
875+
(a 400 naming the offending param and the declared list), **caller-facing**
876+
(a developer or an AI mid-integration, who reads errors for a living), and
877+
**reversible** (the escape hatch below). The R3 hazard that once justified a
878+
long lax window is, in its dangerous half, already closed: dispatch's own
879+
injected keys (`recordId` / `objectName`) ride the `ACTION_PARAM_BUILTIN_KEYS`
880+
allowlist, so what strict rejects now is a bag that is genuinely wrong
881+
against its declaration. And v17 itself sets the severity bar: it flips
882+
unset-`allowExport` from allowed to **denied** and refuses undeclared action
883+
handlers with **no opt-out at all**, both with zero warn window. Holding D2 —
884+
gentler than either — to a stricter standard than the release it ships in is
885+
not caution, it is incoherence. So: **`actionParamsStrict()` defaults to true
886+
in 17.0.** A 16.x deployment meets the contract and its enforcement in one
887+
planned, tested upgrade; the alternative served nobody a gentler break — it
888+
scheduled a second one.
889+
890+
One windfall confirms the timing. The `OS_ACTION_PARAMS_STRICT_ENABLED`
891+
opt-in has only ever existed inside the 17.0 RC window — no stable release
892+
carries it — so flipping in 17.0 means the variable is **deleted before it
893+
ever reaches `latest`**: no redundant knob at GA, no deprecation debt, no
894+
`readEnvWithDeprecation` shim. Parking the flip on 18.0 would have shipped
895+
the knob stable and then carried it. Mechanics: the opt-in is removed (RC
896+
consumers get one release-note line); the new `OS_ALLOW_LAX_ACTION_PARAMS`
897+
escape hatch (Prime Directive #9 `OS_ALLOW_*` shape) is the single switch and
898+
restores warn-first verbatim for an operator mid-diagnosis; the dogfood
899+
contract suite (`action-params-contract.dogfood.test.ts`) flips its duals so
900+
the *default* path is the strict one and the escape hatch is the
901+
explicitly-set case; the v17 release notes carry the migration section (what
902+
a rejected bag looks like, how to fix the caller, the escape hatch's name).
903+
904+
Actions declaring no `params` keep their pass-through — that boundary is
905+
unchanged from D2's landing. And the asymmetry with D1 stays load-bearing in
906+
both directions: a version flip is wrong where complete per-deployment
907+
evidence is *possible* (D1's data), and right where it is impossible in
908+
principle and the failure mode is a self-describing 400 to the party who can
909+
fix it.
860910

861911
### What rides where
862912

863913
| vehicle | change |
864914
|---|---|
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.
915+
| 17.0 | D2 strict by default (`OS_ACTION_PARAMS_STRICT_ENABLED` deleted, `OS_ALLOW_LAX_ACTION_PARAMS` added, dogfood duals flipped). `os migrate value-shapes` + the `adr-0104-value-shapes` gate wiring. Fresh-datastore attestation of both flags. `os migrate meta` / boot-log advertisement of the scan. D1 non-media stays warn-first until the deployment's own gate opens. |
916+
| each deployment, ≥17.0 | runs `os migrate value-shapes --apply` (or is born attested) → its own D1 non-media classes flip to strict. Clean data ⇒ one upgrade, done. |
917+
918+
#3438 then tracks two work items instead of an open question, both on the
919+
17.0 train: the D2 default flip (validator default + env-var swap + dogfood
920+
duals + release notes), and the scan migration (command, engine wiring,
921+
creation-time attestation, upgrade-path advertisement).

0 commit comments

Comments
 (0)