Skip to content

feat(lint): name the retired allowRestore/allowPurge residue at the authoring door - #17917

Merged
os-bill merged 3 commits into
mainfrom
claude/issue-17425-retired-permission-residue-lint
Sep 13, 2026
Merged

feat(lint): name the retired allowRestore/allowPurge residue at the authoring door#17917
os-bill merged 3 commits into
mainfrom
claude/issue-17425-retired-permission-residue-lint

Conversation

@os-bill

@os-bill os-bill commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17425

Clause-②: yes — flipped by the domain:spec seat, 2026-09-13T02:5xZ. The round declared no and 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, the RetiredPermissionResidueFinding type, and PERMISSION_RETIRED_LIFECYCLE_RESIDUE), verified by the seat from the diff against merge base 5741ff10c30, 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/spec not in the diff — is the OTHER limb and is accurate; it does not reach this one. needs:contract-review hung 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.ts and shared/retired-key.ts are 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

acceptRetiredDefaultResidue states why its accept is silent, and in the same sentence names the channels that stay loud for authored sources:

the strip is deliberately SILENT — real artifacts carry the residue once per permission entry, and a per-occurrence notice would be a 75-line storm that teaches operators to skim; the loud channels for authored sources (tsc never, os migrate meta, the D2 conversion) are unchanged.

Read that list against a non-TypeScript author and it is one entry short.

  • tsc never is a TypeScript channel. An author using definePermissionSet cannot write the key at all.
  • os migrate meta and the ADR-0087 D2 conversion are the same channel twice — and that conversion, permission-allow-restore-purge-removed, is declared retiredFromLoadPath: true, so it never runs while a stack loads. Measured: normalizeStackInput over a raw stack carrying allowRestore: false emits 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, never grep -c line counts):

class occurrences largest carrier
built artifact / fixture 150 packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json (75 + 75)
tests 123 packages/spec/src/security/permission.test.ts (44)
spec / runtime machinery that NAMES the keys 88 packages/spec/src/security/permission.zod.ts (26)
docs and changelog prose 99 packages/spec/CHANGELOG.md (18)
changesets 4
authored stack source 0 none

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 the objects: { 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 fabricated allowTeleport returns 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, and objectstack.json in 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 it

  1. A zero population is not an evidence base for a gate. There is no measured false-positive budget to spend and no in-tree carrier to prove the rule would refuse the right thing. error would be a refusal grade chosen on zero observations.
  2. error would reverse ruling D by the back door. The parse ACCEPTS allowRestore: false. An error at the authoring door makes os build refuse 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. warning is the only grade that adds a signal without moving a gate.
  3. The registry's own tier rule agrees. gating means the rule can emit error and therefore must run on all three commands as a publish gate; advisory never emits error. This is advisory, and authoring-rule-wiring.test.ts reads the rule's own source to keep that claim honest.

Ruling D named warning as its expectation and conditioned the final choice on the measurement. The measurement supports it, so warning it 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, in packages/lint/src/validate-retired-permission-residue.ts.

  • Reads raw source, input: 'normalized' — the normalizeStackInput output, before any Zod parse. That tier is load-bearing rather than conventional here: the evidence is a key the residue stage removes, so a parsed rule would read a stack that structurally can never carry it.
  • Fires on the captured residue value and nothing else. true, 'false', 0 and null already 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.
  • Carries the prescription, read rather than retyped. retiredKey() publishes its guidance as the key's own description; the hint is resolved from ObjectPermissionSchema'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 posture lintLivenessProperties takes 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 AuthoringFinding shape already has: message says what is wrong (the line is inert and silently stripped), hint is 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_RULES in packages/lint/src/authoring-rules.ts — the existing table, no new mechanism. That one entry reaches os validate, os build and os lint (commands: ALL), which is also os compile's gate, since compile.ts makes the same runAuthoringRules('build', ...) call. surfaces: CLI_ONLY with a written surfaceReason: crossing to the runtime publish gate needs a measurement this round did not take — whether that gate's body reaches it BEFORE the per-type safeParse whose 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 from src/index.ts, per rule-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):

  • LIT — the residue survives normalizeStackInput; the rule fires once per key with the right path and severity; it reaches an author through runAuthoringRules on all three commands, with the parsed tier deliberately handed a CLEAN stack so a fallback to parsed would be visible.
  • DARK — a clean permission set, a fabricated key, and every non-residue value earn nothing; the same runner is silent on a clean stack; malformed input never throws.
  • COST DIRECTIONallowTransfer: false, the surviving ENFORCED lifecycle bit, is the nearest miss in the shape (same family, same object, same false) 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-object before the run, restored against the HEAD blob after it, with a trap on absolute paths:

