Skip to content

Commit e15e679

Browse files
fix(spec): position.delegatable names only the enforcer that exists (#6628) (#6759)
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. Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk Co-authored-by: Claude <noreply@anthropic.com>
1 parent 32cb5ad commit e15e679

3 files changed

Lines changed: 207 additions & 3 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): `position.delegatable` no longer names a lint rule that was never written (#6628)
6+
7+
The JSDoc on the authorable `delegatable` key closed with
8+
9+
> so a delegatable position must never distribute an `adminScope`-carrying set
10+
> (enforced by the `security-delegatable-admin-position` lint rule and the D12
11+
> gate).
12+
13+
Only the second of those two enforcers exists. `security-delegatable-admin-position`
14+
occurred **exactly once in the repository** — in that sentence. The security-domain
15+
publish linter's rule table (`packages/lint/src/validate-security-posture.ts`) and its
16+
twelve exported rule-id constants are the authority, and no delegatable/admin-position
17+
rule is among them. The control that makes this a reading rather than a guess:
18+
ADR-0091's *other* author-time rules did land — `security-grant-expired-at-authoring`
19+
(D2) and `security-delegation-missing-reason` (D3, the same decision as `delegatable`)
20+
are both present and both exported — so the absence is specific to this one rule, not
21+
an artefact of the linter skipping ADR-0091.
22+
23+
The invariant itself is real and is enforced: `plugin-security`'s delegated-admin gate
24+
implements the D12 containment check as step 6 of the self-service delegation path.
25+
What was false is **when** it holds. The sentence promised an *author-time* gate, so an
26+
author pairing `delegatable: true` with an `adminScope`-carrying permission set believed
27+
`os lint` would stop them before shipping. It does not — the package publishes clean and
28+
the mistake surfaces later, in a different package, as a runtime deny phrased as a fact
29+
about the position rather than as a fix for the authoring error.
30+
31+
The JSDoc now names only the enforcer that exists and says plainly where it runs: the
32+
D12 gate refuses the delegation at the moment a holder attempts it, denying with the
33+
offending permission set named, so the failure an author will see is a delegation deny
34+
at first use rather than a lint error. It also points at the one author-time rule
35+
ADR-0091 D3 *does* have (`security-delegation-missing-reason`) and says what that one
36+
actually checks, so "no lint rule for this" cannot be misread as "this invariant is
37+
unenforced".
38+
39+
This is text only — a comment inside `position.zod.ts`, which `packages/spec` publishes
40+
to npm via its `src/**/*.zod.ts` files entry, so the corrected prose reaches consumers
41+
and AI authors reading the installed schema source. **`PositionSchema` accepts exactly
42+
what it accepted before**; no key, default, or acceptance behaviour changed, and no
43+
generated artifact moved.
44+
45+
Whether ADR-0091 D3 *should* grow an author-time rule for this combination is a separate
46+
product decision and is deliberately not made here.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#6628] The `delegatable` JSDoc may name a lint rule only if that rule exists.
5+
*
6+
* That JSDoc is an authoring surface, not a comment: it is the TSDoc an author
7+
* (often an AI author, ADR-0033) hovers at the exact moment they type
8+
* `delegatable:`. For a whole major it closed with
9+
*
10+
* "(enforced by the `security-delegatable-admin-position` lint rule and the
11+
* D12 gate)"
12+
*
13+
* and the first of those two enforcers had never been written — the string
14+
* occurred exactly once in the repository, in that sentence. The runtime half
15+
* was real (`plugin-security`'s delegated-admin gate, step 6), so the invariant
16+
* held; what was false was WHEN it holds. The sentence promised an author-time
17+
* gate, so an author pairing `delegatable: true` with an `adminScope`-carrying
18+
* set believed `os lint` would stop them. It does not: the package publishes
19+
* clean and the mistake surfaces later, in a different package, as a runtime
20+
* deny phrased as a fact about the position rather than as a fix for the
21+
* authoring error.
22+
*
23+
* This is the `validate-security-posture.ts` header's own hazard one layer out.
24+
* That file records how alias tolerance "silently downgraded a NAMED rejection
25+
* into an inert branch — and an inert branch in a security linter reads, to the
26+
* next author, as a gate that is watching (#4984, #5009, #5017)". A rule that is
27+
* named but absent reads the same way, and is cheaper to write by accident:
28+
* prose costs nothing to add and no compiler checks it.
29+
*
30+
* So the authority here is machine-readable, never a hand-copied list — the
31+
* rule-id constants `packages/lint` actually exports, read off its `src/`
32+
* directory the way `rule-id-barrel-exports.test.ts` (#5648) reads it. A gate
33+
* name the rule table does not back turns this red.
34+
*
35+
* ⛔ Scope: the relation, not the wording. Rewording this JSDoc freely is fine —
36+
* what it may not do is name a `security-*` rule that no rule file declares, or
37+
* stop locating the containment check at runtime. The second half matters
38+
* because "no author-time rule" is only safe to say next to "the D12 gate does
39+
* enforce this, at delegation time"; drop that and the text overcorrects into
40+
* implying the invariant is unenforced, which is the opposite lie.
41+
*
42+
* Deliberately NOT asserted: that no author-time rule exists. Whether ADR-0091
43+
* D3 should grow one is a product decision (the finding left it open); if that
44+
* rule is ever written, this pin stays green the moment the JSDoc names it,
45+
* because the name will resolve against the same rule table.
46+
*/
47+
48+
import { readFileSync, readdirSync } from 'node:fs';
49+
import { dirname, join, resolve } from 'node:path';
50+
import { fileURLToPath } from 'node:url';
51+
52+
import { describe, it, expect } from 'vitest';
53+
54+
const HERE = dirname(fileURLToPath(import.meta.url));
55+
/** …/packages/spec/src/identity → repo root */
56+
const REPO_ROOT = resolve(HERE, '../../../..');
57+
const LINT_SRC = join(REPO_ROOT, 'packages', 'lint', 'src');
58+
const POSITION_SOURCE = join(HERE, 'position.zod.ts');
59+
60+
/** `export const NAME = 'security-…';` — how every security rule id is declared. */
61+
const EXPORTED_SECURITY_RULE_ID = /^export const [A-Z][A-Z0-9_]* = '(security-[a-z0-9-]+)';\s*$/;
62+
63+
/**
64+
* Every `security-*` rule id `packages/lint` declares, found by reading its
65+
* `src/` directory rather than by naming files. A new security rule in a new
66+
* file is therefore authoritative the moment it exists — the property a
67+
* hand-maintained list here would quietly lose.
68+
*/
69+
function declaredSecurityRuleIds(): Set<string> {
70+
const ids = new Set<string>();
71+
for (const file of readdirSync(LINT_SRC)) {
72+
if (!file.endsWith('.ts') || file.endsWith('.test.ts')) continue;
73+
for (const line of readFileSync(join(LINT_SRC, file), 'utf8').split('\n')) {
74+
const m = EXPORTED_SECURITY_RULE_ID.exec(line);
75+
if (m) ids.add(m[1]);
76+
}
77+
}
78+
return ids;
79+
}
80+
81+
/** The JSDoc block attached to the authorable `delegatable` key. */
82+
function delegatableDoc(): string {
83+
const source = readFileSync(POSITION_SOURCE, 'utf8');
84+
const key = source.indexOf('delegatable: z.boolean()');
85+
expect(key, 'the `delegatable` key declaration moved — re-anchor this pin').toBeGreaterThan(-1);
86+
const open = source.lastIndexOf('/**', key);
87+
const close = source.indexOf('*/', open);
88+
expect(open, 'no JSDoc block precedes `delegatable`').toBeGreaterThan(-1);
89+
expect(close, 'unterminated JSDoc block').toBeLessThan(key);
90+
return source.slice(open, close + 2);
91+
}
92+
93+
/**
94+
* The `security-*` rule ids a piece of prose names, minus the ones the rule
95+
* table backs. Rule ids are matched by their backticked, multi-segment slug
96+
* shape: `security-owd-alias` is a rule id, while the cloud product name
97+
* `security-enterprise` (one segment, and never called a rule) is prose.
98+
*/
99+
function unbackedRuleIds(prose: string, backed: Set<string>): string[] {
100+
const named = [...prose.matchAll(/`(security-[a-z0-9]+(?:-[a-z0-9]+)+)`/g)].map((m) => m[1]);
101+
return [...new Set(named.filter((id) => !backed.has(id)))];
102+
}
103+
104+
describe('`delegatable` JSDoc names only enforcers that exist (#6628)', () => {
105+
it('reads a real rule table off `packages/lint`', () => {
106+
const ids = declaredSecurityRuleIds();
107+
// A floor, not an exact count — new security rules are expected. Its only
108+
// job is to fail loudly if the extraction above stops finding anything,
109+
// which would turn the self-test below vacuously green.
110+
expect(ids.size).toBeGreaterThanOrEqual(12);
111+
// The control the finding itself used: an ADR-0091 D3 author-time rule that
112+
// DID land, proving the absence of the phantom was specific to that one
113+
// rule and not an artefact of the linter skipping ADR-0091.
114+
expect([...ids]).toContain('security-delegation-missing-reason');
115+
});
116+
117+
it('would reject a gate name the rule table does not back (self-test)', () => {
118+
// The historical sentence's shape, proving the predicate has teeth
119+
// regardless of what the JSDoc currently says — without this, "no unbacked
120+
// ids" below could pass simply because the prose stopped naming rules.
121+
//
122+
// The rule name here is deliberately SYNTHETIC rather than #6628's literal
123+
// `security-delegatable-admin-position`. Whether ADR-0091 D3 should grow
124+
// that author-time rule is an open product decision the finding declined to
125+
// make; asserting its name is unbacked would quietly make this test the
126+
// thing that breaks when someone implements it. What needs pinning is the
127+
// predicate, not what any one unwritten rule would be called.
128+
const before =
129+
'so a delegatable position must never distribute an `adminScope`-carrying ' +
130+
'set (enforced by the `security-no-such-rule-exists` lint rule and the D12 gate).';
131+
expect(unbackedRuleIds(before, declaredSecurityRuleIds())).toEqual([
132+
'security-no-such-rule-exists',
133+
]);
134+
});
135+
136+
it('names no rule that `packages/lint` does not declare', () => {
137+
expect(unbackedRuleIds(delegatableDoc(), declaredSecurityRuleIds())).toEqual([]);
138+
});
139+
140+
it('still locates the D12 containment check at runtime', () => {
141+
const doc = delegatableDoc();
142+
// Both halves, together: the gate that does enforce it, and WHEN. Naming
143+
// D12 without placing it at runtime is the sentence this pin was written
144+
// for; placing it at runtime without naming D12 reads as unenforced.
145+
expect(doc).toContain('D12');
146+
expect(doc).toMatch(/runtime/i);
147+
});
148+
});

packages/spec/src/identity/position.zod.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,19 @@ export const PositionSchema = lazySchema(() => strictObject(
8383
* positions (an approver going on leave) opt in; admin-ish positions do
8484
* NOT — delegating administration would bypass the D12 containment gate,
8585
* so a delegatable position must never distribute an `adminScope`-carrying
86-
* set (enforced by the `security-delegatable-admin-position` lint rule and
87-
* the D12 gate). A grant that itself arrived via delegation is not
88-
* re-delegatable (chains are cut).
86+
* set. A grant that itself arrived via delegation is not re-delegatable
87+
* (chains are cut).
88+
*
89+
* That invariant IS enforced — but at RUNTIME, not at authoring time. The
90+
* D12 containment gate (`plugin-security`'s delegated-admin gate, step 6 of
91+
* the self-service delegation path) refuses the delegation the moment a
92+
* holder attempts it, denying with the offending permission set named. No
93+
* lint rule checks the combination, so a package pairing `delegatable: true`
94+
* with an `adminScope`-carrying set publishes clean and `os lint` stays
95+
* green: what you will see is a delegation deny at first use, not an
96+
* author-time error. (The one author-time rule ADR-0091 D3 does have,
97+
* `security-delegation-missing-reason`, checks something else — that a
98+
* seeded delegation row carries its dual-audit reason.)
8999
*/
90100
delegatable: z.boolean().default(false).describe(
91101
'ADR-0091 D3: holders may self-service delegate this position, time-boxed (default false).',

0 commit comments

Comments
 (0)