Skip to content

fix(metadata-protocol): the destructive gate's reachable type set is object alone (#11014) - #11170

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-11014-trim-dead-field-limb
Aug 23, 2026
Merged

fix(metadata-protocol): the destructive gate's reachable type set is object alone (#11014)#11170
os-zhuang merged 1 commit into
mainfrom
claude/issue-11014-trim-dead-field-limb

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#11014

saveMetaItem's Phase 3a-destructive gate opened on
(singularType === 'object' || singularType === 'field'), and the field limb
could 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 field spelling is the one thing that made
the 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 saveMetaItem over a
sys_metadata-backed kernel before anything was deleted — not read off the
card. Every measurement below is now a case in the new pin.

probeconfigurationmeasured outcome
Adefault posture, field create, runtime-only parentNOT_CREATABLE / 403, nothing persisted
Bdefault posture, fieldupdate over an existing rowNOT_CREATABLE / 403 — the gate's own precondition (prev non-null) is met and it still never runs
Fdefault posture, field under an artifact-backed parentNOT_OVERRIDABLE / 403 (#7743's isNestedArtifactField path)
COS_METADATA_WRITABLE=field, fieldtextnumberresolved — no 409
Dcontrol: object, field droppedDESTRUCTIVE_CHANGE / 409
EOS_METADATA_WRITABLE=field + stored field body carrying a fields mapDESTRUCTIVE_CHANGE / 409

Reason 1 — a field body has no fields map to diff. Confirmed at
detectDestructiveObjectChanges: it reads prev.fields / next.fields, and
both fold to {} for a field body, so every loop iterates zero times. The
mechanism 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: textnumber is exactly the edit that raises
field_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 — field cannot reach the gate.packages/spec
metadata-plugin.zod.ts:733 still declares it code-only (allowRuntimeCreate: false, allowOrgOverride: false, supportsOverlay: false; read-only for this
card, unmodified). The #5086 refusal at protocol.ts:13188 sits above the gate
and answers first, in both of its shapes (A, F).

⚠️ Where each reason stops — and why that argues for the trim

Neither reason is absolute. Reporting this rather than smoothing it over, since
the card asserted both without qualification:

  • Reason 2 has a documented operator hatch.OS_METADATA_WRITABLE=field
    makes isOverlayAllowed('field') true and does carry a field write past the
    code-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 the
    gate inert on its own — which is exactly what "two independent reasons" buys.
  • Reason 1 stops at schema-valid bodies. The detector is type-agnostic: it
    reads prev.fields whatever the type is. A storedfield row carrying a
    fields map did fire the gate (probe E). So reason 1 is a fact about
    FieldSchema, not about the code path.

Together: the limb was reachable only through a double fault — the hatch
open and a stored body FieldSchema rejects (unrecognized_keys: ['fields'],
asserted in the pin). And in that configuration the 409 was a false alarm: a
field write mints a standalone sys_metadata row that nothing composes into
its 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. object behaviour 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 object alone:

  • §0 control — the harness raises the real 409 for an object. ⭐ Without
    this green, every "no 409" assertion below is satisfied by a harness that never
    reached the gate — the card's own failure mode in miniature.
  • §1 reason 2field create → NOT_CREATABLE; fieldupdate over an
    existing row
    NOT_CREATABLE (the case that matters most: the gate's
    precondition is met); artifact-backed parent → NOT_OVERRIDABLE. Each asserts
    code + status (ADR-0112, never a bare toThrow()) and that nothing was
    persisted
    — a gate that refuses after writing is a log line.
  • §2 reason 1 — driven with the hatch open so reason 1 is measured
    independently of reason 2, with its own liveness case (the write really did
    persist, so it reached the gate) and its own in-environment object
    control
    . Plus the shape claim asserted against the schema itself:
    FieldSchema accepts a field body (full safeParse) and rejects a fields
    key with unrecognized_keys (the key claim).
  • §3 — the double-fault delta, and that the stored body it needs is one
    FieldSchema refuses.

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 one
gate earlier, and §2's field bodies have no fields map for the limb to act on.

Measured — exactly that:

 × a corrupt `field` body with a `fields` map now saves instead of 409-ing
AssertionError: expected 'threw' to be 'resolved'
Test Files 1 failed (1)
Tests 1 failed | 9 passed (10)

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 resolves src/protocol.ts
with no dist/ on the path and no rebuild is involved — check:test-source-alias
is 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 is
byte-identical to the commit afterwards (git diff --quiet → 0).

Verification — all at final commit e2ca7578d

  • pnpm --filter @objectstack/metadata-protocol exec vitest runTest Files 137 passed | 2 skipped (139) · Tests 1878 passed | 10 skipped (1888)
    (baseline 1868; +10 is this file).
  • New pin alone — Test Files 1 passed (1) · Tests 10 passed (10).
  • Downstream, against a rebuilt dist/: packages/objectql's
    protocol-destructive.test.ts imports the package specifier, so
    @objectstack/metadata-protocol was rebuilt first and the trim confirmed
    present in dist/index.js (request.force && singularType === "object", zero
    occurrences of the field disjunct) before running — Tests 7 passed (7).
    Skipping that rebuild would have measured the old code and gone green for the
    wrong reason.
  • Gate union re-derived at e2ca7578d with node 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-debt caught 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 — one TS2339 in the new
    test file, where a helper's parameter type erased the caller's op field.
    Making the helper generic brought the re-measure to exactly 63, with zero
    errors attributed to the new file.
  • scripts/engine-double-contract.pinned.json regenerated with
    --write (never hand-edited): the new file's update double routes through
    assertEngineUpdateDispatch from @objectstack/metadata-core (⛔ not from
    objectql — reverse edge), so the ledger grew by one row. The double declares
    no delete: no case reaches it, and an unreached member is dead code that
    would also need pinning.
  • ESLint — declared narrowing, not a skipped run. Scoped to the 5 changed
    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 supplied for the changeset .md and the ledger .json,
    i.e. it decided which of my paths it lints. (2) The count is eslint's own
    --format json output: 5 entries, 3 linted. (3) Invariance: this repo's
    eslint.config.mjs states in its own header that it "never enables type-aware
    linting (no parserOptions.project, no typed @typescript-eslint rules) for
    ANY file, test or not" — so this diff cannot move a verdict on any untouched
    file. The repo-wide pnpm lint is CI's run.

Also in this diff

protocol.destructive-409-face-inventory.test.ts's inventory prose said the gate
fires when "the folded type is object or field" — it documented the very
condition 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, with
a note pointing at the new pin. Doc comment only; no assertion in that file
changed and its cases are unaffected.

Boundaries observed

packages/spec was 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 is patch. No test skipped,
disabled, or quarantined; no ratchet ceiling raised.


Generated by Claude Code

…`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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 1 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via saveMetaItem (symbol))
  • content/docs/deployment/validating-metadata.mdx(via saveMetaItem (symbol))
  • content/docs/kernel/services-checklist.mdx(via saveMetaItem (symbol))
  • content/docs/permissions/authorization.mdx(via saveMetaItem (symbol))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via saveMetaItem (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 10485009a693b07cf90c98e81a0d8b824c62be95packageMentionDocs.

Which tree this was computed on

This run read content/docs from 44030e7b5cc8ed2b576ac0588dfb1f4faa849fc1 — the merge of head e2ca7578d0bddc5d998d9f52634d4359c3ad5425 into base 10485009a693b07cf90c98e81a0d8b824c62be95, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 10485009a693b07cf90c98e81a0d8b824c62be95 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 23, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 23, 2026 00:58
@os-zhuang
os-zhuang added this pull request to the merge queueAug 23, 2026
Merged via the queue into main with commit 2d8b92fAug 23, 2026
32 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-11014-trim-dead-field-limb branch August 23, 2026 01:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] saveMetaItem's Phase 3a-destructive gate declares a field limb that can never produce a finding — two independent reasons

2 participants

@os-zhuang@os-project-manager