leg mutation verdict
guard removed the residue detection short-circuits vitest exit 1 — 3 failed / 14 passed, the LIT cases
cost direction widened to also match allowTransfer vitest exit 1 — 1 failed, exactly the COST DIRECTION case

Both legs restored: git diff HEAD empty and hash-object equal to the HEAD blob, checked rather than inferred from an exit code.

Tests and gates

run exit
pnpm --filter @objectstack/lint build + pnpm --filter @objectstack/lint test (lock VERDICT command-exit) 0 — 102 files, 3766 tests, 0 skipped
pnpm --filter @objectstack/lint typecheck (lock VERDICT command-exit) 0
eslint . --no-inline-config over the WHOLE repo population 0 — 6685 files, 0 errors, 0 warnings
88 derived gate families, run individually 85 exit 0, 3 NOT MEASURED

The three NOT MEASURED are check:dual-build-cjs-loads, check:lean-entry-closure and check: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-react whose 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 --ran accounts 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-authoring refuses an internal tracker id inside customer-facing string prose, so the reference moved from the surfaceReason string to the adjacent comment. check:docs-transcript-drift derives 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 dist BUILT

@objectstack/lint publishes dist only. dist was unbuilt at first reading (a real npm pack --dry-run returned 3 files: CHANGELOG, README, package.json), so it was built and the measurement retaken rather than argued from the declared tsup entries. With dist built, npm pack --dry-run returns 17 files, 14 of them under dist/, and all three new symbols are in the tarball:

  • validateRetiredPermissionResidue and PERMISSION_RETIRED_LIFECYCLE_RESIDUE — 6 published files each, including dist/index.d.ts and dist/index.d.cts
  • RetiredPermissionResidueFinding — the 2 declaration files
  • lit control: an already-published symbol, lintLivenessProperties, 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, graded minor (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 the src/index.ts barrel. 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.md and four content/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

…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
…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 12 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object))
  • content/docs/permissions/permission-metadata.mdx (via allowPurge (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE), allowRestore (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE))
  • content/docs/permissions/permission-sets.mdx (via allowPurge (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE), allowRestore (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE))
  • content/docs/permissions/permissions-matrix.mdx (via allowPurge (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE), allowRestore (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE))
  • content/docs/protocol/objectql/security.mdx (via allowPurge (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE), allowRestore (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE))

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

  • content/docs/releases/v12.mdx (via allowPurge (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE), allowRestore (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE))
  • content/docs/releases/v17/17-3.mdx (via allowPurge (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE), allowRestore (literal, a string literal in RETIRED_LIFECYCLE_RESIDUE))

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
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 4 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 5741ff10c3068a84e9099d3a3eb3b533054bbc50packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8ec7a6013530e3012bce229d6d3627ef158c7a8c — the merge of head d7d22bf4bebc4f0065b932556b07a9330d7822b2 into base 5741ff10c3068a84e9099d3a3eb3b533054bbc50, 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 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

⚠️ 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 5741ff10c3068a84e9099d3a3eb3b533054bbc50 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-bill
os-bill marked this pull request as ready for review September 13, 2026 08:13
@os-bill
os-bill added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 6ec467b Sep 13, 2026
58 checks passed
@os-bill
os-bill deleted the claude/issue-17425-retired-permission-residue-lint branch September 13, 2026 09:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants