Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/17425-retired-permission-residue-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/lint": minor
---

feat(lint): `permission-retired-lifecycle-residue` — the retired `allowRestore` / `allowPurge` bits are now named at the authoring door (#17425)

`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.

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.

`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.

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.

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.
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ os compile --json # JSON output for CI pipelines
→ Normalizing stack definition...
→ Lowering inline handlers...
→ Validating protocol compliance...
→ Running author-time rules (44)...
→ Running author-time rules (45)...
→ Checking capability providers (#3366)...
→ Collecting package docs (ADR-0046)...
→ Writing artifact...
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,7 @@ A clean run walks the registry and reports timing:
Config: /path/to/support-desk/objectstack.config.ts
Load time: 21ms
→ Validating against ObjectStack Protocol...
→ Running author-time rules (44)...
→ Running author-time rules (45)...
→ Checking capability providers (#3366)...
→ Checking package docs (ADR-0046)...

Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ visible: 'status != "resolved"'
◆ Validate
────────────────────────────────────────
→ Validating against ObjectStack Protocol...
→ Running author-time rules (44)...
→ Running author-time rules (45)...

✗ Author-time rules failed (1 issue)
• stack · action 'resolve_ticket' visible: bare reference `status` — a
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/react-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ objectstack validate
────────────────────────────────────────
→ Loading configuration...
→ Validating against ObjectStack Protocol...
→ Running author-time rules (44)...
→ Running author-time rules (45)...
→ Checking capability providers (#3366)...
→ Checking package docs (ADR-0046)...

Expand Down
35 changes: 35 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ import { lintFlowPatterns } from './lint-flow-patterns.js';
import { lintLivenessProperties } from './lint-liveness-properties.js';
import { lintAutonumberFormats } from './lint-autonumber-formats.js';
import { lintViewRefs } from './lint-view-refs.js';
import { validateRetiredPermissionResidue } from './validate-retired-permission-residue.js';
import {
lintUniqueDeclarations,
lintUnscopedDeclaredIndexes,
Expand Down Expand Up @@ -1247,6 +1248,40 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
hint: f.hint,
})),
},
// [#17425, director ruling D] The author-time half of #12840's retired-default
// residue tolerance. The parse ACCEPTS `allowRestore: false` / `allowPurge:
// false` and strips them in silence — deliberately, so that built artifacts
// survive — which leaves a non-TypeScript author writing the key a clean pass
// and no signal at all. `input: 'normalized'` 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 can never carry it. Measured: the
// ADR-0087 conversion that would otherwise strip it (`permission-allow-
// restore-purge-removed`) is `retiredFromLoadPath: true`, so it does not run
// inside `normalizeStackInput` and the key reaches this tier intact.
{
name: 'validateRetiredPermissionResidue',
tier: 'advisory',
input: 'normalized',
commands: ALL,
source: 'packages/lint/src/validate-retired-permission-residue.ts',
surfaces: CLI_ONLY,
surfaceReason:
'Ruled scope: the signal belongs at the authoring door over RAW SOURCE, which is ' +
'where the authored and the built path are distinguishable. Crossing it needs a measurement ' +
"this round did not take — whether the 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, not coverage.',
run: (stack) =>
validateRetiredPermissionResidue(stack).map((f) => ({
severity: f.severity,
rule: f.rule,
where: f.where,
path: f.path,
message: f.message,
hint: f.hint,
})),
},
// A format like `{plan_no}{000}` makes the referenced field part of the
// counter scope, so it must exist and be set at create time. Unknown field →
// broken (error); optional field → fragile (warning).
Expand Down
10 changes: 10 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,16 @@ export {
LIVENESS_LIVE_ELSEWHERE_PROPERTY,
} from './lint-liveness-properties.js';

export { validateRetiredPermissionResidue } from './validate-retired-permission-residue.js';
export type { RetiredPermissionResidueFinding } from './validate-retired-permission-residue.js';
export {
// [#17425] The one value #12840's residue stage accepts in silence, named at
// the authoring door. Published because `f.rule` is what `--json` consumers
// and `suppressWarnings` compare against — a rule id no barrel re-exports is
// unreachable (`rule-id-barrel-exports.test.ts`).
PERMISSION_RETIRED_LIFECYCLE_RESIDUE,
} from './validate-retired-permission-residue.js';

export { lintAutonumberFormats } from './lint-autonumber-formats.js';
export type { AutonumberLintFinding } from './lint-autonumber-formats.js';
export {
Expand Down
178 changes: 178 additions & 0 deletions packages/lint/src/validate-retired-permission-residue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #17425, director ruling D. The rule's job is to be the ONE author-facing
// voice on a value the parse consumes in silence, so almost every assertion
// here is paired: a LIT control that must fire and a DARK control that must
// not. A rule of this shape fails in two directions and only one of them is
// noisy — a rule that never fires looks exactly like a clean codebase.

import { describe, expect, it } from 'vitest';
import { ObjectPermissionSchema } from '@objectstack/spec/security';
import { normalizeStackInput } from '@objectstack/spec';

import {
PERMISSION_RETIRED_LIFECYCLE_RESIDUE,
retiredKeyPrescription,
validateRetiredPermissionResidue,
} from './validate-retired-permission-residue.js';
import { AUTHORING_COMMANDS, authoringRulesFor, runAuthoringRules } from './authoring-rules.js';

type AnyRec = Record<string, unknown>;

/** The two keys and the single value each one's residue stage swallows. */
const RETIRED: ReadonlyArray<readonly [string, boolean]> = [
['allowRestore', false],
['allowPurge', false],
];

/** A raw authored stack, map-shaped, exactly as a non-TypeScript source spells it. */
function rawStack(entry: AnyRec): AnyRec {
return {
permissions: {
support_agent: {
label: 'Support Agent',
objects: { crm_ticket: { allowRead: true, ...entry } },
},
},
};
}

/** The `normalizeStackInput` output every authoring command hands a `normalized` rule. */
function normalized(entry: AnyRec): AnyRec {
return normalizeStackInput(structuredClone(rawStack(entry)) as AnyRec) as AnyRec;
}

describe('validateRetiredPermissionResidue (#17425)', () => {
describe('the premise the rule stands on', () => {
// Everything below is vacuous if the load path strips the key before a rule
// can see it — which is what the ADR-0087 conversion does when it is asked
// to. It is `retiredFromLoadPath`, so it is not asked to here.
it('LIT — the residue survives normalizeStackInput, which is the tier this rule reads', () => {
const perm = (normalized({ allowRestore: false, allowPurge: false }).permissions as AnyRec[])[0];
const ticket = (perm.objects as AnyRec).crm_ticket as AnyRec;
expect(Object.prototype.hasOwnProperty.call(ticket, 'allowRestore')).toBe(true);
expect(Object.prototype.hasOwnProperty.call(ticket, 'allowPurge')).toBe(true);
});

it('DARK — the same key does NOT survive the parse, which is why a `parsed` rule could not do this', () => {
const parsed = ObjectPermissionSchema.safeParse({ allowRead: true, allowRestore: false });
expect(parsed.success).toBe(true);
expect(Object.prototype.hasOwnProperty.call(parsed.data!, 'allowRestore')).toBe(false);
});

it.each(RETIRED)('the accept set for `%s` is exactly the value this rule fires on', (key, residue) => {
// The rule's private table has to agree with the schema's captured
// literal, and the schema does not export it — so the agreement is
// asserted through the behaviour the capture produces.
expect(ObjectPermissionSchema.safeParse({ allowRead: true, [key]: residue }).success).toBe(true);
for (const other of [true, 'false', 0, null, '']) {
const refused = ObjectPermissionSchema.safeParse({ allowRead: true, [key]: other });
expect(refused.success, `${key}: ${JSON.stringify(other)} must stay refused`).toBe(false);
}
});
});

describe('the finding', () => {
it('LIT — fires on the residue, once per key, naming the site', () => {
const findings = validateRetiredPermissionResidue(normalized({ allowRestore: false, allowPurge: false }));
expect(findings.map((f) => f.rule)).toEqual([
PERMISSION_RETIRED_LIFECYCLE_RESIDUE,
PERMISSION_RETIRED_LIFECYCLE_RESIDUE,
]);
expect(findings.map((f) => f.path)).toEqual([
'permissions[0].objects.crm_ticket.allowRestore',
'permissions[0].objects.crm_ticket.allowPurge',
]);
expect(findings[0].where).toBe("permission set 'support_agent' · object 'crm_ticket'");
expect(findings.every((f) => f.severity === 'warning')).toBe(true);
});

it('carries the retirement’s OWN prescription, not a second wording', () => {
const [finding] = validateRetiredPermissionResidue(normalized({ allowRestore: false }));
const fromSchema = String(
(ObjectPermissionSchema as unknown as { shape: Record<string, { description?: string }> })
.shape.allowRestore.description,
).replace(/^\[REMOVED\]\s*/, '');
expect(finding.hint).toBe(fromSchema);
// Anti-vacuity: an empty derivation would make the assertion above true
// and the rule silent. The prescription's two load-bearing clauses.
expect(finding.hint).toContain('Delete the key');
expect(finding.hint).toContain('os migrate meta --from 17');
});

it('the prescription resolves for every key the rule knows about', () => {
for (const [key] of RETIRED) {
expect(retiredKeyPrescription(key), `no prescription resolved for ${key}`).not.toBeNull();
}
// The resolver is not a constant function: a key with no tombstone has none.
expect(retiredKeyPrescription('allowTransfer')).not.toContain('was removed');
expect(retiredKeyPrescription('allowTeleport')).toBeNull();
});
});

describe('DARK controls — what must stay silent', () => {
it('a clean permission set earns nothing', () => {
expect(validateRetiredPermissionResidue(normalized({}))).toEqual([]);
});

it('COST DIRECTION — a live lifecycle bit set falsy is NOT residue', () => {
// `allowTransfer` is the surviving lifecycle key (#3004, enforced). It is
// the nearest miss in the shape: same family, same object, same `false`.
// Flagging it would tell an author to delete an enforced grant.
expect(validateRetiredPermissionResidue(normalized({ allowTransfer: false }))).toEqual([]);
expect(validateRetiredPermissionResidue(normalized({ allowCreate: false, allowDelete: false }))).toEqual([]);
});

it('a non-residue VALUE is the tombstone’s business, not this rule’s', () => {
// Each of these is refused at the parse with the prescription attached.
for (const other of [true, 'false', 0, null]) {
expect(
validateRetiredPermissionResidue(normalized({ allowRestore: other })),
`${JSON.stringify(other)} must not be double-reported`,
).toEqual([]);
}
});

it('a fabricated key earns nothing', () => {
expect(validateRetiredPermissionResidue(normalized({ allowTeleport: false }))).toEqual([]);
});

it('never throws on malformed input, and reports nothing about it', () => {
for (const junk of [{}, { permissions: null }, { permissions: [null, 7] }, { permissions: [{ objects: 3 }] },
{ permissions: [{ objects: { a: null } }] }]) {
expect(validateRetiredPermissionResidue(junk as AnyRec)).toEqual([]);
}
});
});

describe('wiring — the rule really runs, on every command', () => {
it.each([...AUTHORING_COMMANDS])('os %s runs it', (command) => {
expect(authoringRulesFor(command).map((r) => r.name)).toContain('validateRetiredPermissionResidue');
});

it('LIT — reaches an author through the registry runner on all three commands', () => {
for (const command of AUTHORING_COMMANDS) {
const findings = runAuthoringRules(command, {
normalized: normalized({ allowRestore: false }),
// The parsed tier CANNOT carry the evidence; handing it over proves
// the entry reads `normalized` rather than falling back.
parsed: normalized({}),
}).filter((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE);
expect(findings.map((f) => f.path), `os ${command}`).toEqual([
'permissions[0].objects.crm_ticket.allowRestore',
]);
expect(findings[0].severity).toBe('warning');
}
});

it('DARK — the same runner is silent on a clean stack', () => {
for (const command of AUTHORING_COMMANDS) {
expect(
runAuthoringRules(command, { normalized: normalized({}) })
.filter((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE),
`os ${command}`,
).toEqual([]);
}
});
});
});
Loading
Loading