diff --git a/.changeset/17425-retired-permission-residue-lint.md b/.changeset/17425-retired-permission-residue-lint.md new file mode 100644 index 0000000000..3853225439 --- /dev/null +++ b/.changeset/17425-retired-permission-residue-lint.md @@ -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. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index ffbe20acbc..9a34d83634 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -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... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index c0d9a0dbce..8a18b827c9 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -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)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index c3b0855efd..ca967711f9 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -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 diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index 762fb48f88..392d5c5d11 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -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)... diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index b24aabbeb0..83fe7775c1 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -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, @@ -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). diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index db55d05cba..443a1c316f 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -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 { diff --git a/packages/lint/src/validate-retired-permission-residue.test.ts b/packages/lint/src/validate-retired-permission-residue.test.ts new file mode 100644 index 0000000000..fb2bbff2d3 --- /dev/null +++ b/packages/lint/src/validate-retired-permission-residue.test.ts @@ -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; + +/** The two keys and the single value each one's residue stage swallows. */ +const RETIRED: ReadonlyArray = [ + ['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 }) + .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([]); + } + }); + }); +}); diff --git a/packages/lint/src/validate-retired-permission-residue.ts b/packages/lint/src/validate-retired-permission-residue.ts new file mode 100644 index 0000000000..7e87df2ffa --- /dev/null +++ b/packages/lint/src/validate-retired-permission-residue.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Author-time signal for the ONE value the retired `allowRestore` / + * `allowPurge` tombstones accept in silence (#17425, director ruling D). + * + * ## The gap this closes, and the gap it deliberately does NOT close + * + * `ObjectPermissionSchema` wraps its closed shape in + * `acceptRetiredDefaultResidue(...)` — the class helper of #12840, at the exact + * keys it was written for. That ruling is marked NOT re-adjudicable and nothing + * here touches it: the parse keeps accepting the retired default and keeps + * refusing every other value. The helper's own docblock states why the accept + * is silent, and in the same sentence names the channels that stay loud: + * + * > 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 JSON/YAML author and the gap is exactly one entry + * wide. `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: `permission-allow-restore-purge-removed` is + * declared `retiredFromLoadPath: true`, so it fires only when someone RUNS the + * migration, never on the load path (measured: `normalizeStackInput` on a raw + * stack carrying `allowRestore: false` emits zero conversion notices and hands + * the key straight through). So an author who writes the key in a non-TypeScript + * source and never runs `os migrate meta` gets a clean parse and no signal — the + * thing a tombstone exists to prevent, which is the whole of #17425's complaint. + * + * This rule is that missing channel, sited where the two paths ARE + * distinguishable: BEFORE the parse (`input: 'normalized'`), on the raw authored + * stack, where the residue is still present and still attributable to a line + * somebody wrote. It says nothing about built artifacts, because a built + * artifact never reaches an authoring command. + * + * ## Why only the residue VALUE + * + * The rule fires on the captured residue literal and on nothing else. Every + * other value — `true`, `'false'`, `0`, `null` — already lands on the + * tombstone's own refusal with the prescription attached, at the key's own path; + * repeating it here would be a second voice saying the same thing one layer + * earlier. The residue value is precisely the one the parse consumes without a + * word, so it is precisely the one an author-time rule is needed for. + * + * ## Why the prescription is READ, not retyped + * + * `retiredKey()` publishes its guidance as the key's own `description` + * (`[REMOVED] `), and that string is the retirement's single wording — + * pinned class-wide by `retired-key-migrate-sentence.test.ts` over in + * `packages/spec`. A copy here would be a second wording free to drift from the + * parse-time one the same author sees through the other door, so the hint is + * resolved from `ObjectPermissionSchema`'s own shape at call time. An + * unresolvable prescription yields NO finding rather than a hint this module + * invented — the same posture `lintLivenessProperties` takes to an unreadable + * ledger, and the reason this module's test carries an anti-vacuity guard. + */ + +import { ObjectPermissionSchema } from '@objectstack/spec/security'; +import { recordsOf } from './object-graph.js'; + +type AnyRec = Record; + +export interface RetiredPermissionResidueFinding { + where: string; + /** Positional config path, e.g. `permissions[0].objects.crm_ticket.allowRestore`. */ + path: string; + message: string; + hint: string; + rule: string; + severity: 'warning'; +} + +export const PERMISSION_RETIRED_LIFECYCLE_RESIDUE = 'permission-retired-lifecycle-residue'; + +/** + * The retired object-permission keys and the ONE value each one's residue stage + * swallows — the same discrimination `OBJECT_PERMISSION_RETIRED_KEY_RESIDUE` + * makes, by identity against the literal captured at retirement time. + * + * ⛔ Not derived from anything live, for the reason `RetiredDefaultResidue` + * states: the default no longer exists in the schema, so a literal written down + * at retirement is the only trustworthy record of what the released toolchain + * materialized. This table is the authoring-door copy of that same capture, and + * `validate-retired-permission-residue.test.ts` holds it equal to the spec's. + */ +const RETIRED_LIFECYCLE_RESIDUE: ReadonlyArray = [ + ['allowRestore', false], + ['allowPurge', false], +]; + +/** Strip the `[REMOVED] ` marker `retiredKey()` prefixes onto its guidance. */ +const REMOVED_MARKER = /^\[REMOVED\]\s*/; + +/** + * The retirement's own prescription for `key`, read from the tombstone's + * published description. `null` when the shape or the description cannot be + * resolved — see the module docblock for why that is silence rather than a + * substitute wording. + */ +export function retiredKeyPrescription(key: string): string | null { + const shape = (ObjectPermissionSchema as unknown as { shape?: Record }).shape; + const described = shape?.[key]?.description; + if (typeof described !== 'string' || described.length === 0) return null; + const prescription = described.replace(REMOVED_MARKER, '').trim(); + return prescription.length > 0 ? prescription : null; +} + +/** + * Flag every authored object-permission entry carrying a retired lifecycle + * key at its inert residue value. Advisory only — returns findings, never + * throws, never emits `error`. + */ +export function validateRetiredPermissionResidue(stack: AnyRec): RetiredPermissionResidueFinding[] { + const findings: RetiredPermissionResidueFinding[] = []; + const sets = recordsOf(stack.permissions); + for (let i = 0; i < sets.length; i++) { + const set = sets[i]; + const objects = set.objects; + if (!objects || typeof objects !== 'object' || Array.isArray(objects)) continue; + const setName = typeof set.name === 'string' ? set.name : '(unnamed permission set)'; + for (const [objectName, perm] of Object.entries(objects as AnyRec)) { + if (!perm || typeof perm !== 'object' || Array.isArray(perm)) continue; + const entry = perm as AnyRec; + for (const [key, residue] of RETIRED_LIFECYCLE_RESIDUE) { + if (!Object.prototype.hasOwnProperty.call(entry, key)) continue; + // Identity against the captured literal, exactly as the residue stage + // compares. `0`/`''`/`null` are NOT this value and are refused at the + // parse with the prescription already attached. + if (entry[key] !== residue) continue; + const hint = retiredKeyPrescription(key); + if (hint === null) continue; + findings.push({ + rule: PERMISSION_RETIRED_LIFECYCLE_RESIDUE, + severity: 'warning', + where: `permission set '${setName}' · object '${objectName}'`, + path: `permissions[${i}].objects.${objectName}.${key}`, + message: + `sets \`${key}: ${JSON.stringify(residue)}\`, the retired default of a key removed in ` + + '@objectstack/spec 17 (ADR-0049). It is accepted as inert residue and silently stripped ' + + 'on parse, so it grants nothing and nothing reports it — this line has no effect.', + hint, + }); + } + } + } + return findings; +}