Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): the destructive gate's reachable type set is object alone (#11014) - #11170
Conversation
…`object` alone (#11014) `saveMetaItem`'s Phase 3a-destructive gate opened on `(singularType === 'object' || singularType === 'field')` and the `field` limb could not produce a finding, so the condition made the gate's coverage read wider than it is — #10886's face inventory had to chase a `field` face population that does not exist. Both reasons the limb was inert were re-measured through the real `saveMetaItem` before the deletion: 1. a `field` body has no `fields` map to diff (`FieldSchema` is a `strictObject` declaring no such key, so both sides of the detector fold to `{}`); measured as a `text` -> `number` change saving with no 409 while the same-shaped change on an `object` refused in the same harness; 2. `field` is code-only, so the #5086 refusal one gate up answers first — `NOT_CREATABLE` for a runtime-only parent, `NOT_OVERRIDABLE` for an artifact-backed one — before persistence and before the diff. Neither reason is absolute and the trim is right because of where they stop: reason 2 stops at the documented `OS_METADATA_WRITABLE` hatch (reason 1 then holds the gate inert on its own), and reason 1 stops at schema-valid bodies — a stored `field` row carrying a `fields` map did fire the gate. That double fault is the one behaviour delta, and the refusal it removes was a false alarm: a `field` write mints a standalone `sys_metadata` row nothing composes into its parent object (#7893), so no driver ever materialised the named columns. `object` behaviour is untouched. The reachable type set is now pinned by `protocol.destructive-gate-reachable-types.test.ts`, which pairs every "no 409" assertion with a live `object` control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 44030e7b5cc8ed2b576ac0588dfb1f4faa849fc1 && git checkout 44030e7b5cc8ed2b576ac0588dfb1f4faa849fc1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 10485009a693b07cf90c98e81a0d8b824c62be95 e2ca7578d0bddc5d998d9f52634d4359c3ad5425 && git checkout -B drift-repro 10485009a693b07cf90c98e81a0d8b824c62be95 && git merge --no-ff e2ca7578d0bddc5d998d9f52634d4359c3ad5425
node scripts/docs-audit/affected-docs.mjs --json 10485009a693b07cf90c98e81a0d8b824c62be95
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11014
saveMetaItem's Phase 3a-destructive gate opened on(singularType === 'object' || singularType === 'field'), and thefieldlimbcould not produce a finding. The condition therefore made the gate's coverage
read wider than it is — #10886's face inventory had to establish, per face,
which types reach this gate, and the
fieldspelling is the one thing that madethe answer look bigger. Triage
(first-touch)
ruled between the card's two open directions: trim the dead limb.
The condition is now
if (!request.force && singularType === 'object') {.How both reasons were re-measured
The limb is safe to delete only because it cannot fire, so both of the card's
reasons were re-measured through the real
saveMetaItemover asys_metadata-backed kernel before anything was deleted — not read off thecard. Every measurement below is now a case in the new pin.
fieldcreate, runtime-only parentNOT_CREATABLE/ 403, nothing persistedfieldupdate over an existing rowNOT_CREATABLE/ 403 — the gate's own precondition (prevnon-null) is met and it still never runsfieldunder an artifact-backed parentNOT_OVERRIDABLE/ 403 (#7743'sisNestedArtifactFieldpath)OS_METADATA_WRITABLE=field,fieldtext→numberobject, field droppedDESTRUCTIVE_CHANGE/ 409OS_METADATA_WRITABLE=field+ storedfieldbody carrying afieldsmapDESTRUCTIVE_CHANGE/ 409Reason 1 — a
fieldbody has nofieldsmap to diff. Confirmed atdetectDestructiveObjectChanges: it readsprev.fields/next.fields, andboth fold to
{}for afieldbody, so every loop iterates zero times. Themechanism is slightly different from the card's wording (there is no literal
early return — the empty-map defaults make the loops vacuous), same conclusion.
Probe C measures it end-to-end:
text→numberis exactly the edit that raisesfield_type_changeinside an object body, and it saved with no 409 — while D,the same-shaped change on an
object, refused in the same harness.Reason 2 —
fieldcannot reach the gate.packages/specmetadata-plugin.zod.ts:733still declares it code-only (allowRuntimeCreate: false,allowOrgOverride: false,supportsOverlay: false; read-only for thiscard, unmodified). The #5086 refusal at
protocol.ts:13188sits above the gateand answers first, in both of its shapes (A, F).
Neither reason is absolute. Reporting this rather than smoothing it over, since
the card asserted both without qualification:
OS_METADATA_WRITABLE=fieldmakes
isOverlayAllowed('field')true and does carry afieldwrite past thecode-only refusal and into this gate (probe C reached it and passed through).
The card's "no caller can reach the destructive gate with
singularType === 'field'" is over-stated by that one configuration. Reason 1 then holds thegate inert on its own — which is exactly what "two independent reasons" buys.
reads
prev.fieldswhatever the type is. A storedfieldrow carrying afieldsmap did fire the gate (probe E). So reason 1 is a fact aboutFieldSchema, not about the code path.Together: the limb was reachable only through a double fault — the hatch
open and a stored body
FieldSchemarejects (unrecognized_keys: ['fields'],asserted in the pin). And in that configuration the 409 was a false alarm: a
fieldwrite mints a standalonesys_metadatarow that nothing composes intoits parent object (#7893), so no driver ever materialised the columns the
finding named. The limb's entire behavioural footprint was one spurious
data-loss refusal over corrupt metadata. That strengthens the ruled direction
rather than overturning it, so the trim proceeded as ruled — flagged here for
the record, not treated as a licence to switch arms.
The one behaviour delta, recorded deliberately as a passing case in §3 of
the pin rather than left as a surprise: that double-fault save now succeeds
instead of answering
409 DESTRUCTIVE_CHANGE.objectbehaviour is untouched —same predicate, same findings, same envelope, same remedy clause.
What the pin asserts
packages/metadata-protocol/src/protocol.destructive-gate-reachable-types.test.ts(10 cases) holds the reachable type set at
objectalone:409for anobject. ⭐ Withoutthis green, every "no 409" assertion below is satisfied by a harness that never
reached the gate — the card's own failure mode in miniature.
fieldcreate →NOT_CREATABLE;fieldupdate over anexisting row →
NOT_CREATABLE(the case that matters most: the gate'sprecondition is met); artifact-backed parent →
NOT_OVERRIDABLE. Each assertscode+status(ADR-0112, never a baretoThrow()) and that nothing waspersisted — a gate that refuses after writing is a log line.
independently of reason 2, with its own liveness case (the write really did
persist, so it reached the gate) and its own in-environment
objectcontrol. Plus the shape claim asserted against the schema itself:
FieldSchemaaccepts a field body (fullsafeParse) and rejects afieldskey with
unrecognized_keys(the key claim).FieldSchemarefuses.Reverse verification — direction predicted before running
Predicted: restoring the limb reddens exactly one case, §3's delta; the other
nine stay green, because §0/§2-control are
object(untouched), §1 refuses onegate earlier, and §2's
fieldbodies have nofieldsmap for the limb to act on.Measured — exactly that:
The narrowness is the finding: one case is the limb's entire behavioural
footprint, which is what "inert" means, measured rather than argued. The subject
is imported as
./protocol.js(relative), so vitest resolvessrc/protocol.tswith no
dist/on the path and no rebuild is involved —check:test-source-aliasis green. The mutation was confirmed on disk by counting both the injected
and the replaced text (mutant 1 / trimmed 0), the restore likewise (mutant 0 /
trimmed 1), and the script carried
trap … EXIT INT TERM; the tree isbyte-identical to the commit afterwards (
git diff --quiet→ 0).Verification — all at final commit
e2ca7578dpnpm --filter @objectstack/metadata-protocol exec vitest run—Test Files 137 passed | 2 skipped (139)·Tests 1878 passed | 10 skipped (1888)(baseline 1868; +10 is this file).
Test Files 1 passed (1)·Tests 10 passed (10).dist/:packages/objectql'sprotocol-destructive.test.tsimports the package specifier, so@objectstack/metadata-protocolwas rebuilt first and the trim confirmedpresent in
dist/index.js(request.force && singularType === "object", zerooccurrences of the
fielddisjunct) before running —Tests 7 passed (7).Skipping that rebuild would have measured the old code and gone green for the
wrong reason.
e2ca7578dwithnode scripts/pm/dispatch-gates.mjs(no path list passed — the script derives the change set itself): 17 families,
all green, plus the convention-triggered ones. Verdict lines rather than
$?:check-engine-double-contract: OK — 384 pinned, 133 in the DEBT ledger, 2 exempt;check-test-source-alias OK — 72 packages with tests scanned;OK: 13 package(s) read outside themselves, all declared.check:type-check-debtcaught a real defect and it was fixed in the code,not the ceiling. The first measurement came back 64 against
@objectstack/metadata-protocol's ledgered 63 — oneTS2339in the newtest file, where a helper's parameter type erased the caller's
opfield.Making the helper generic brought the re-measure to exactly 63, with zero
errors attributed to the new file.
scripts/engine-double-contract.pinned.jsonregenerated with--write(never hand-edited): the new file'supdatedouble routes throughassertEngineUpdateDispatchfrom@objectstack/metadata-core(⛔ not fromobjectql— reverse edge), so the ledger grew by one row. The double declaresno
delete: no case reaches it, and an unreached member is dead code thatwould also need pinning.
paths: 0 errors, 0 rule warnings. (1) The population came from eslint's own
config, not a guess — it reported
File ignored because no matching configuration was suppliedfor the changeset.mdand the ledger.json,i.e. it decided which of my paths it lints. (2) The count is eslint's own
--format jsonoutput: 5 entries, 3 linted. (3) Invariance: this repo'seslint.config.mjsstates in its own header that it "never enables type-awarelinting (no
parserOptions.project, no typed@typescript-eslintrules) forANY file, test or not" — so this diff cannot move a verdict on any untouched
file. The repo-wide
pnpm lintis CI's run.Also in this diff
protocol.destructive-409-face-inventory.test.ts's inventory prose said the gatefires when "the folded type is
objectorfield" — it documented the verycondition being trimmed, so leaving it would have re-published the wrong
reachable set from the file the card was filed out of. Updated to
object, witha note pointing at the new pin. Doc comment only; no assertion in that file
changed and its cases are unaffected.
Boundaries observed
packages/specwas read (metadata-plugin.zod.ts:733) and not edited.packages/drivers/driver-sql/src/sql-driver.ts(held by #11067) untouched.No
content/docs/releases/edit. Changeset ispatch. No test skipped,disabled, or quarantined; no ratchet ceiling raised.
Generated by Claude Code