Skip to content

[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

@os-project-manager

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 delegatable default (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; PositionSchema accepts exactly what it accepts today.

Provenance

Activity

  1. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Triage: finding + domain:spec-surface.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    CollaboratorAuthor

    pm:blocked applied + Blocked-by: #5593 body line (paired write; spec-surface seat #6298, session session_018ffcE95NaMJcL9XJ9VDYgk). Reason: #5593's in-flight claim declares identity/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

  3. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    CollaboratorAuthor

    Unblocked (spec-surface seat #6298, session session_018ffcE95NaMJcL9XJ9VDYgk): pm:blocked removed and the Blocked-by: #5593 body line deleted, paired.

    Two things worth recording, because the block itself was this seat's error:

    1. 把 44 个 strictUnknownKeyError 直调点批量迁到 strictObject,棘轮降到 0(路线 1 消不掉手抄数组与 shape 的漂移) #5593 had already closed at 09:45:48Z (PR feat(spec): 关闭 chart 最后两个 STRIP 站点,并把 44 个直调 alias 表迁到 strictObject (#5583, #5593) #6595, merged e0f300ba5) when this seat blocked the card at ~12:25Z. A search_issues read at 12:21Z still reported state: open with updated_at frozen 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 read issue_read, never search results.
    2. E7 re-verification at the merged ref passed: the phantom rule name is still present, verbatim, now at 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

  4. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    CollaboratorAuthor

    Claim: PM loop round 8c (domain:spec-surface seat #6298)
    Session: session_018ffcE95NaMJcL9XJ9VDYgk (GitHub os-project-manager)
    Branch: claude/issue-6628-delegatable-phantom-lint-rule
    Worktree: objectstack-issue-6628
    Domain: domain:spec-surface
    File surface: packages/spec/src/identity/position.zod.ts (the delegatable JSDoc 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 fresh origin/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:subagent shared 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

  5. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    CollaboratorAuthor

    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, including Check 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:

    • The phantom is gone and nothing else claims it: security-delegatable-admin-position had exactly one occurrence repo-wide (the defect sentence), and the corrected JSDoc names only the enforcer that exists — the D12 containment check in plugin-security's delegated-admin-gate.ts, located at runtime, with the deny-not-lint failure mode stated in those words.
    • The card's three required properties survive: invariant real and enforced / enforcement at delegation time not publish time / the failure an author sees is a deny. The pointer to security-delegation-missing-reason (the ADR-0091 D3 author-time rule that does exist, with what it actually checks) closes off the opposite misreading.
    • Acceptance surface untouched — every changed position.zod.ts line is inside the JSDoc block.
    • The pin's design is the right one: the authority is read off packages/lint/src/ the way rule-id-barrel-exports.test.ts reads 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.
    • Reverse verification predicted red 2-of-4 and hit exactly those two (unbacked-name assertion + runtime-location assertion), with the file-independent cases staying green as predicted.
    • Scope kept narrow on measurement: the "backticked slug + lint rule" prose construct has zero remaining instances repo-wide after this fix, so a repo-wide gate would guard an empty class — the narrow pin is the better trade, generalisation deferred if the class recurs.

    One process note for the record: this PR justifies its changeset via packages/spec's published files including src/**/*.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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions