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
|
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