Skip to content

feat(spec): the liveness gate reads the retiredKey() tombstone marker — a tombstoned key may not be graded live - #19092

Merged
os-bill merged 5 commits into
mainfrom
claude/issue-19062-liveness-reads-tombstone-marker
Sep 18, 2026
Merged

os-bill merged 5 commits into
mainfrom
claude/issue-19062-liveness-reads-tombstone-marker

Conversation

@os-bill

@os-bill os-bill commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #19062

Clause-②: no

What this closes

The liveness gate graded a schema property from two sources: the ledger row's status, or — with no row — a [experimental / [planned marker read by markerStatus(). It had no reading of the retiredKey() tombstone marker at all, so tombstone + status: live was a structurally permitted combination rather than an oversight:

  • the forward pass is satisfied — a tombstone keeps the key in the walked shape, so the row exists and is classified;
  • the orphan pass is satisfied for the same reason — the property has not left the schema;
  • nothing constrained what that row was allowed to say.

This adds the join both directions lacked, as a fourth direction beside the orphan scan: walk the tombstones the gate already reaches, and ask each one's ledger row what it claims.

Ordering — discharged

This section used to read "this PR must not merge before #19059", and that constraint is now spent: #19059 landed as a362e0e425, main is merged into this branch, and the single offending row it named — agent/tools, #18304's — is dead on the real ledgers. Re-measured by the seat on this PR's head 3d49d7a199 (2026-09-18T22:08Z):

  • check:liveness against the shipped ledgers: exit 0, printing 79 graded with a status the tombstone allows.
  • The 16 control cases that were red for that one cause are green. The whole scripts/liveness/ suite: 333 passed (333) over 12 files, exit 0.

⛔ No landing order remains on this PR. Read the population table below as the measurement that motivated the rule, not as a description of today's tree.

The population, measured — the card's first deliverable

The card states its own evidence limit: "the population is unmeasured, and measuring it is the first piece of the work — it needs exactly the join the gate lacks". Measured over all 39 governed types by the gate's own walk, at two commits — the tree the rule was written against, and this PR's head 3d49d7a199 (the right-hand column re-measured by the seat, 2026-09-18T22:08Z):

reading tree the rule was written against this PR's head
[REMOVED] tombstones the gate's walk reaches 79 79
…graded dead 78 79
…graded live (the defective state) 1 — agent/tools 0 — #19059 repaired that row
…whose status came from an explicit ledger row 79 / 79 79 / 79 (none from a marker, none from childrenDefault, none UNCLASSIFIED)

The full list of 79 is in the report on the card. Drift control on the instrument: the census walk reproduces the gate's own per-type verdict counts for all 39 types and 1092 verdict points exactly (compared against check:liveness --json's classified + unclassified), so it is the gate's walk and not a copy of it.

Reachable radius of that census — it is the gate's walk, so it is the ledger-driven drill, nothing wider. Known targets necessarily outside it: a full-depth walk of the same governed roots finds 59 further [REMOVED] tombstones the gate never reaches — object/fields.conditionalRequired, object/listViews.performance, job/retryPolicy.retryDelayMs among them — because they sit under containers carrying a blanket verdict (the 601 child keys the gate already reports as classified nowhere). No rule placed in this gate can grade a key the gate does not walk; closing that is the undrilled-container worklist, not this card.

Why remedy (2), measured rather than argued

Triage named two shapes and refused to pick. Their side-effect surfaces were measured, each applied alone to the base gate with the mutation proven on disk before any verdict was read:

  • (1) a third [REMOVED] arm in markerStatus() — on the acceptance sample this leaves the gate at exit 0. markerStatus() is consulted only when led?.status is falsy, and all 79 tombstones the walk reaches take their status from an explicit row, so this arm changes the verdict of 0 of the 79 today. What it does change is the no-row case, which the gate currently reports as UNCLASSIFIED and fails on, and which ORPHAN_GUIDANCE tells authors never to create — so (1) alone would silence an existing loud failure. It cannot satisfy the acceptance.
  • (2) a rule beside the tombstone-row rule — implemented here. The forbidden set is a named constant holding exactly live, which is what was ruled. planned / experimental / live-elsewhere on a tombstone are equally false, and their population today is zero, so widening the set is a separate measurement rather than a rider on this one.

Acceptance

