Skip to content

Commit 6ec467b

Browse files
os-billclaude
andauthored
feat(lint): name the retired allowRestore/allowPurge residue at the authoring door (#17917)
Fixes #17425 Clause-②: yes — **flipped by the `domain:spec` seat, 2026-09-13T02:5xZ.** The round declared `no` and correctly left the final value to the seat, reporting both limbs separately. The deciding limb is the mechanical one: `references/contract-review.md:13` — 「**新导出符号或已发布载荷上的新键恒 `yes`**」. This diff adds **three** new exported symbols to `@objectstack/lint`'s published barrel (`validateRetiredPermissionResidue`, the `RetiredPermissionResidueFinding` type, and `PERMISSION_RETIRED_LIFECYCLE_RESIDUE`), verified by the seat from the diff against merge base `5741ff10c30`, and measured by the round in the built tarball (6 published dist files each). ⇒ `yes`, unconditionally. The round's own reasoning — no schema touched, no accept set moved, `packages/spec` not in the diff — is the OTHER limb and is accurate; it does not reach this one. `needs:contract-review` hung on both carriers (PR and card) in the same stroke. This is the **second, lint half** of the card. The parse half landed as #17485 and is not re-opened here: #12840's retired-default residue tolerance stays exactly as ruled, `packages/spec/src/security/permission.zod.ts` and `shared/retired-key.ts` are untouched, and nothing about what parses changes. This implements the director seat's **ruling D** — the missing signal is delivered where the authored path and the built path ARE distinguishable, which is before the parse. ## The gap, in the contract's own words `acceptRetiredDefaultResidue` states why its accept is silent, and in the same sentence names the channels that stay loud for authored sources: > the strip is deliberately SILENT — real artifacts carry the residue once per permission entry, and a per-occurrence notice would be a 75-line storm that teaches operators to skim; the loud channels for authored sources (tsc `never`, `os migrate meta`, the D2 conversion) are unchanged. Read that list against a non-TypeScript author and it is one entry short. * `tsc never` is a TypeScript channel. An author using `definePermissionSet` cannot write the key at all. * `os migrate meta` and the ADR-0087 D2 conversion are the same channel twice — and that conversion, `permission-allow-restore-purge-removed`, is declared `retiredFromLoadPath: true`, so it never runs while a stack loads. **Measured**: `normalizeStackInput` over a raw stack carrying `allowRestore: false` emits **0** conversion notices and hands the key straight through. So an author who writes the key in a JSON or YAML source and does not run the migration gets a clean parse and no signal at all — which is what a tombstone exists to prevent, and it is exactly the complaint the card was filed for. ## Population measurement — taken FIRST, because it gates the severity The ruling made this the ordering, so it is reported before the choice it gates. **Authored stack sources in this tree carrying the retired keys: ZERO.** The census classified every in-tree carrier structurally rather than by token count (occurrences via `grep -o`, never `grep -c` line counts): | class | occurrences | largest carrier | |:--|--:|:--| | built artifact / fixture | 150 | `packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json` (75 + 75) | | tests | 123 | `packages/spec/src/security/permission.test.ts` (44) | | spec / runtime machinery that NAMES the keys | 88 | `packages/spec/src/security/permission.zod.ts` (26) | | docs and changelog prose | 99 | `packages/spec/CHANGELOG.md` (18) | | changesets | 4 | — | | **authored stack source** | **0** | **none** | **LIT CONTROL — the census could have found one.** The two real authored permission sets in this tree (`examples/app-showcase/src/security/permission-sets.ts`, `examples/app-crm/src/security/sales-positions.ts`) carry **99** and **28** occurrences of live object-permission keys (`allowRead` / `allowCreate` / `allowEdit` / `allowDelete` / `allowTransfer`) in exactly the `objects: { NAME: { ... } }` shape this rule reads. The probe is aimed at files that really do carry object-permission blocks, and it returns a positive number on them — so the zero for the retired keys is an absence, not a miss. **DARK CONTROL**: a fabricated `allowTeleport` returns 0 in the same files, same expression. There is also a structural reason the zero is not surprising, and it is worth stating because it bounds the rule's reach: every tracked `objectstack.config.*` in this repo declares its metadata in TypeScript code, and `objectstack.json` in this tree is the **built artifact** (`dist/objectstack.json`), not an author's source. The ruling's own warning — that the 181 carriers are fixtures and built artifacts, not sources — holds, and the in-tree source population beneath it is empty. ### Severity: `warning`, and the measurement is what supports it 1. **A zero population is not an evidence base for a gate.** There is no measured false-positive budget to spend and no in-tree carrier to prove the rule would refuse the right thing. `error` would be a refusal grade chosen on zero observations. 2. **`error` would reverse ruling D by the back door.** The parse ACCEPTS `allowRestore: false`. An `error` at the authoring door makes `os build` refuse a stack the schema accepts — which is option B's accept-set narrowing, restricted to the CLI, and both #12840 and ruling D declined it. `warning` is the only grade that adds a signal without moving a gate. 3. **The registry's own tier rule agrees.** `gating` means the rule can emit `error` and therefore must run on all three commands as a publish gate; `advisory` never emits `error`. This is advisory, and `authoring-rule-wiring.test.ts` reads the rule's own source to keep that claim honest. Ruling D named `warning` as its expectation and conditioned the final choice on the measurement. The measurement supports it, so `warning` it is. **And the honest reading of what a zero population means for D itself**: today this rule would fire on nothing in this repository. Its reach is authored JSON/YAML sources outside the tree — and the ruling already names the condition under which B re-opens as a new decision card, "AI-generated JSON that never runs lint". A lint rule cannot reach an author who never runs lint. That limit is not closed by this PR and is not claimed to be. ## What the rule does One rule, `validateRetiredPermissionResidue`, in `packages/lint/src/validate-retired-permission-residue.ts`. * **Reads raw source**, `input: 'normalized'` — the `normalizeStackInput` output, before any Zod parse. That tier is load-bearing rather than conventional here: the evidence is a key the residue stage removes, so a `parsed` rule would read a stack that structurally can never carry it. * **Fires on the captured residue value and nothing else.** `true`, `'false'`, `0` and `null` already land on the tombstone's own refusal with the prescription attached; repeating them here would be a second voice one layer earlier. The surviving enforced lifecycle bit, `allowTransfer: false`, is not residue and is never named. * **Carries the prescription, read rather than retyped.** `retiredKey()` publishes its guidance as the key's own description; the hint is resolved from `ObjectPermissionSchema`'s shape at call time, so it cannot drift from the parse-time wording the same author sees through the other door. An unresolvable prescription yields no finding rather than a wording this module invented — the posture `lintLivenessProperties` takes to an unreadable ledger, which is why the test carries an anti-vacuity guard. The finding splits the ruling's "message = the retired-key prescription" across the two fields the shared `AuthoringFinding` shape already has: `message` says what is wrong (the line is inert and silently stripped), `hint` is the prescription verbatim. Every other rule in the registry uses the same split, and the prescription reaches the author either way. ### Registration, and which commands run it Appended to `AUTHORING_RULES` in `packages/lint/src/authoring-rules.ts` — the existing table, no new mechanism. That one entry reaches **`os validate`, `os build` and `os lint`** (`commands: ALL`), which is also `os compile`'s gate, since `compile.ts` makes the same `runAuthoringRules('build', ...)` call. `surfaces: CLI_ONLY` with a written `surfaceReason`: crossing to the runtime publish gate needs a measurement this round did not take — whether that gate's `body` reaches it BEFORE the per-type `safeParse` whose residue stage strips the only evidence this rule reads. Post-parse the rule is structurally silent, so wiring it there without that reading would publish a phantom check rather than coverage. The rule id constant is re-exported from `src/index.ts`, per `rule-id-barrel-exports.test.ts`. ## Controls and ablation The test carries paired controls throughout (`packages/lint/src/validate-retired-permission-residue.test.ts`, 17 cases): * **LIT** — the residue survives `normalizeStackInput`; the rule fires once per key with the right path and severity; it reaches an author through `runAuthoringRules` on all three commands, with the parsed tier deliberately handed a CLEAN stack so a fallback to `parsed` would be visible. * **DARK** — a clean permission set, a fabricated key, and every non-residue value earn nothing; the same runner is silent on a clean stack; malformed input never throws. * **COST DIRECTION** — `allowTransfer: false`, the surviving ENFORCED lifecycle bit, is the nearest miss in the shape (same family, same object, same `false`) and must never be named; flagging it would tell an author to delete a live grant. **Ablation**, both legs proven on disk by occurrence count AND `git hash-object` before the run, restored against the HEAD blob after it, with a `trap` on absolute paths: | leg | mutation | verdict | |:--|:--|:--| | guard removed | the residue detection short-circuits | vitest **exit 1** — 3 failed / 14 passed, the LIT cases | | **cost direction** | widened to also match `allowTransfer` | vitest **exit 1** — 1 failed, exactly the COST DIRECTION case | Both legs restored: `git diff HEAD` empty and `hash-object` equal to the HEAD blob, checked rather than inferred from an exit code. ## Tests and gates | run | exit | |:--|--:| | `pnpm --filter @objectstack/lint build` + `pnpm --filter @objectstack/lint test` (lock VERDICT command-exit) | **0** — 102 files, 3766 tests, 0 skipped | | `pnpm --filter @objectstack/lint typecheck` (lock VERDICT command-exit) | **0** | | `eslint . --no-inline-config` over the WHOLE repo population | **0** — 6685 files, 0 errors, 0 warnings | | 88 derived gate families, run individually | 85 **exit 0**, 3 NOT MEASURED | The three NOT MEASURED are `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`, each exiting **3** on its own PREREQUISITE NOT MET (they read built output the whole workspace has not produced here). A fourth, `check:skill-examples`, exited 1 with its own "Build first, then re-run" prerequisite text naming an unbuilt `@objectstack/client-react` whose build fails on its own unbuilt closure — a wrong-reason red, recorded as NOT MEASURED, not as red. `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran` accounts for all 88 with 0 UNRUN. The eslint reading above is not a narrowing: the full population ran. No red anywhere. Two gates went red on the first sweep and both are fixed in the second commit — they are the mechanical consequences of the change, not incidental cleanups. `check:doc-authoring` refuses an internal tracker id inside customer-facing string prose, so the reference moved from the `surfaceReason` string to the adjacent comment. `check:docs-transcript-drift` derives the author-time rule count from the registry and compares it against the transcripts the docs quote: the new entry moves it 44 to 45, so the four pages printing it are refreshed. ## Changeset — measured, with `dist` BUILT `@objectstack/lint` publishes `dist` only. `dist` was unbuilt at first reading (a real `npm pack --dry-run` returned 3 files: CHANGELOG, README, package.json), so it was built and the measurement retaken rather than argued from the declared tsup entries. **With `dist` built**, `npm pack --dry-run` returns **17 files, 14 of them under `dist/`**, and all three new symbols are in the tarball: * `validateRetiredPermissionResidue` and `PERMISSION_RETIRED_LIFECYCLE_RESIDUE` — 6 published files each, including `dist/index.d.ts` and `dist/index.d.cts` * `RetiredPermissionResidueFinding` — the 2 declaration files * **lit control**: an already-published symbol, `lintLivenessProperties`, reaches 6 files. **dark control**: a fabricated symbol reaches 0. Published surface moves, so a changeset is owed and present: `.changeset/17425-retired-permission-residue-lint.md`, graded `minor` (additive; nothing is removed and no existing finding changes shape or severity). ## Declared overlap Sibling card #17319's round has an open PR (#17912, awaiting review) that also adds a rule under `packages/lint/src/` and edits the `src/index.ts` barrel. Declared rather than avoided, per this lane's ruled discipline: whoever lands second resolves. The barrel is an export list — on a conflict, merge main and re-add the export block. Also declared: the **file face grew past the claim's list**. The claim declared `packages/lint/` (rule, test, barrel). The diff additionally carries `.changeset/17425-retired-permission-residue-lint.md` and four `content/docs/` pages, the latter because the derived rule count they quote moved. Amending the claim comment is the seat's act, not this round's. Authored by Claude Code in session `session_01MkQhmuuJAVDjmeWNixwDDH`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4c33bd4 commit 6ec467b

9 files changed

Lines changed: 391 additions & 4 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): `permission-retired-lifecycle-residue` — the retired `allowRestore` / `allowPurge` bits are now named at the authoring door (#17425)
6+
7+
`ObjectPermissionSchema` accepts `allowRestore: false` / `allowPurge: false` as inert residue and strips them silently. That tolerance is #12840's class ruling and is unchanged here: the accept set does not move, no schema is touched, and every other value keeps the tombstone's loud refusal.
8+
9+
The silence is deliberate — every artifact the published 17.x toolchain built has the retired default materialized in every permission entry, and a per-occurrence notice would be a storm. But `acceptRetiredDefaultResidue`'s own docblock names the channels that stay loud for authored sources — tsc `never`, `os migrate meta`, the ADR-0087 D2 conversion — and against a non-TypeScript author that list is one entry short. `tsc never` is a TypeScript channel. The conversion and `os migrate meta` are the same channel twice, and `permission-allow-restore-purge-removed` is declared `retiredFromLoadPath`, so it never fires while a stack loads. An author who writes the key in a JSON or YAML source and does not run the migration gets a clean parse and no signal at all — which is what a tombstone exists to prevent.
10+
11+
`os validate`, `os build` and `os lint` now emit one advisory `warning` per carrying entry, on the raw pre-parse stack where the key is still present and still attributable to a line somebody wrote. The hint is the retirement's own prescription, read from the tombstone's published description rather than retyped, so it cannot drift from the parse-time wording the same author sees through the other door.
12+
13+
It fires on the captured residue value and on nothing else: `true`, `"false"`, `0` and `null` are already refused at the parse with the prescription attached, and the surviving enforced lifecycle bit `allowTransfer: false` is not residue and is never named.
14+
15+
New published exports on `@objectstack/lint`: `validateRetiredPermissionResidue`, `PERMISSION_RETIRED_LIFECYCLE_RESIDUE` and the `RetiredPermissionResidueFinding` type. Nothing is removed and no existing finding changes shape or severity.

content/docs/deployment/cli.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,7 @@ os compile --json # JSON output for CI pipelines
589589
→ Normalizing stack definition...
590590
→ Lowering inline handlers...
591591
→ Validating protocol compliance...
592-
→ Running author-time rules (44)...
592+
→ Running author-time rules (45)...
593593
→ Checking capability providers (#3366)...
594594
→ Collecting package docs (ADR-0046)...
595595
→ Writing artifact...

content/docs/deployment/validating-metadata.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,7 @@ A clean run walks the registry and reports timing:
555555
Config: /path/to/support-desk/objectstack.config.ts
556556
Load time: 21ms
557557
→ Validating against ObjectStack Protocol...
558-
→ Running author-time rules (44)...
558+
→ Running author-time rules (45)...
559559
→ Checking capability providers (#3366)...
560560
→ Checking package docs (ADR-0046)...
561561

content/docs/getting-started/build-with-claude-code.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ visible: 'status != "resolved"'
265265
◆ Validate
266266
────────────────────────────────────────
267267
→ Validating against ObjectStack Protocol...
268-
→ Running author-time rules (44)...
268+
→ Running author-time rules (45)...
269269
270270
✗ Author-time rules failed (1 issue)
271271
• stack · action 'resolve_ticket' visible: bare reference `status` — a

content/docs/ui/react-pages.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ objectstack validate
381381
────────────────────────────────────────
382382
→ Loading configuration...
383383
→ Validating against ObjectStack Protocol...
384-
→ Running author-time rules (44)...
384+
→ Running author-time rules (45)...
385385
→ Checking capability providers (#3366)...
386386
→ Checking package docs (ADR-0046)...
387387

packages/lint/src/authoring-rules.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ import { lintFlowPatterns } from './lint-flow-patterns.js';
137137
import { lintLivenessProperties } from './lint-liveness-properties.js';
138138
import { lintAutonumberFormats } from './lint-autonumber-formats.js';
139139
import { lintViewRefs } from './lint-view-refs.js';
140+
import { validateRetiredPermissionResidue } from './validate-retired-permission-residue.js';
140141
import {
141142
lintUniqueDeclarations,
142143
lintUnscopedDeclaredIndexes,
@@ -1247,6 +1248,40 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
12471248
hint: f.hint,
12481249
})),
12491250
},
1251+
// [#17425, director ruling D] The author-time half of #12840's retired-default
1252+
// residue tolerance. The parse ACCEPTS `allowRestore: false` / `allowPurge:
1253+
// false` and strips them in silence — deliberately, so that built artifacts
1254+
// survive — which leaves a non-TypeScript author writing the key a clean pass
1255+
// and no signal at all. `input: 'normalized'` is load-bearing rather than
1256+
// conventional here: the evidence is a key the residue stage removes, so a
1257+
// `parsed` rule would read a stack that can never carry it. Measured: the
1258+
// ADR-0087 conversion that would otherwise strip it (`permission-allow-
1259+
// restore-purge-removed`) is `retiredFromLoadPath: true`, so it does not run
1260+
// inside `normalizeStackInput` and the key reaches this tier intact.
1261+
{
1262+
name: 'validateRetiredPermissionResidue',
1263+
tier: 'advisory',
1264+
input: 'normalized',
1265+
commands: ALL,
1266+
source: 'packages/lint/src/validate-retired-permission-residue.ts',
1267+
surfaces: CLI_ONLY,
1268+
surfaceReason:
1269+
'Ruled scope: the signal belongs at the authoring door over RAW SOURCE, which is ' +
1270+
'where the authored and the built path are distinguishable. Crossing it needs a measurement ' +
1271+
"this round did not take — whether the gate's `body` reaches it BEFORE the per-type " +
1272+
'`safeParse`, whose residue stage strips the only evidence this rule reads. Post-parse the ' +
1273+
'rule is structurally silent, so wiring it there without that reading would publish a ' +
1274+
'phantom check, not coverage.',
1275+
run: (stack) =>
1276+
validateRetiredPermissionResidue(stack).map((f) => ({
1277+
severity: f.severity,
1278+
rule: f.rule,
1279+
where: f.where,
1280+
path: f.path,
1281+
message: f.message,
1282+
hint: f.hint,
1283+
})),
1284+
},
12501285
// A format like `{plan_no}{000}` makes the referenced field part of the
12511286
// counter scope, so it must exist and be set at create time. Unknown field →
12521287
// broken (error); optional field → fragile (warning).

packages/lint/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,16 @@ export {
841841
LIVENESS_LIVE_ELSEWHERE_PROPERTY,
842842
} from './lint-liveness-properties.js';
843843

844+
export { validateRetiredPermissionResidue } from './validate-retired-permission-residue.js';
845+
export type { RetiredPermissionResidueFinding } from './validate-retired-permission-residue.js';
846+
export {
847+
// [#17425] The one value #12840's residue stage accepts in silence, named at
848+
// the authoring door. Published because `f.rule` is what `--json` consumers
849+
// and `suppressWarnings` compare against — a rule id no barrel re-exports is
850+
// unreachable (`rule-id-barrel-exports.test.ts`).
851+
PERMISSION_RETIRED_LIFECYCLE_RESIDUE,
852+
} from './validate-retired-permission-residue.js';
853+
844854
export { lintAutonumberFormats } from './lint-autonumber-formats.js';
845855
export type { AutonumberLintFinding } from './lint-autonumber-formats.js';
846856
export {
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #17425, director ruling D. The rule's job is to be the ONE author-facing
4+
// voice on a value the parse consumes in silence, so almost every assertion
5+
// here is paired: a LIT control that must fire and a DARK control that must
6+
// not. A rule of this shape fails in two directions and only one of them is
7+
// noisy — a rule that never fires looks exactly like a clean codebase.
8+
9+
import { describe, expect, it } from 'vitest';
10+
import { ObjectPermissionSchema } from '@objectstack/spec/security';
11+
import { normalizeStackInput } from '@objectstack/spec';
12+
13+
import {
14+
PERMISSION_RETIRED_LIFECYCLE_RESIDUE,
15+
retiredKeyPrescription,
16+
validateRetiredPermissionResidue,
17+
} from './validate-retired-permission-residue.js';
18+
import { AUTHORING_COMMANDS, authoringRulesFor, runAuthoringRules } from './authoring-rules.js';
19+
20+
type AnyRec = Record<string, unknown>;
21+
22+
/** The two keys and the single value each one's residue stage swallows. */
23+
const RETIRED: ReadonlyArray<readonly [string, boolean]> = [
24+
['allowRestore', false],
25+
['allowPurge', false],
26+
];
27+
28+
/** A raw authored stack, map-shaped, exactly as a non-TypeScript source spells it. */
29+
function rawStack(entry: AnyRec): AnyRec {
30+
return {
31+
permissions: {
32+
support_agent: {
33+
label: 'Support Agent',
34+
objects: { crm_ticket: { allowRead: true, ...entry } },
35+
},
36+
},
37+
};
38+
}
39+
40+
/** The `normalizeStackInput` output every authoring command hands a `normalized` rule. */
41+
function normalized(entry: AnyRec): AnyRec {
42+
return normalizeStackInput(structuredClone(rawStack(entry)) as AnyRec) as AnyRec;
43+
}
44+
45+
describe('validateRetiredPermissionResidue (#17425)', () => {
46+
describe('the premise the rule stands on', () => {
47+
// Everything below is vacuous if the load path strips the key before a rule
48+
// can see it — which is what the ADR-0087 conversion does when it is asked
49+
// to. It is `retiredFromLoadPath`, so it is not asked to here.
50+
it('LIT — the residue survives normalizeStackInput, which is the tier this rule reads', () => {
51+
const perm = (normalized({ allowRestore: false, allowPurge: false }).permissions as AnyRec[])[0];
52+
const ticket = (perm.objects as AnyRec).crm_ticket as AnyRec;
53+
expect(Object.prototype.hasOwnProperty.call(ticket, 'allowRestore')).toBe(true);
54+
expect(Object.prototype.hasOwnProperty.call(ticket, 'allowPurge')).toBe(true);
55+
});
56+
57+
it('DARK — the same key does NOT survive the parse, which is why a `parsed` rule could not do this', () => {
58+
const parsed = ObjectPermissionSchema.safeParse({ allowRead: true, allowRestore: false });
59+
expect(parsed.success).toBe(true);
60+
expect(Object.prototype.hasOwnProperty.call(parsed.data!, 'allowRestore')).toBe(false);
61+
});
62+
63+
it.each(RETIRED)('the accept set for `%s` is exactly the value this rule fires on', (key, residue) => {
64+
// The rule's private table has to agree with the schema's captured
65+
// literal, and the schema does not export it — so the agreement is
66+
// asserted through the behaviour the capture produces.
67+
expect(ObjectPermissionSchema.safeParse({ allowRead: true, [key]: residue }).success).toBe(true);
68+
for (const other of [true, 'false', 0, null, '']) {
69+
const refused = ObjectPermissionSchema.safeParse({ allowRead: true, [key]: other });
70+
expect(refused.success, `${key}: ${JSON.stringify(other)} must stay refused`).toBe(false);
71+
}
72+
});
73+
});
74+
75+
describe('the finding', () => {
76+
it('LIT — fires on the residue, once per key, naming the site', () => {
77+
const findings = validateRetiredPermissionResidue(normalized({ allowRestore: false, allowPurge: false }));
78+
expect(findings.map((f) => f.rule)).toEqual([
79+
PERMISSION_RETIRED_LIFECYCLE_RESIDUE,
80+
PERMISSION_RETIRED_LIFECYCLE_RESIDUE,
81+
]);
82+
expect(findings.map((f) => f.path)).toEqual([
83+
'permissions[0].objects.crm_ticket.allowRestore',
84+
'permissions[0].objects.crm_ticket.allowPurge',
85+
]);
86+
expect(findings[0].where).toBe("permission set 'support_agent' · object 'crm_ticket'");
87+
expect(findings.every((f) => f.severity === 'warning')).toBe(true);
88+
});
89+
90+
it('carries the retirement’s OWN prescription, not a second wording', () => {
91+
const [finding] = validateRetiredPermissionResidue(normalized({ allowRestore: false }));
92+
const fromSchema = String(
93+
(ObjectPermissionSchema as unknown as { shape: Record<string, { description?: string }> })
94+
.shape.allowRestore.description,
95+
).replace(/^\[REMOVED\]\s*/, '');
96+
expect(finding.hint).toBe(fromSchema);
97+
// Anti-vacuity: an empty derivation would make the assertion above true
98+
// and the rule silent. The prescription's two load-bearing clauses.
99+
expect(finding.hint).toContain('Delete the key');
100+
expect(finding.hint).toContain('os migrate meta --from 17');
101+
});
102+
103+
it('the prescription resolves for every key the rule knows about', () => {
104+
for (const [key] of RETIRED) {
105+
expect(retiredKeyPrescription(key), `no prescription resolved for ${key}`).not.toBeNull();
106+
}
107+
// The resolver is not a constant function: a key with no tombstone has none.
108+
expect(retiredKeyPrescription('allowTransfer')).not.toContain('was removed');
109+
expect(retiredKeyPrescription('allowTeleport')).toBeNull();
110+
});
111+
});
112+
113+
describe('DARK controls — what must stay silent', () => {
114+
it('a clean permission set earns nothing', () => {
115+
expect(validateRetiredPermissionResidue(normalized({}))).toEqual([]);
116+
});
117+
118+
it('COST DIRECTION — a live lifecycle bit set falsy is NOT residue', () => {
119+
// `allowTransfer` is the surviving lifecycle key (#3004, enforced). It is
120+
// the nearest miss in the shape: same family, same object, same `false`.
121+
// Flagging it would tell an author to delete an enforced grant.
122+
expect(validateRetiredPermissionResidue(normalized({ allowTransfer: false }))).toEqual([]);
123+
expect(validateRetiredPermissionResidue(normalized({ allowCreate: false, allowDelete: false }))).toEqual([]);
124+
});
125+
126+
it('a non-residue VALUE is the tombstone’s business, not this rule’s', () => {
127+
// Each of these is refused at the parse with the prescription attached.
128+
for (const other of [true, 'false', 0, null]) {
129+
expect(
130+
validateRetiredPermissionResidue(normalized({ allowRestore: other })),
131+
`${JSON.stringify(other)} must not be double-reported`,
132+
).toEqual([]);
133+
}
134+
});
135+
136+
it('a fabricated key earns nothing', () => {
137+
expect(validateRetiredPermissionResidue(normalized({ allowTeleport: false }))).toEqual([]);
138+
});
139+
140+
it('never throws on malformed input, and reports nothing about it', () => {
141+
for (const junk of [{}, { permissions: null }, { permissions: [null, 7] }, { permissions: [{ objects: 3 }] },
142+
{ permissions: [{ objects: { a: null } }] }]) {
143+
expect(validateRetiredPermissionResidue(junk as AnyRec)).toEqual([]);
144+
}
145+
});
146+
});
147+
148+
describe('wiring — the rule really runs, on every command', () => {
149+
it.each([...AUTHORING_COMMANDS])('os %s runs it', (command) => {
150+
expect(authoringRulesFor(command).map((r) => r.name)).toContain('validateRetiredPermissionResidue');
151+
});
152+
153+
it('LIT — reaches an author through the registry runner on all three commands', () => {
154+
for (const command of AUTHORING_COMMANDS) {
155+
const findings = runAuthoringRules(command, {
156+
normalized: normalized({ allowRestore: false }),
157+
// The parsed tier CANNOT carry the evidence; handing it over proves
158+
// the entry reads `normalized` rather than falling back.
159+
parsed: normalized({}),
160+
}).filter((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE);
161+
expect(findings.map((f) => f.path), `os ${command}`).toEqual([
162+
'permissions[0].objects.crm_ticket.allowRestore',
163+
]);
164+
expect(findings[0].severity).toBe('warning');
165+
}
166+
});
167+
168+
it('DARK — the same runner is silent on a clean stack', () => {
169+
for (const command of AUTHORING_COMMANDS) {
170+
expect(
171+
runAuthoringRules(command, { normalized: normalized({}) })
172+
.filter((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE),
173+
`os ${command}`,
174+
).toEqual([]);
175+
}
176+
});
177+
});
178+
});

0 commit comments

Comments
 (0)