Repository navigation
feat(lint): name the retired allowRestore/allowPurge residue at the authoring door - #17917
Conversation
…g door `ObjectPermissionSchema` accepts `allowRestore: false` / `allowPurge: false` as inert residue and strips them in silence (#12840, the retired-default residue tolerance — its ruling is not re-adjudicable and nothing here moves it). The silence is deliberate so that artifacts built by the published 17.x toolchain keep parsing, and `acceptRetiredDefaultResidue`'s own docblock names the channels that stay loud for authored sources: tsc `never`, `os migrate meta`, the ADR-0087 D2 conversion. Against a non-TypeScript author that list is one entry short. `tsc never` is a TypeScript channel; the conversion and `os migrate meta` are the same channel twice, and it is declared `retiredFromLoadPath`, so it never fires on the load path. An author writing the key in a JSON/YAML source and not running the migration gets a clean parse and no signal at all. Adds `validateRetiredPermissionResidue` — one advisory `warning` rule on the `normalized` tier, registered in `AUTHORING_RULES` so `os validate`, `os build` and `os lint` run it. It fires on the captured residue value and nothing else; every other value is already refused at the parse with the prescription attached. The hint is READ from the tombstone's own published description rather than retyped, so it cannot drift from the parse-time wording. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…horing rule Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…sh four CLI transcripts `check:doc-authoring` refuses an internal issue id inside customer-facing string prose — a runtime string reaches authors and generated surfaces, none of whom can resolve it. The reference moves to the adjacent comment, where the reader who can resolve it already looks. `check:docs-transcript-drift` derives the author-time rule count from `AUTHORING_RULES` and compares it against the transcripts the docs quote. The new entry moves it 44 -> 45, so the four pages that print it are refreshed. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 4 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 8ec7a6013530e3012bce229d6d3627ef158c7a8c && git checkout 8ec7a6013530e3012bce229d6d3627ef158c7a8c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5741ff10c3068a84e9099d3a3eb3b533054bbc50 d7d22bf4bebc4f0065b932556b07a9330d7822b2 && git checkout -B drift-repro 5741ff10c3068a84e9099d3a3eb3b533054bbc50 && git merge --no-ff d7d22bf4bebc4f0065b932556b07a9330d7822b2
node scripts/docs-audit/affected-docs.mjs --json 5741ff10c3068a84e9099d3a3eb3b533054bbc50
|
…tack-ai#18681) Fixes objectstack-ai#18456 Clause-②: no `scripts/pm/` sits outside every workspace package, and the root package is private, so no `files[]` can ship this diff — `skip-changeset`. ## The defect `check-clause2-carriers --pair` is the landing pre-check every seat runs, and on one pair (PR objectstack-ai#17917 / card objectstack-ai#17425) it answered **0 at 02:57Z, 4 at 03:04:09Z and 0 at 03:58:33Z on 2026-09-13 with an identical script blob**. Two explanations were ruled out with controls (no comment on that thread was ever edited; the board is resolved from the environment, never from the working directory), so the cause is still UNKNOWN — and the three runs could not be compared, because not one of them had SAID what it read. The judging half is already deterministic given a fixed document (`--pair-json` proves that); what was unpinned is **what document the live path builds**. This states it: every `--pair` run now closes with a fenced `clause2 input record` block on stderr, with the same field roster on every exit, so two runs that disagree are settled by **diffing their two blocks** — never by re-running until one side wins. ⛔ No guess at the cause is dressed as a fix here: no predicate, no state, no row, no count and no exit code reads one character of the record, and the judging half is untouched. ## The record's field roster Rendered from `INPUT_RECORD_RUN_FIELDS` and `INPUT_RECORD_PAIR_FIELDS` and from nowhere else, so a field cannot silently disappear: a declared field this run could not fill renders with an explicit token rather than vanishing, and a field the builder fills that the roster does not declare is NAMED in the block (`record.undeclared`). Values too long for one line continue on indented lines under their key. | half | fields | |:--|:--| | run | `record.version` · `run.utc` · `run.mode` · `run.script.path` · `run.script.blob` · `run.script.bytes` · `run.node` · `board.repo` · `board.source` · `read.plan` · `read.api` · `read.token` · `read.served` · `read.pair-json` · `run.requests` · `pairs.derived` | | per pair (`pair.N.`) | `pr` · `card` · `derivation` · `head-sha` · `card-comments` · `card-comment-ids` · `card-comment-newest` · `pr-comments` · `pr-comment-ids` · `pr-comment-newest` · `claim.rule` · `claim.selected` · `claim.rejected` · `claim.clause2-line` · `pr-body.clause2-line` | Four of them are worth naming for WHY they are there: - **`run.requests`** — every read the run issued, in order, with its channel, its exact path and its **row count**. A page asked for with `per_page=100` that answers with exactly 100 rows is the one shape a truncated read and a complete one share, and nothing printed it. - **`claim.rule` + `claim.selected` + `claim.rejected`** — the carrier, the rule that picked it and every candidate it did not pick, each with its reason. That separates "the two runs selected different comments" from "the two runs applied different rules". - **`claim.selected`'s body fingerprint** (bytes + `sha256:`) — the field the measured 0/4/0 actually needs. A `misplaced` verdict on that thread requires the governing claim to have carried no readable declaration while a superseded one did; same ids with a different verdict is only possible if the BYTES differed, and the ids were all anybody could see. - **`run.script.blob`** — git's blob hash of this file, beside the path it ran from. "The blob was identical on both sides" was a claim in the incident; it is now a printed fact any seat checks with `git hash-object`. On this PR's head it reads `25d204236aa8296644813109fa77541d6efe1644`, which is exactly `git rev-parse HEAD:scripts/pm/check-clause2-carriers.mjs`. The `--json` sweep carries the same record under `inputs` — the same record, ⛔ never a second format. ## The two live blocks the card names `--pair 17917` — the pair from the card. Both it and objectstack-ai#18654 have since merged, so `--pair` answers **exit 2** on each today (the pair cannot be formed from a closed PR). ⭐ That is precisely the class of exit the old code said the least about, and the block is now complete on it: ```text ----- clause2 input record v1 ----- record.version: 1 run.utc: 2026-09-17T14:10:26.210Z run.mode: --pair 17917 run.script.path: /home/user/objectstack-issue-18456/scripts/pm/check-clause2-carriers.mjs run.script.blob: 25d2042 (git blob sha1 — check it with `git hash-object` on the path above) run.script.bytes: 513169 run.node: v22.22.2 board.repo: objectstack-ai/objectstack board.source: default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered read.plan: (i) token then (ii) token-less public read read.api: https://api.github.com (REST, accept application/vnd.github+json) read.token: present read.served: token=1, public=0, pair-json=0 read.pair-json: (not named — this run read the network) run.requests: 1 read(s), in the order they were issued objectstack-ai#1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s)) pairs.derived: 0 pair(s) record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two blocks — ⛔ never by re-running until one side wins. The blob line says whether the two runs were even the same instrument. ----- end clause2 input record ----- ``` `--pair 18654` — the pair this seat landed today, which answered 0 at 12:32Z and is likewise merged now (**exit 2**): ```text ----- clause2 input record v1 ----- record.version: 1 run.utc: 2026-09-17T14:10:27.163Z run.mode: --pair 18654 run.script.path: /home/user/objectstack-issue-18456/scripts/pm/check-clause2-carriers.mjs run.script.blob: 25d2042 (git blob sha1 — check it with `git hash-object` on the path above) run.script.bytes: 513169 run.node: v22.22.2 board.repo: objectstack-ai/objectstack board.source: default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered read.plan: (i) token then (ii) token-less public read read.api: https://api.github.com (REST, accept application/vnd.github+json) read.token: present read.served: token=1, public=0, pair-json=0 read.pair-json: (not named — this run read the network) run.requests: 1 read(s), in the order they were issued objectstack-ai#1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s)) pairs.derived: 0 pair(s) record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two blocks — ⛔ never by re-running until one side wins. The blob line says whether the two runs were even the same instrument. ----- end clause2 input record ----- ``` ⭐ `diff` of those two blocks is **four lines**: `run.utc` and `run.mode`, twice. Same roster, same order, same shape — which is the property the card asked for. ## A live block on exit 0 `--pair 18659` (open at the time of writing) — **exit 0**, the full pair half: ```text ----- clause2 input record v1 ----- record.version: 1 run.utc: 2026-09-17T14:10:36.744Z run.mode: --pair 18659 run.script.path: /home/user/objectstack-issue-18456/scripts/pm/check-clause2-carriers.mjs run.script.blob: 25d2042 (git blob sha1 — check it with `git hash-object` on the path above) run.script.bytes: 513169 run.node: v22.22.2 board.repo: objectstack-ai/objectstack board.source: default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered read.plan: (i) token then (ii) token-less public read read.api: https://api.github.com (REST, accept application/vnd.github+json) read.token: present read.served: token=5, public=0, pair-json=0 read.pair-json: (not named — this run read the network) run.requests: 5 read(s), in the order they were issued objectstack-ai#1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s)) objectstack-ai#2 (i) token /repos/objectstack-ai/issues/18443 -> HTTP 200 objectstack-ai#3 (i) token /repos/objectstack-ai/issues/18443/comments?per_page=100 -> HTTP 200 (4 row(s)) objectstack-ai#4 (i) token /repos/objectstack-ai/objectstack/pulls/18659/files?per_page=100&page=1 -> HTTP 200 (1 row(s)) objectstack-ai#5 (i) token /repos/objectstack-ai/issues/18659/comments?per_page=100 -> HTTP 200 (1 row(s)) pairs.derived: 1 pair(s) pair.1.pr: 18659 pair.1.card: 18443 pair.1.derivation: `closing-keyword` (via a closing keyword) — body line: Fixes objectstack-ai#18443 pair.1.head-sha: 1344eb5 pair.1.card-comments: 4 row(s) pair.1.card-comment-ids: 5713976124,5714587497,5714873191,5715029659 pair.1.card-comment-newest: 5715029659 at 2026-09-17T13:19:56Z pair.1.pr-comments: 1 row(s) pair.1.pr-comment-ids: 5715030051 pair.1.pr-comment-newest: 5715030051 at 2026-09-17T13:19:57Z pair.1.claim.rule: the GOVERNING claim — the NEWEST comment whose body carries a line beginning `Claim:`/`Claimed:` AND whose `Branch:` line parses at least one protocol-shaped branch (newest by `created_at`; an unreadable stamp or a tie falls back to thread order, later row wins). The pool is every claim comment sharing that `created_at`; when NO claim names a branch at all, every claim comment is the pool. ⛔ Not earliest, ⛔ not a session match, ⛔ not the one whose body mentions the key. pair.1.claim.selected: 1 comment(s) in the pool 5714587497 at 2026-09-17T12:46:45Z — 2159 bytes, sha256:795e1df6c9fd pair.1.claim.rejected: none — every claim comment on this thread is in the pool pair.1.claim.clause2-line: DECLARED `no` — Clause-②: no pair.1.pr-body.clause2-line: DECLARED `no` — Clause-②: no⚠️ stated as an INPUT only — ⛔ no row here judges the PR body; the declaration limb is judged from the card, and `check-changeset-no-major.mjs` is what reads this line. record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two blocks — ⛔ never by re-running until one side wins. The blob line says whether the two runs were even the same instrument. ----- end clause2 input record ----- ``` ## Pins Battery **objectstack-ai#18456: the `--pair` input record — the same block on every exit, so two runs that disagree can be diffed**, registered in `SELF_TEST_BATTERIES` with a floor of **38**; **41** cases register. `SELF_TEST_BATTERY_FLOOR` raised 26 → 27 by exactly the one battery this adds. What is pinned, in the card's own terms: - the record is **present and complete on exit 0**, on the **exit-4 (MISPLACED)** shape and on a **refusal that formed no pair** — all three key lists asserted equal; - the **field roster** cannot lose a field: a declared field that was never filled still renders (with `INPUT_RECORD_UNSET`), an empty record still carries every declared key, and a key outside the roster is named rather than printed in silence; - the **selected-claim rule is stated**, and it is the one constant `claimCarrierSelection` applies — so the printed rule cannot drift from the applied one; - a **rejected candidate is named with its reason**, and a thread with nothing rejected says so; - a **`--pair-json` run names that read path as such** and names the document; - the body fingerprint **moves when only the bytes move** while every id field stays identical — the measured shape, asserted directly; - `gitBlobSha1` is pinned against two values `git hash-object` prints. ⛔ CONTROLS in the same battery: the block carries no verdict, no exit code and no finding row; building it changes no reading; and the selection the block prints IS the pool `cardDeclaration` judged (ONE derivation — `cardDeclaration` now calls `claimCarrierSelection` instead of deriving the pool inline, so the record and the verdict cannot describe two different comments). `--self-test` on this head: **786 cases pass, exit 0** (745 before; +41). ## Ablation From the committed tree, blob `25d204236aa8296644813109fa77541d6efe1644` (= this PR's head blob), the pair half of the record removed on disk, mutation proved before the run, restore by blob hash under a `trap`: ```text HEAD blob 25d2042 before: removed-text count=1 (want 1); injected count=0 (want 0) after : removed-text count=0 (want 0); injected count=1 (want 1) mutated blob e88355f70e8648f1e3d30147f0c82b7c3c157609 VERDICT ablation-mutated self-test exit=1 ← 14 cases red ✗ every declared PAIR field is present once per derived pair, prefixed by its index ✗ the SELECTION RULE is printed, not merely applied — two runs must be comparable on the rule too ✗ …and it is the one constant, so the printed rule cannot drift from the applied one ✗ the SELECTED carrier is named by id and by date ✗ ⭐ …with a BODY FINGERPRINT: the one field that tells "same ids, different bytes" apart ✗ ⭐ …and it MOVES when only the bytes move: same ids, same count, same newest, different verdict ✗ every REJECTED candidate is named, with the reason it is not the carrier ✗ …and a thread whose claims are all in the pool says THAT, rather than going quiet ✗ a claim that parses ZERO branches leaves NO carrier, and the block names that claim ✗ an UNREAD thread reads UNREAD, ⛔ never 0 rows ✗ the line READ from the carrier is stated — declared, near miss or nothing ✗ the PAIRING quotes the body line it was derived from ✗ …and the branch-name fallback names the head ref instead of quoting a line that does not exist ✗ the PR-BODY line is read and stated — ⛔ and stated as an INPUT, never as a limb restored blob 25d2042 (HEAD 25d2042) git diff HEAD --name-only: [] VERDICT ablation-restored self-test exit=0 ``` Direction predicted before the run and observed: **turns red**. The module is run directly from source by `node scripts/pm/…` — no build and no `dist/` between the edit and the run, so the on-disk proof is the whole preflight.⚠️ **A named gap, not a hidden one**: the battery drives the builder and the renderer, and it cannot see `main`'s **emission**. An ablation that deleted the two lines in `main`'s `finally` would come back green. What covers emission is the three live blocks quoted above, taken on this head across three different exits. ## Candidate cause, unproven — ⛔ not fixed here Two readings taken while wiring the record. Neither is acted on in this PR. **1. On the blob all three 2026-09-13 runs ran, exit 4 was the DETERMINISTIC answer for that pair — so what is unexplained is the two 0s, not the 4.** - The file's last change before those runs was `a5ed18ced` (2026-09-12T06:05:25Z, "a key-INITIAL clause-② line that QUOTES the spelling is not a declaration"); its next change was `4e3a496ba` at 2026-09-13T17:19:18Z, after all three runs. `a5ed18ced`'s blob is `aecbb2d86683eb908468fdacaac2ff53753f06ef` — the same blob PR objectstack-ai#18448's body independently cites as "the exact blob the 2026-09-13 readings were taken from". - The governing claim on card objectstack-ai#17425 at that moment was comment `5650083758` (2026-09-13T01:57:23Z). Its line 3 opens `Clause-②: no —` and then quotes the spelling again inside the same line. Run first-hand against **that historical blob's own `readClause2Line`**: `{"kind":"near-miss","reason":"describing"}`, and `cardDeclaration` on a one-claim thread reads `missing` — ⛔ not a declaration. Today's copy reads it identically. - A `--pair-json` document assembled from the REAL thread as it stood at 03:04:09Z (its 9 comments, both carriers' real label event streams) answers **exit 4, MISPLACED** on this PR's head, quoting the superseded `Clause-②: yes` and naming `5650083758` as the correction target — which is what the 03:04Z reviewer and the 02:53Z dev round both reported. - ⇒ The 4 is reproducible and mechanically explained. The 0s are not. ⭐ Exactly the difference the record's `claim.selected` fingerprint and `claim.clause2-line` would have shown, had the 0-runs printed one. -⚠️ Limits of this reading: the historical module was exercised for `readClause2Line` (self contained) and `cardDeclaration` (which imports today's sibling modules); the commit ordering is read from a shallow checkout, corroborated by objectstack-ai#18448's independent citation of the same blob. **2. The comment read — the one the declaration limb depends on — is the only read here with no page discipline.** `readCardComments` issues ONE request, `/issues/N/comments?per_page=100`, with no `page=` ladder and no short-read check. `readCarrierEvents` and `readPullFiles` both page to exhaustion and answer `null` (UNJUDGED, never clean) when their cap is hit, for the reason their own docblocks state. A card thread past 100 comments therefore loses its tail silently, and the claim pool is built from whatever came back. Not the cause on objectstack-ai#17425 (7 comments at 02:53Z, 16 today), but it is a live fail-open in this reading. The record makes it visible for the first time: request `objectstack-ai#3` prints its row count, so a `(100 row(s))` on a `per_page=100` request is now readable. ⛔ Not fixed here; the seat files or re-scopes. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from the worktree with no hand-fed path list; re-derived after rebasing onto current `main` (the derivation was STALE-TREE by 4 commits) — **identical command list**. All 34 run at head `f5773ce08`, exit codes captured redirect-then-`$?`: ```text 0 :: node scripts/check-adr-0087-registration.mjs --base origin/main 0 :: node scripts/check-adr-0087-registration.mjs --self-test 0 :: node scripts/check-changeset-no-major.mjs --base origin/main 0 :: node scripts/check-changeset-no-major.mjs --self-test 0 :: node scripts/check-ci-filter-parity.mjs 0 :: node scripts/check-closing-keyword-parity.mjs 0 :: node scripts/check-closing-keyword-parity.mjs --self-test 0 :: node scripts/check-comment-mask-corpus.mjs 0 :: node scripts/check-declaration-mirrors.mjs 0 :: node scripts/check-declaration-mirrors.mjs --self-test 0 :: node scripts/check-scripts-symbol-anchors.mjs 0 :: node scripts/check-scripts-symbol-anchors.mjs --self-test 0 :: node scripts/check-self-test-wired.mjs 0 :: node scripts/check-self-test-wired.mjs --self-test 0 :: node scripts/check-self-test-workflow-commands.mjs 0 :: node scripts/check-self-test-workflow-commands.mjs --self-test 0 :: node scripts/check-whole-set-label-write.mjs 0 :: node scripts/check-whole-set-label-write.mjs --self-test 0 :: node scripts/pm/bare-root-worklist.mjs --self-test 0 :: pnpm check:agent-test-spelling 0 :: pnpm check:bash32-floor 0 :: pnpm check:changeset-gate-self-tests 0 :: pnpm check:cli-command-ids 0 :: pnpm check:cross-package-test-inputs 0 :: pnpm check:driver-memory-census 0 :: pnpm check:entry-guard 0 :: pnpm check:nul-bytes 0 :: pnpm check:parse-guard 0 :: pnpm check:pm-clause2-carriers 0 :: pnpm check:pnpm-filter-targets 0 :: pnpm check:ratchet-remedy-authority 0 :: pnpm check:refd-timer-probe 0 :: pnpm check:watch-hint-literal 0 :: pnpm check:pm-dispatch-gates ``` Reconciled: `dispatch-gates --ran` ⇒ **34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN**. Repo-wide `pnpm lint` (`eslint . --no-inline-config`) at `f5773ce08`: **exit 0**. `grep -naP` for control bytes over the changed file: no hits. ⛔ Outside these 34, as the derivation itself prints: 53 artifact-roster families, 11 wide-population families, 7 pending-changeset families, 1 path-scheduled CI job and the always-runs tail. Their absence here is not a clearance. ## Acceptance notes Out of scope, noted and ⛔ not filed: - The read-path report and the input record now also print on the `--pair-json` **usage refusals** (a missing file, a non-JSON document, a board conflict), because everything past the board resolution moved inside one `try`/`finally`. One extra stderr line on those paths, in the direction the file's own header argues for. Carrier: whoever next edits `main`. - `main`'s `--pair` value is parsed in two places now (once for `run.mode`, once for the pair itself). Both read the same argv through `flagIndex`; a reader may prefer one. Carrier: whoever next edits `main`. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu --- _Generated by [Claude Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17425
Clause-②: yes — flipped by the
domain:specseat, 2026-09-13T02:5xZ. The round declarednoand correctly left the final value to the seat, reporting both limbs separately. The deciding limb is the mechanical one:references/contract-review.md:13— 「新导出符号或已发布载荷上的新键恒yes」. This diff adds three new exported symbols to@objectstack/lint's published barrel (validateRetiredPermissionResidue, theRetiredPermissionResidueFindingtype, andPERMISSION_RETIRED_LIFECYCLE_RESIDUE), verified by the seat from the diff against merge base5741ff10c30, and measured by the round in the built tarball (6 published dist files each). ⇒yes, unconditionally. The round's own reasoning — no schema touched, no accept set moved,packages/specnot in the diff — is the OTHER limb and is accurate; it does not reach this one.needs:contract-reviewhung on both carriers (PR and card) in the same stroke.This is the second, lint half of the card. The parse half landed as #17485 and is not re-opened here: #12840's retired-default residue tolerance stays exactly as ruled,
packages/spec/src/security/permission.zod.tsandshared/retired-key.tsare untouched, and nothing about what parses changes. This implements the director seat's ruling D — the missing signal is delivered where the authored path and the built path ARE distinguishable, which is before the parse.The gap, in the contract's own words
acceptRetiredDefaultResiduestates why its accept is silent, and in the same sentence names the channels that stay loud for authored sources:Read that list against a non-TypeScript author and it is one entry short.
tsc neveris a TypeScript channel. An author usingdefinePermissionSetcannot write the key at all.os migrate metaand the ADR-0087 D2 conversion are the same channel twice — and that conversion,permission-allow-restore-purge-removed, is declaredretiredFromLoadPath: true, so it never runs while a stack loads. Measured:normalizeStackInputover a raw stack carryingallowRestore: falseemits 0 conversion notices and hands the key straight through.So an author who writes the key in a JSON or YAML source and does not run the migration gets a clean parse and no signal at all — which is what a tombstone exists to prevent, and it is exactly the complaint the card was filed for.
Population measurement — taken FIRST, because it gates the severity
The ruling made this the ordering, so it is reported before the choice it gates.
Authored stack sources in this tree carrying the retired keys: ZERO.
The census classified every in-tree carrier structurally rather than by token count (occurrences via
grep -o, nevergrep -cline counts):packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json(75 + 75)packages/spec/src/security/permission.test.ts(44)packages/spec/src/security/permission.zod.ts(26)packages/spec/CHANGELOG.md(18)LIT CONTROL — the census could have found one. The two real authored permission sets in this tree (
examples/app-showcase/src/security/permission-sets.ts,examples/app-crm/src/security/sales-positions.ts) carry 99 and 28 occurrences of live object-permission keys (allowRead/allowCreate/allowEdit/allowDelete/allowTransfer) in exactly theobjects: { NAME: { ... } }shape this rule reads. The probe is aimed at files that really do carry object-permission blocks, and it returns a positive number on them — so the zero for the retired keys is an absence, not a miss. DARK CONTROL: a fabricatedallowTeleportreturns 0 in the same files, same expression.There is also a structural reason the zero is not surprising, and it is worth stating because it bounds the rule's reach: every tracked
objectstack.config.*in this repo declares its metadata in TypeScript code, andobjectstack.jsonin this tree is the built artifact (dist/objectstack.json), not an author's source. The ruling's own warning — that the 181 carriers are fixtures and built artifacts, not sources — holds, and the in-tree source population beneath it is empty.Severity:
warning, and the measurement is what supports iterrorwould be a refusal grade chosen on zero observations.errorwould reverse ruling D by the back door. The parse ACCEPTSallowRestore: false. Anerrorat the authoring door makesos buildrefuse a stack the schema accepts — which is option B's accept-set narrowing, restricted to the CLI, and both feat(spec): retired-defaulted-key tolerance — the retired default parses as inert residue and strips; non-default values keep the loud refusal (#12497 class rule) #12840 and ruling D declined it.warningis the only grade that adds a signal without moving a gate.gatingmeans the rule can emiterrorand therefore must run on all three commands as a publish gate;advisorynever emitserror. This is advisory, andauthoring-rule-wiring.test.tsreads the rule's own source to keep that claim honest.Ruling D named
warningas its expectation and conditioned the final choice on the measurement. The measurement supports it, sowarningit is.And the honest reading of what a zero population means for D itself: today this rule would fire on nothing in this repository. Its reach is authored JSON/YAML sources outside the tree — and the ruling already names the condition under which B re-opens as a new decision card, "AI-generated JSON that never runs lint". A lint rule cannot reach an author who never runs lint. That limit is not closed by this PR and is not claimed to be.
What the rule does
One rule,
validateRetiredPermissionResidue, inpackages/lint/src/validate-retired-permission-residue.ts.input: 'normalized'— thenormalizeStackInputoutput, before any Zod parse. That tier is load-bearing rather than conventional here: the evidence is a key the residue stage removes, so aparsedrule would read a stack that structurally can never carry it.true,'false',0andnullalready land on the tombstone's own refusal with the prescription attached; repeating them here would be a second voice one layer earlier. The surviving enforced lifecycle bit,allowTransfer: false, is not residue and is never named.retiredKey()publishes its guidance as the key's own description; the hint is resolved fromObjectPermissionSchema's shape at call time, so it cannot drift from the parse-time wording the same author sees through the other door. An unresolvable prescription yields no finding rather than a wording this module invented — the posturelintLivenessPropertiestakes to an unreadable ledger, which is why the test carries an anti-vacuity guard.The finding splits the ruling's "message = the retired-key prescription" across the two fields the shared
AuthoringFindingshape already has:messagesays what is wrong (the line is inert and silently stripped),hintis the prescription verbatim. Every other rule in the registry uses the same split, and the prescription reaches the author either way.Registration, and which commands run it
Appended to
AUTHORING_RULESinpackages/lint/src/authoring-rules.ts— the existing table, no new mechanism. That one entry reachesos validate,os buildandos lint(commands: ALL), which is alsoos compile's gate, sincecompile.tsmakes the samerunAuthoringRules('build', ...)call.surfaces: CLI_ONLYwith a writtensurfaceReason: crossing to the runtime publish gate needs a measurement this round did not take — whether that gate'sbodyreaches it BEFORE the per-typesafeParsewhose residue stage strips the only evidence this rule reads. Post-parse the rule is structurally silent, so wiring it there without that reading would publish a phantom check rather than coverage. The rule id constant is re-exported fromsrc/index.ts, perrule-id-barrel-exports.test.ts.Controls and ablation
The test carries paired controls throughout (
packages/lint/src/validate-retired-permission-residue.test.ts, 17 cases):normalizeStackInput; the rule fires once per key with the right path and severity; it reaches an author throughrunAuthoringRuleson all three commands, with the parsed tier deliberately handed a CLEAN stack so a fallback toparsedwould be visible.allowTransfer: false, the surviving ENFORCED lifecycle bit, is the nearest miss in the shape (same family, same object, samefalse) and must never be named; flagging it would tell an author to delete a live grant.Ablation, both legs proven on disk by occurrence count AND
git hash-objectbefore the run, restored against the HEAD blob after it, with atrapon absolute paths:allowTransferBoth legs restored:
git diff HEADempty andhash-objectequal to the HEAD blob, checked rather than inferred from an exit code.Tests and gates
pnpm --filter @objectstack/lint build+pnpm --filter @objectstack/lint test(lock VERDICT command-exit)pnpm --filter @objectstack/lint typecheck(lock VERDICT command-exit)eslint . --no-inline-configover the WHOLE repo populationThe three NOT MEASURED are
check:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debt, each exiting 3 on its own PREREQUISITE NOT MET (they read built output the whole workspace has not produced here). A fourth,check:skill-examples, exited 1 with its own "Build first, then re-run" prerequisite text naming an unbuilt@objectstack/client-reactwhose build fails on its own unbuilt closure — a wrong-reason red, recorded as NOT MEASURED, not as red.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ranaccounts for all 88 with 0 UNRUN. The eslint reading above is not a narrowing: the full population ran.No red anywhere.
Two gates went red on the first sweep and both are fixed in the second commit — they are the mechanical consequences of the change, not incidental cleanups.
check:doc-authoringrefuses an internal tracker id inside customer-facing string prose, so the reference moved from thesurfaceReasonstring to the adjacent comment.check:docs-transcript-driftderives the author-time rule count from the registry and compares it against the transcripts the docs quote: the new entry moves it 44 to 45, so the four pages printing it are refreshed.Changeset — measured, with
distBUILT@objectstack/lintpublishesdistonly.distwas unbuilt at first reading (a realnpm pack --dry-runreturned 3 files: CHANGELOG, README, package.json), so it was built and the measurement retaken rather than argued from the declared tsup entries. Withdistbuilt,npm pack --dry-runreturns 17 files, 14 of them underdist/, and all three new symbols are in the tarball:validateRetiredPermissionResidueandPERMISSION_RETIRED_LIFECYCLE_RESIDUE— 6 published files each, includingdist/index.d.tsanddist/index.d.ctsRetiredPermissionResidueFinding— the 2 declaration fileslintLivenessProperties, reaches 6 files. dark control: a fabricated symbol reaches 0.Published surface moves, so a changeset is owed and present:
.changeset/17425-retired-permission-residue-lint.md, gradedminor(additive; nothing is removed and no existing finding changes shape or severity).Declared overlap
Sibling card #17319's round has an open PR (#17912, awaiting review) that also adds a rule under
packages/lint/src/and edits thesrc/index.tsbarrel. Declared rather than avoided, per this lane's ruled discipline: whoever lands second resolves. The barrel is an export list — on a conflict, merge main and re-add the export block.Also declared: the file face grew past the claim's list. The claim declared
packages/lint/(rule, test, barrel). The diff additionally carries.changeset/17425-retired-permission-residue-lint.mdand fourcontent/docs/pages, the latter because the derived rule count they quote moved. Amending the claim comment is the seat's act, not this round's.Authored by Claude Code in session
session_01MkQhmuuJAVDjmeWNixwDDH.Generated by Claude Code