Every leg runs the real gate the way CI invokes it, via --ledger-root against a copied ledger.

The carrier the legs flip is derived per run from the gate's own report.tombstones enumeration, not named: top-level, not a BOUND_PROOF_PATHS coordinate, and currently dead. A fixture pinned to one row's current verdict is pinned to a moving fact, which is how this block first broke — it named agent/tools, that row went dead upstream, and the "this would be a no-op" guard took four cases red. The seat re-derived the same filter chain independently (2026-09-18T22:08Z): 79 enumerated → 42 top-level → 42 unbound → 42 dead, carrier action/bulkEnabled. ⚠️ Today only the top-level filter bites — the bound-path and dead filters each drop 0 — so those two are shape guards holding a property, not measured reductions, and the block's own assertion is on eligible > 0, never on a filter biting.

The red and green samples differ in exactly one status value (plus the arithmetic the generated count artifact requires, so a stale count cannot mask the verdict), and "one value apart" is now checked as bytes by its own case rather than asserted in prose.

leg before after
main — tombstone + row status: live exit 0 (green — the gate had no reading) exit 1, <derived carrier> -> "live", with exactly one ✗ block in the output
lit control — same sample, legal status exit 0 exit 0
dark control — ordinary non-tombstoned live rows exit 0 exit 0, and the whole gate output is identical except the two lines added here

The dark control is the strong form: a full diff of the gate's output on a legal ledger, before versus after, differs only by the new tombstoned keys: census line and the new clause in the summary sentence. All 39 per-type verdict lines and every other check's output are byte-identical, so nothing else was tightened.

The before-legs were taken by reverting both files to the base commit after the fix was committed, with the mutation proven on disk (blob hashes moved, the new symbols counted 0 occurrences) and the restore proven by git diff HEAD being empty and the blob hash matching HEAD's.

Non-vacuity

0 forbidden reads identically whether every row is honest or the marker drifted and the scan reached nothing, so the gate prints the population it asked on every run, pass or fail. On this PR's head 3d49d7a199 against the shipped ledgers (2026-09-18T22:08Z) it prints the green form:

tombstoned keys: 79 `[REMOVED]` tombstone(s) reached by the walk, 79 graded with a status the tombstone allows.

and on the red leg the same line carries the finding:

tombstoned keys: 79 `[REMOVED]` tombstone(s) reached by the walk, 78 graded with a status the tombstone allows, 1 FORBIDDEN.

and orphans.test.ts holds TOMBSTONE_MARKER equal to what retiredKey() actually writes, read from the producer rather than restated.

Changeset

skip-changeset, measured rather than assumed. @objectstack/spec's files[] is dist · json-schema · liveness · prompts · llms.txt · README.md · src/**/*.zod.ts · CHANGELOG.md · api-surface · api-surface-declarations · spec-changes.json; scripts/ is not in it, and the build's entries are src modules. Each of the three symbols this PR adds occurs in 0 files under those shipped paths; positive controls through the same instrument and the same path set: retiredKey in 102, ObjectSchema in 30. Nothing this diff moves is shipped by any package.

Acceptance notes

Noted, not filed — observations, with no reproducible defect, contract violation or authoring trap behind them:

  • PR fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059 landed scripts/liveness/tombstoned-row-status.test.ts as a362e0e425 (2026-09-18T22:08Z), a contract test asserting the same invariant against the real ledgers at top-level granularity only. It is complementary rather than duplicative — that one pins today's ledgers in Test Core, this one is a gate rule over every depth the walk reaches and over any ledger root. Left untouched here, per triage's ⛔ 不折叠. This PR is the second of the two, and both files were written without sight of the other.
  • The gate's markerStatus() still has no [REMOVED] arm, deliberately: with a row present it is never consulted, and without one the UNCLASSIFIED failure is the louder answer. Recorded so the next reader does not read its absence as an oversight twice.

Generated by Claude Code

The gate graded a property from the ledger row's `status` or, with no row,
from a `[experimental` / `[planned` marker. It had no reading of the
tombstone marker at all, so `retiredKey()` tombstone + `status: live` was a
structurally permitted combination: the forward pass is satisfied (the
tombstone keeps the key in the walked shape), the orphan pass is satisfied
(the property is still there), and nothing constrained what the row claimed.

Adds the join both directions lacked — walk the tombstones, ask each one's
ledger row what it says — as a fourth direction beside the orphan scan, with
its guidance next to the guidance that states the rule's other half.

Census over all 39 governed types at this base: the walk reaches 79
`[REMOVED]` tombstones, 78 already `dead`, one outlier.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
Unit cases for the pure scan (including the marker held equal to the real
`retiredKey()` producer, so a drifted marker fails instead of reporting a
clean tree), and the real-gate pair via `--ledger-root`: one sample, two
copies differing in exactly one status value, red and green respectively.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
@os-bill os-bill added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 18, 2026 — with Claude
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see

Coarse fallback — 0 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 a675ad4ef0111bf0ae72b39f24c93ffdde82c092 → packageMentionDocs.

os-bill and others added 2 commits September 19, 2026 05:43
…a row

The first draft named two rows: it flipped `agent/tools` to `dead` to
neutralise the then-offender, and `hook/timeout` to `live` as the sample.
The first broke as soon as that row was repaired upstream — the fixture's
own "this would be a no-op" guard fired, loudly, which is what a guard is
for. What was wrong was pinning a fixture to one row's current VERDICT,
a moving fact about the ledger rather than a property of the rule.

So the carrier is derived per run from the gate's own enumeration, under
filters that are properties of the shape (top-level · not a bound
high-risk coordinate · currently `dead`), and the derivation asserts it
found one and names the population it searched. The neutralisation half
is gone: these cases now inherit this file's existing dependency on the
shipped ledgers being green rather than carrying a private patch list.

To make that possible the scan enumerates its population instead of
totalling it (`report.tombstones`), on `report.authorable`'s reason.

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l and removed size/m labels Sep 18, 2026
@os-bill
os-bill marked this pull request as ready for review September 18, 2026 22:13

os-bill commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Seat review — re-measured against the tree, not the report

domain:spec seat 2. The three stale statements the dev flagged are now corrected in the body above. Below is what the seat measured itself, on head 3d49d7a199, before landing (2026-09-18T22:08Z).

reading instrument result — all taken 2026-09-18T22:08Z
the ordering blocker #19059 merged as a362e0e425; main merged into this branch discharged
the gate on the real ledgers tsx scripts/liveness/check-liveness.mts exit 0 — 79 … 79 graded with a status the tombstone allows
the suite vitest run scripts/liveness/ 333 passed (333), 12 files, exit 0
the census the gate's own --json report.tombstones 79 enumerated, all 79 graded dead, 0 taking status from anything but an explicit row; 37 of the 79 sit below the top level, spanning 21 of the 39 types
the derivation the seat's own filter chain, written independently against BOUND_PROOF_PATHS and the ledgers 79 → 42 top-level → 42 unbound → 42 dead, carrier action/bulkEnabled — reproduces the block's own figures exactly
merge=os-regen git check-attr merge on each of the 4 changed files unspecified on all four — nothing here resolves silently
governed surface check-governed-merges --pr 19092 0 of 4 paths hit the register — ordinary queue landing
clause-② check-clause2-carriers --pair 19092 exit 0; Clause-②: no on both the card claim (5733893029) and the body. A new forbidden combination narrows the accepted set, which is not clause-②.

One observation, recorded rather than filed. Of the derivation's three filters only the first bites today: after the top-level filter takes 79 to 42, the BOUND_PROOF_PATHS filter and the dead filter each drop 0. That is not a defect — they hold a property of the shape, and the block asserts on eligible > 0 rather than on any filter biting — but "three filters" should not be read as three measured reductions, so the body now says which one is load-bearing today.

Radius of that re-measurement. It is the gate's own walk, so it is silent about the 59 further [REMOVED] tombstones under blanket-verdict containers the walk never reaches. Known target necessarily outside it: #19118 — strict-route retirements leave no [REMOVED] marker at all, so they are invisible to report.tombstones by construction. The 79 is a correct answer to a narrower question than "how many retirements exist".

Ready flipped; auto-merge mounted on SQUASH.


Generated by Claude Code

@os-bill
os-bill added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 70e1a82 Sep 18, 2026
44 checks passed
@os-bill
os-bill deleted the claude/issue-19062-liveness-reads-tombstone-marker branch September 18, 2026 23:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate tests tooling

Projects

None yet

2 participants