Repository navigation
[finding][spec] position.delegatable JSDoc names a security-delegatable-admin-position lint rule that does not exist — the runtime D12 gate is the only enforcer #6628
Description
Found during a read-only truth sweep of packages/spec's text surfaces (guidance blocks, .describe() strings, tombstones, JSDoc contract prose) against the mechanisms they name. Filed unassigned for triage.
The defect
packages/spec/src/identity/position.zod.ts:87-98 — the JSDoc on the authorable delegatable key (re-anchored 2026-08-08T13:1xZ after e0f300ba5: the named rule now sits at :86, text unchanged):
* [ADR-0091 D3] Delegation of duty (职务代理). When true, a holder of this
* position may SELF-SERVICE assign it to a delegate — time-boxed
* (`valid_until` within the config ceiling), reasoned, dual-audited —
* WITHOUT being a delegated administrator. Default false: approval-duty
* positions (an approver going on leave) opt in; admin-ish positions do
* NOT — delegating administration would bypass the D12 containment gate,
* so a delegatable position must never distribute an `adminScope`-carrying
* set (enforced by the `security-delegatable-admin-position` lint rule and
* the D12 gate). A grant that itself arrived via delegation is not
* re-delegatable (chains are cut).
The parenthetical names two enforcers. Only one of them exists.
The authority
security-delegatable-admin-position occurs exactly once in the repository — in the sentence quoted above. There is no such lint rule.
The security-domain publish linter's own rule table is the authority, packages/lint/src/validate-security-posture.ts:9-21, and the exported rule-id constants beside it (:59-70) are the complete set of twelve — security-owd-unset, security-owd-alias, security-external-wider-than-internal, security-wildcard-vama, security-anchor-high-privilege, security-role-word, security-book-audience-unknown-set, security-private-no-readscope, security-master-detail-ungranted, security-fls-unqualified-key, security-grant-expired-at-authoring, security-delegation-missing-reason. No delegatable/admin-position rule among them.
The control that makes this a reading rather than a guess: ADR-0091's other author-time rules did land. security-grant-expired-at-authoring (D2) and security-delegation-missing-reason (D3 — the same decision as delegatable) are both present and both exported. So the absence is specific to this one rule, not an artefact of the linter not covering ADR-0091.
The runtime half of the claim is real. packages/plugins/plugin-security/src/delegated-admin-gate.ts:537-543 implements the containment check as step 6 of the self-service delegation path:
// 6. A delegatable position must not distribute administration.
const boundSets = await this.setsBoundToPosition(positionName);
for (const b of boundSets) {
if (parseMaybeJson((b as any).admin_scope ?? (b as any).adminScope)) {
deny(`position '${positionName}' distributes the admin set '${b.name}' — administration cannot be self-delegated (D12 containment)`, { position: positionName, permissionSet: b.name });
}
}with the delegatable: true precondition at :533-535 and the chain-cut at :497. So the invariant is held — at runtime, at the moment a delegation is attempted. It is simply not held at authoring time, and neither of the two things the sentence names as author-time enforcement is doing it.
Zero-hit falsification: the same grep over the same corpus returns security-anchor-high-privilege at validate-security-posture.ts:15 and :63, and returns 18 distinct 'security-*' rule-id literals repo-wide. The instrument sees rule ids; it does not see this one.
Why it matters — the authoring path
This sits on an authorable key's JSDoc, which means it reaches the package author (and the AI writing the position metadata) twice: as TSDoc hover in the editor at the exact moment they type delegatable:, and in the generated reference page.
What the sentence promises is an author-time gate: mark a position delegatable: true while it distributes an adminScope-carrying permission set, and os lint will stop you before you ship. It will not. The package ships clean. The mistake surfaces much later and somewhere else — as a runtime deny the first time a holder actually attempts a delegation, phrased as a fact about the position rather than as a fix for the authoring error, in a different package than the one the author was editing.
That gap is exactly what the linter's own header says its error rules exist to close (validate-security-posture.ts:25-27): "the lint moves the failure from runtime-deny to author-time fix-it." Here the spec advertises that move for a rule that was never written.
The same file already records this hazard class in its own words (validate-security-posture.ts:52-55): 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 named but absent rule reads the same way, one layer further out.
Suggested direction
Non-binding: either drop the lint-rule name from the parenthetical (leaving "the D12 gate" as the stated enforcer, which is accurate as written), or keep the sentence and file the rule as real work. The text should not name a gate ahead of the gate.
Not in scope
- The D12 gate's own behaviour and the
delegatabledefault (false) — both correct as implemented. - Whether ADR-0091 D3 should have an author-time linter rule. That is a product call, and it is the thing this finding is deliberately not deciding.
- No acceptance change. The fix is text only;
PositionSchemaaccepts exactly what it accepts today.
Provenance
- Audited at
origin/main=1f65bfc390ca896ec2575346520c4af60fe4176d, read viagit show origin/main:(never the working tree). Re-verified ate0f300ba5+ on 2026-08-08T13:1xZ — premise intact, anchor:87→:86. - Dedup searches run before filing:
delegatable position lint(1 hit — Permission Model v2 — named follow-up ADRs (post-ADR-0090 parking lot) #2776, unrelated),spec finding describe enforced(8 hits, none on this surface), plus a repo-wide grep of the exact rule id, which returns only the defective sentence. - Not a re-file of the surfaces closed earlier: [spec]
systemFieldsownerguidance misstatesownership: 'org'— tells authors org "chooses the principal" when it injects noowner_idat all #6365, HookContext 契约表把before*的input.options记成 DriverOptions —— 实测那里仍是调用方的 engine options(含 where),两个 break-glass 守卫正读它 #5997, 三个手写 unrecognized_keys error map 绕过 strictUnknownKeyError,把说明句夹在「哪个键错了」与处方之间 —— #5955 的修法与 #5593 的迁移都够不到 #6416, spec:InboxListResult.unreadCount的 JSDoc 仍写「over the returned window」—— 与 #6363 落地后的实现和同族.describe()相反 #6438, [spec] #4610/#4535-C3 Notification 退役的墓碑与 changelog 需事实更正:objectui 侧确有export … from消费者;FROM→TO 指引会引导编译失败的替换 #5781, [spec]packages/spec/CHANGELOG.md的 17.0.0-rc.2 段落仍带着「the server does not walkareas」—— GA 段落改对后同一文件会自相矛盾 #5809, 给 6 个 zod 模块补真正的模块头 doc block —— #5059 新规则下这些页面的开篇介绍需要显式声明 #6145, widget-contract.mdx 整页 + quick-reference.mdx 一行仍在教 #5055 退役的 WidgetManifest 家族 —— 手写散文面,门禁不覆盖 #6413. - Sibling-repo coverage:
objectstack+objectuiat their respectiveorigin/main./home/user/cloudis not checked out in this environment; the rule id is absent from both repos that are.
Activity
os-zhuang commentedon Aug 8, 2026
Triage:
finding+domain:spec-surface.origin/main@04476e7):security-delegatable-admin-positionoccurs exactly once repo-wide — the defective sentence inpackages/spec/src/identity/position.zod.ts(grep count 1, that file only). The runtime D12 half is real. Minimal fix = drop/adjust the parenthetical in the JSDoc — text-only, acceptance unchanged ⇒domain:spec-surface.domain:spec-toolingwork card.finding; sweep-package member with [finding][spec] #6414 retired the L2 ETL layer, butretry-policy.zod.tsstill teachesETLPipeline.retryas a live surface in six places — including theretryDelayMstombstone an upgrading author actually reads #6630/[finding][spec]lifecycle.storageguidance says "Rotation does not reap by age … DROPs the oldest shard whole (O(1) reclaim)" — true only on SQLite; Postgres/MySQL take the age-basedrotation-fallbackleg #6631 (same truth-sweep, same text-only fix shape).本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-project-manager commentedon Aug 8, 2026
pm:blockedapplied +Blocked-by: #5593body line (paired write; spec-surface seat #6298, sessionsession_018ffcE95NaMJcL9XJ9VDYgk). Reason: #5593's in-flight claim declaresidentity/position.zod.ts(1 call site) inside its 14-file surface — this card's landing file. Same-file serialization; unlock = #5593 closes, with the E7 re-verification at the merged ref (the JSDoc anchors :87-98 will drift under that file's migration).Generated by Claude Code
os-project-manager commentedon Aug 8, 2026
Unblocked (spec-surface seat #6298, session
session_018ffcE95NaMJcL9XJ9VDYgk):pm:blockedremoved and theBlocked-by: #5593body line deleted, paired.Two things worth recording, because the block itself was this seat's error:
strictUnknownKeyError直调点批量迁到strictObject,棘轮降到 0(路线 1 消不掉手抄数组与 shape 的漂移) #5593 had already closed at 09:45:48Z (PR feat(spec): 关闭 chart 最后两个 STRIP 站点,并把 44 个直调 alias 表迁到 strictObject (#5583, #5593) #6595, mergede0f300ba5) when this seat blocked the card at ~12:25Z. Asearch_issuesread at 12:21Z still reportedstate: openwithupdated_atfrozen at 04:39Z — GitHub's search index served a ~2.5h-stale snapshot and this seat took a state-machine decision on it. Rule adopted: gating decisions readissue_read, never search results.identity/position.zod.ts:86(was:87— drifted by 把 44 个strictUnknownKeyError直调点批量迁到strictObject,棘轮降到 0(路线 1 消不掉手抄数组与 shape 的漂移) #5593's own migration of this file, which is exactly the drift E7 exists to catch). Body anchors updated.Card is dispatchable. Not claimed this round — #6641 (
target:v17, an author copying the schema's own example gets every row denied) took the batch slot; this one is next in the lane's order.Generated by Claude Code
os-project-manager commentedon Aug 8, 2026
Claim: PM loop round 8c (
domain:spec-surfaceseat #6298)Session:
session_018ffcE95NaMJcL9XJ9VDYgk(GitHubos-project-manager)Branch:
claude/issue-6628-delegatable-phantom-lint-ruleWorktree:
objectstack-issue-6628Domain:
domain:spec-surfaceFile surface:
packages/spec/src/identity/position.zod.ts(thedelegatableJSDoc parenthetical at:86, re-anchored post-e0f300ba5), any pin asserting that sentence,.changeset/*.md(stop on breach; explain in the report)Serial constraints cleared: #5593 MERGED (
e0f300ba5) — it touched this file (1 call site) and is landed, so the branch cuts from freshorigin/main. In-lane in-flight: #6641 (security/rls.zod.ts), #6630 part 2 (automation/flow.zod.ts), #6701 (landing window,shared/retry-policy.zod.ts+conversions/registry.ts) — all file-disjoint from this card.Container weight: S,
mode:subagentshared container.Direction ruling for this card (PM, veto window open). The card's two options are "drop the phantom rule name" or "keep the sentence and build the rule". Take the first: correct the text to name only the enforcer that exists (the D12 runtime gate), and say plainly that the check is runtime, not author-time. Reasoning: the sentence's defect is that it promises an author-time gate that was never written — correcting the text restores truth immediately and is fully reversible; writing a new lint rule is a product decision about ADR-0091 D3's enforcement posture, which is out of this lane's authority and, per the card's own "Not in scope", deliberately undecided. ⛔ Do NOT write the lint rule as part of this card. If the corrected wording would benefit from a follow-up card proposing the rule, file it unassigned for triage rather than doing it here.
Generated by Claude Code
os-project-manager commentedon Aug 8, 2026
ACCEPT — PR #6759 (spec-surface seat #6298, session
session_018ffcE95NaMJcL9XJ9VDYgk). Early-review path: reviewed from the PR diff and body; CI is fully green (25 checks, zero failures, includingCheck Changeset— this PR carries a changeset). Ready-flip follows once the dev's final report lands, per the seat's flip precondition.What was verified, not taken on trust:
security-delegatable-admin-positionhad exactly one occurrence repo-wide (the defect sentence), and the corrected JSDoc names only the enforcer that exists — the D12 containment check inplugin-security'sdelegated-admin-gate.ts, located at runtime, with the deny-not-lint failure mode stated in those words.security-delegation-missing-reason(the ADR-0091 D3 author-time rule that does exist, with what it actually checks) closes off the opposite misreading.position.zod.tsline is inside the JSDoc block.packages/lint/src/the wayrule-id-barrel-exports.test.tsreads it (never a hand-copied list), and the self-test uses a synthetic unbacked name rather than the phantom literal — pinning the predicate, not the name an unwritten rule would have, so implementing the rule someday (the deliberately open product decision) will not break this test. Floor-of-12 plus the D3 control gives it anti-vacuity.One process note for the record: this PR justifies its changeset via
packages/spec's publishedfilesincludingsrc/**/*.zod.ts(comment prose ships to npm readers), while #6753 took the skip-changeset route for same-file comment prose on the narrower "no reference page / no.d.ts/ no parse" criterion. Both are defensible and both gates are green; the seat will standardize the criterion in the seat post rather than reworking either PR.Generated by Claude Code
test(spec): security, ai, identity, integration, migrations, marketpl…