fix(spec): position.delegatable names only the enforcer that exists (#6628) - #6759
Merged
os-project-manager merged 1 commit intoAug 8, 2026
Merged
Conversation
…#6628) The JSDoc on the authorable `delegatable` key claimed the "never distribute an `adminScope`-carrying set" invariant was "enforced by the `security-delegatable-admin-position` lint rule and the D12 gate". That lint rule was never written — the string occurred exactly once in the repository, in that sentence. `packages/lint/src/validate-security-posture.ts`'s rule table and its twelve exported rule-id constants are the authority, and ADR-0091's other author-time rules (`security-grant-expired-at-authoring`, `security-delegation-missing-reason`) DID land, so the absence is specific to this one rule. The invariant itself is real: plugin-security's delegated-admin gate implements the D12 containment check as step 6 of the self-service delegation path. What was false is WHEN it holds. The sentence promised an author-time gate, so an author pairing `delegatable: true` with an `adminScope`-carrying set believed `os lint` would stop them; it does not, and the mistake surfaces later as a runtime deny in a different package. The JSDoc now names only the D12 gate, locates it at delegation time, and says the failure an author sees is a deny rather than a lint error — while pointing at the one author-time rule ADR-0091 D3 does have, so "no lint rule for this" cannot be misread as "unenforced". A pin (`position-delegatable-enforcer.pin.test.ts`) holds the relation: every `security-*` rule the JSDoc names must be one `packages/lint` actually exports, read off its `src/` the way `rule-id-barrel-exports.test.ts` (#5648) does, plus a self-test proving the predicate has teeth. Reverse-verified: restoring the original sentence turns exactly 2 of its 4 cases red. Text only — `PositionSchema` accepts exactly what it accepted before, and all ten spec generated-artifact gates are up to date with no regeneration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 112 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-project-manager
marked this pull request as ready for review
August 8, 2026 17:27
os-project-manager
deleted the
claude/issue-6628-delegatable-phantom-lint-rule
branch
August 8, 2026 17:44
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6628
What was wrong
The JSDoc on the authorable
delegatablekey closed with:The parenthetical names two enforcers. Only the second one exists.
Premise re-verified on
origin/main@61282f906(the card was last re-anchored ate0f300ba5):security-delegatable-admin-positionoccurs exactly once in the repository — in that sentence (git grep -coverorigin/mainreturnspackages/spec/src/identity/position.zod.ts:1, and no other file).packages/lint/src/validate-security-posture.ts— its rule table (:9-21) and the twelve exported rule-id constants beside it (:59-70). No delegatable/admin-position rule is among them.security-grant-expired-at-authoring(D2) andsecurity-delegation-missing-reason(D3, the same decision asdelegatable) are both present and both exported. The absence is specific to this one rule, not an artefact of the linter skipping ADR-0091.packages/plugins/plugin-security/src/delegated-admin-gate.ts:537-543implements the D12 containment check as step 6 of the self-service delegation path, denying with the offending permission set named.So the invariant holds. What was false is when it holds. The sentence promised an author-time gate, so an author marking a position
delegatable: truewhile it distributes anadminScope-carrying set believedos lintwould stop them before shipping. It does not: the package publishes clean, and the mistake surfaces later, in a different package, as a runtime deny phrased as a fact about the position rather than as a fix for the authoring error.That is the
validate-security-posture.tsheader's own hazard one layer out. It records how alias tolerance "silently downgraded a NAMED rejection into an inert branch — and an inert branch in a security linter reads, to the next author, as a gate that is watching (#4984, #5009, #5017)". A rule that is named but absent reads the same way, and is cheaper to write by accident: prose costs nothing to add and no compiler checks it.The fix — text only
Per the direction ruling on the card, this corrects the text to name only the enforcer that exists; it deliberately does not write the missing lint rule. Whether ADR-0091 D3 should grow an author-time rule is a product decision the finding explicitly declined to make, and it stays open.
The corrected JSDoc keeps three properties the card asked for:
It also points at the one author-time rule ADR-0091 D3 does have (
security-delegation-missing-reason) and says what that one actually checks (a seeded delegation row carries its dual-audit reason), so "no lint rule for this combination" cannot be misread as "this invariant is unenforced" — the opposite lie.Zero acceptance-surface bytes. Every changed line in
position.zod.tsis inside the JSDoc block — mechanically confirmed by filtering the diff to non-comment lines, which returns nothing.PositionSchemaaccepts exactly what it accepted before.The pin
packages/spec/src/identity/position-delegatable-enforcer.pin.test.ts, modelled onexpression-dialect-docs.pin.test.ts(#6085) — the repo's existing "TSDoc prose versus a machine-readable authority" pin shape.No pin asserted the phantom name or the parenthetical (swept: the name's single occurrence was the defect), so this adds one. The authority is never a hand-copied list: it reads the
security-*rule-id constants offpackages/lint/src/the wayrule-id-barrel-exports.test.ts(#5648) reads that directory, so a security rule added in a new file counts the moment it exists. Four cases:security-delegation-missing-reasoncontrol the finding used);packages/lintdoes not declare;Two deliberate choices, both documented in the file:
security-delegatable-admin-position. Asserting that specific name is unbacked would quietly make this test the thing that breaks the day someone implements the rule — the open product decision. What needs pinning is the predicate, not what an unwritten rule would be called.security-enterpriseappears backticked in two spec files and is not a rule.Scope kept narrow, deliberately. A repo-wide sweep was measured rather than assumed: the prose construct "backticked slug +
lint rule" has exactly one instance repo-wide, and this PR removes it. A new repo-wide gate would therefore guard a class with zero remaining instances, at the cost of a CI step and a false-positive surface over other agents' in-flight prose. The narrow pin on the actual surface is the better trade; if the class ever recurs elsewhere, the generalisation is a separate card.Reverse verification — direction predicted before running
Predicted red, and specifically 2 of 4 cases: restoring the original sentence should fail the unbacked-rule-id assertion (the phantom is not in lint's exported set) and the runtime-location assertion (the old block says "D12" but never "runtime"), while the rule-table floor and the self-test are independent of the file and must stay green.
Measured, after
git checkout origin/main -- packages/spec/src/identity/position.zod.ts:Exactly as predicted, including which two.
Mechanism assumptions — one confirmed, one falsified
Falsified: no regeneration was needed. The dispatch expected an authorable key's JSDoc to reach the generated reference page, making some regen plausible. Measured, it does not.
content/docs/references/identity/position.mdx:67carries the.describe()string only — which this PR does not touch — and the JSDoc block reaches neither the.d.tsnor the runtime.js, only the sourcemaps. After a fullpnpm --filter @objectstack/spec build,check:generatedreports all ten artifacts up to date with nothing to regenerate. (Same "cuts both ways" shape as part 1 of #6630 / PR #6701.)Confirmed: the changeset is right, though for a different reason than hover.
packages/spec's publishedfilesincludes"src/**/*.zod.ts", soposition.zod.tsitself ships to npm — the corrected prose reaches consumers and AI authors reading the installed schema source. That is a published author surface, so@objectstack/spec: patch. Noskip-changesetroute is needed here.Verification
All run in this worktree off
origin/main@61282f906:pnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec typechecktsc --noEmit+check:scripts-typecheck+check:test-typecheck; the new pin compiles clean, no debt-ledger entry)pnpm --filter @objectstack/spec check:generatedpnpm lint(the required ESLint job)pnpm check:nul-bytescheck:adr-anchors/check:role-word/check:doc-authoring/check:published-files/check:spec-parsed-alias/check:quick-reference-countsByte discipline: the diff was self-scanned beyond the gate with
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'on both changed files — clean.content/docs/releases/untouched.🤖 Generated with Claude Code
https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk
Generated by Claude Code