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

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions