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
16 changes: 16 additions & 0 deletions .changeset/17936-residue-rule-runtime-authoring-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/lint": minor
---

`validateRetiredPermissionResidue` now runs at the runtime authoring door on `permission` writes, at advisory tier — so a Studio / REST `/meta` / MCP author who writes `allowRestore: false` or `allowPurge: false` and never runs `os lint` is told the line has no effect (#17936, out of #17425 ruling D).

Clause-②: no

The rule was registered `CLI_ONLY` on an open question its own `surfaceReason` recorded: does the gate's `body` reach it BEFORE the per-type `safeParse`, whose residue stage strips the only evidence it reads? Wiring it without that reading would have published a phantom check. **Measured: it does reach it.** `saveMetaItem` keeps the AUTHORED body verbatim on purpose — `parsed.data` would strip the Studio-only auxiliary fields an overlay rides with — and grafts back exactly two normalizations (filter `operator` spellings, the form `groups` → `sections` key move), each a walk over the authored keys that adds and removes nothing else. So `assertRuntimeAuthoringRules` is handed the raw document, the gate passes it through as `item`, and the residue is present in the snapshot the rule reads.

What changes for a caller:

- A `permission` publish carrying either retired key **still succeeds** and now returns one `advisories[]` entry per occurrence, in the door's existing six-key diagnostics envelope (`{severity, rule, where, path, message, hint}`) — the shape Studio and MCP already render for a 422's `issues[]`. `rule` is `permission-retired-lifecycle-residue`, `path` is the name-keyed `permissions.<set>.objects.<object>.<key>`, and `hint` is the tombstone's own prescription, read from the schema rather than retyped.
- ⛔ **Never a refusal.** The rule is advisory tier; the accept set is untouched, and a value that is *not* the retired default (`true`, `0`, `null`) is still refused by the tombstone at the parse, with its prescription attached, exactly as before.
- **Draft saves are unchanged** (#4463 D1), and so is every other metadata type: `permission` is the only declared `runtimeTypes` member, because `stack.permissions` is the only collection the rule reads.
- **The CLI door is unchanged** — `os validate` / `os build` / `os lint` run the rule exactly as they did, with the same positional `permissions[i]…` path. The name-keying is the runtime gate's wire rewrite and does not reach the commands.
30 changes: 22 additions & 8 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1258,20 +1258,34 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// 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.
//
// [#17936] Crossed onto the runtime publish gate for `permission` writes, and
// the measurement the previous `surfaceReason` held it back for is TAKEN. That
// reason asked one question — does the gate's `body` reach this rule BEFORE
// the per-type `safeParse`, whose residue stage strips the only evidence it
// reads? It does, and not by luck: `saveMetaItem` keeps the AUTHORED body
// verbatim by design (`parsed.data` would strip the Studio-only auxiliary
// fields an overlay rides with) and grafts back exactly two normalizations,
// each a walk over the authored keys that adds and removes nothing else. So
// `assertRuntimeAuthoringRules` is handed the raw document, the gate passes it
// straight through as `item`, and the residue is present in the snapshot this
// rule reads. Pinned end to end at the door
// (`metadata-protocol`'s `protocol.runtime-authoring-gate.test.ts`, #17936
// block) and at this layer (`runtime-gate.permission-residue.test.ts`) — the
// phantom-check risk that reason named is answered by measurement, not by
// argument. Why it had to cross at all: ruling D's population — a Studio /
// REST `/meta` / MCP author who never runs `os lint` — has no other door.
// Advisory only, so it rides the 2xx the write earns and can never refuse one.
// `permission` is the only declared type because `stack.permissions` is the
// only collection the rule reads.
{
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.',
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['permission'],
run: (stack) =>
validateRetiredPermissionResidue(stack).map((f) => ({
severity: f.severity,
Expand Down
267 changes: 267 additions & 0 deletions packages/lint/src/runtime-gate.permission-residue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #17936 — `validateRetiredPermissionResidue` at the runtime authoring door.
*
* The rule closes the one channel #17425's ruling D named: an author who writes
* permission metadata as JSON and never runs `os lint`. Until this crossing it
* was registered `CLI_ONLY`, which left exactly that population — Studio, REST
* `/meta`, MCP — with no signal at all, because the door most tenants have is
* the one the rule did not run on.
*
* ## The measurement the crossing rests on, and why it was not assumable
*
* The entry's own `surfaceReason` said the crossing "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".
* That measurement is taken here rather than asserted: the door hands the gate
* the AUTHORED body, not `parsed.data`. `saveMetaItem` keeps the body verbatim
* by design (`parsed.data` would drop the Studio-only auxiliary fields that ride
* along with an overlay) and grafts back exactly two normalizations — filter
* `operator` spellings and the form `groups` → `sections` key move — each by a
* walk over the AUTHORED keys that adds nothing and removes nothing else. So the
* residue survives to the gate, `evaluateRuntimeAuthoringGate` passes it through
* as `item: args.body`, and `buildRuntimeWriteSnapshots` puts that same object
* into `candidate.permissions`. The end-to-end half of this is pinned at the
* door itself (`@objectstack/metadata-protocol`'s
* `protocol.runtime-authoring-gate.test.ts`, the #17936 block); what is pinned
* HERE is the dispatch and the differential, at the layer that owns them.
*
* `input: 'normalized'` stays load-bearing for the same reason it always was —
* the snapshot the gate builds is an unparsed body, which is precisely the tier
* this rule reads.
*
* ## The fences
*
* Advisory, never a refusal: ruling D set the severity and the crossing does not
* touch it. `permission` is the only type that crosses — the rule reads
* `stack.permissions` and nothing else, so declaring another type would wire it
* onto a collection it never inspects.
*/
import { describe, expect, it } from 'vitest';
import {
AUTHORING_COMMANDS,
AUTHORING_RULES,
authoringRulesFor,
runAuthoringRules,
} from './authoring-rules.js';
import {
runRuntimeAuthoringRules,
runtimeAuthoringRulesFor,
runtimeGatedTypes,
stackKeyForType,
} from './runtime-gate.js';
import {
PERMISSION_RETIRED_LIFECYCLE_RESIDUE,
validateRetiredPermissionResidue,
} from './validate-retired-permission-residue.js';

const RULE_NAME = 'validateRetiredPermissionResidue';

const entry = () => {
const found = AUTHORING_RULES.find((r) => r.name === RULE_NAME);
expect(found, `${RULE_NAME} left AUTHORING_RULES — re-point this pin or retire it`).toBeDefined();
return found!;
};

/** A permission set carrying the ONE value the tombstone's residue stage swallows. */
const residueSet = () => ({
name: 'sales_team',
label: 'Sales Team',
objects: {
// `readScope` authored on purpose: without it `validateSecurityPosture`
// adds a `security-private-no-readscope` info advisory to every one of
// these writes, and the DARK readings below would be "one advisory instead
// of two" rather than a true zero.
crm_ticket: { allowRead: true, allowEdit: true, readScope: 'own', allowRestore: false },
},
});

/** The same set with the retired key removed — the author's fixed document. */
const cleanSet = () => ({
name: 'sales_team',
label: 'Sales Team',
objects: {
crm_ticket: { allowRead: true, allowEdit: true, readScope: 'own' },
},
});

describe('#17936 — the residue rule dispatches on `permission` writes', () => {
it('the registry entry declares the runtime surface for `permission`, at advisory tier', () => {
const rule = entry();
expect(rule.tier, 'ruling D set advisory — a refusal here was never on the table').toBe('advisory');
expect(rule.surfaces).toEqual(['cli', 'runtime-publish']);
expect(rule.runtimeTypes).toEqual(['permission']);
// A crossed rule states its types; a stale "why not" would contradict the
// crossing it now sits beside.
expect(
rule.surfaceReason,
'the surfaceReason answering "why NOT the runtime gate" must not outlive the crossing',
).toBeUndefined();
});

it('the gate really dispatches it — declared, mapped, and reachable', () => {
expect(runtimeAuthoringRulesFor('permission').map((r) => r.name)).toContain(RULE_NAME);
expect(runtimeGatedTypes()).toContain('permission');
// Without the stack-key mapping the gate finds the rule, builds no snapshot
// and returns clean — wired, enforcing nothing.
expect(stackKeyForType('permission')).toBe('permissions');
});

it('DARK — no other metadata type reaches it', () => {
// The rule reads `stack.permissions` and nothing else. Declaring a second
// type would run it against a collection the snapshot never carries for
// that write, which is the "wired onto nothing" shape one surface over.
for (const type of runtimeGatedTypes().filter((t) => t !== 'permission')) {
expect(
runtimeAuthoringRulesFor(type).map((r) => r.name),
`'${type}' writes must not reach ${RULE_NAME}`,
).not.toContain(RULE_NAME);
}
});
});

describe('#17936 — the door verdict on a permission write', () => {
it('⭐ LIT — a write carrying `allowRestore: false` ADVISES and does not refuse', () => {
const result = runRuntimeAuthoringRules({ type: 'permission', item: residueSet() });

expect(
result.errors,
'ruling D set advisory — a residue key must never block a publish',
).toEqual([]);
expect(result.rulesRun).toContain(RULE_NAME);

expect(
result.advisories.map((f) => f.rule),
`advisories: ${JSON.stringify(result.advisories)}`,
).toEqual([PERMISSION_RETIRED_LIFECYCLE_RESIDUE]);
const advisory = result.advisories.find((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE);
expect(advisory!.severity).toBe('warning');
// [#10064] The wire shape keys the collection entry by NAME, not by the
// gate's private snapshot index — which here would read `permissions[0]`
// only because the context is empty.
expect(advisory!.path).toBe('permissions.sales_team.objects.crm_ticket.allowRestore');
expect(advisory!.where).toContain('sales_team');
expect(advisory!.where).toContain('crm_ticket');
expect(advisory!.message).toContain('allowRestore');
// The prescription is READ from the tombstone's own description, never
// retyped here — an empty hint means the resolution broke.
expect(advisory!.hint.length).toBeGreaterThan(10);
});

it('⭐ LIT — `allowPurge` is the second arm, and both together advise twice', () => {
const both = {
name: 'sales_team',
objects: {
crm_ticket: { allowRead: true, readScope: 'own', allowRestore: false, allowPurge: false },
},
};
const result = runRuntimeAuthoringRules({ type: 'permission', item: both });
const paths = result.advisories
.filter((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE)
.map((f) => f.path)
.sort();
expect(paths).toEqual([
'permissions.sales_team.objects.crm_ticket.allowPurge',
'permissions.sales_team.objects.crm_ticket.allowRestore',
]);
expect(result.errors).toEqual([]);
});

it('⭐ DARK — a clean write produces no residue advisory at all', () => {
const result = runRuntimeAuthoringRules({ type: 'permission', item: cleanSet() });
expect(result.rulesRun, 'the rule must have RUN — a silent rule is not a clean verdict')
.toContain(RULE_NAME);
expect(
result.advisories,
`clean write advised anyway: ${JSON.stringify(result.advisories)}`,
).toEqual([]);
expect(result.errors).toEqual([]);
});

it('DARK — a value that is NOT the residue literal is left to the tombstone', () => {
// `true` is a hard ADR-0049 violation and the parse refuses it with the
// prescription already attached; a second voice here would say the same
// thing one layer earlier and in different words.
const result = runRuntimeAuthoringRules({
type: 'permission',
item: {
name: 'sales_team',
objects: { crm_ticket: { allowRead: true, readScope: 'own', allowRestore: true } },
},
});
expect(result.advisories.filter((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE)).toEqual([]);
});

it("DARK — a STORED set's residue is not charged to this write (the differential)", () => {
// #4463 D4: the gate judges what this write ADDS, never the tenant's
// pre-existing rows. A stored set carrying its own residue appears in both
// passes and cancels.
const stored = {
name: 'support_team',
objects: { crm_case: { allowRead: true, readScope: 'own', allowPurge: false } },
};
const result = runRuntimeAuthoringRules({
type: 'permission',
item: cleanSet(),
context: { permissions: [stored] },
});
expect(
result.advisories.filter((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE),
'somebody else\'s stored residue is not this write\'s to answer for',
).toEqual([]);

// Non-vacuous: the same stored row present, the WRITTEN body dirty, and the
// write's own residue is still reported.
const dirty = runRuntimeAuthoringRules({
type: 'permission',
item: residueSet(),
context: { permissions: [stored] },
});
expect(
dirty.advisories
.filter((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE)
.map((f) => f.path),
).toEqual(['permissions.sales_team.objects.crm_ticket.allowRestore']);
});
});

describe('#17936 — ⭐ DARK: the CLI door is unchanged by the crossing', () => {
it('all three commands still run it, and the finding is the rule\'s own, unrewritten', () => {
for (const command of AUTHORING_COMMANDS) {
expect(
authoringRulesFor(command).map((r) => r.name),
`${command} lost ${RULE_NAME}`,
).toContain(RULE_NAME);
}

const stack = { permissions: [residueSet()] };
const direct = validateRetiredPermissionResidue(stack);
expect(direct.map((f) => f.path)).toEqual([
'permissions[0].objects.crm_ticket.allowRestore',
]);

for (const command of AUTHORING_COMMANDS) {
const viaCommand = runAuthoringRules(command, { normalized: stack })
.filter((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE);
// POSITIONAL on the CLI surface — the name-keying above is the runtime
// gate's WIRE rewrite and must not have leaked onto the commands.
expect(viaCommand.map((f) => f.path), `${command} path`).toEqual([
'permissions[0].objects.crm_ticket.allowRestore',
]);
expect(viaCommand.map((f) => f.severity), `${command} severity`).toEqual(['warning']);
expect(viaCommand[0]!.message, `${command} message`).toBe(direct[0]!.message);
expect(viaCommand[0]!.hint, `${command} hint`).toBe(direct[0]!.hint);
}
});

it('a clean stack reads 0 on every command', () => {
for (const command of AUTHORING_COMMANDS) {
expect(
runAuthoringRules(command, { normalized: { permissions: [cleanSet()] } })
.filter((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE),
`${command} advised on a clean stack`,
).toEqual([]);
}
});
});
Loading
Loading