From 330eeba52b79aa9af2481397f44d07425e841e4b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 16:13:31 +0000 Subject: [PATCH 1/3] test(lint,metadata-protocol): pin the residue rule at the runtime authoring door (RED) The pins for the #17936 crossing, landed before the registration edit so the red/green pair is readable: the LIT arm (a permission write carrying `allowRestore` / `allowPurge` produces the advisory and still publishes) fails today because the rule is registered CLI_ONLY, while the DARK arms (a clean write advises nothing, the CLI door is unchanged) already read 0. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- .../runtime-gate.permission-residue.test.ts | 255 ++++++++++++++++++ .../protocol.runtime-authoring-gate.test.ts | 162 +++++++++++ 2 files changed, 417 insertions(+) create mode 100644 packages/lint/src/runtime-gate.permission-residue.test.ts diff --git a/packages/lint/src/runtime-gate.permission-residue.test.ts b/packages/lint/src/runtime-gate.permission-residue.test.ts new file mode 100644 index 00000000000..a886a81ec41 --- /dev/null +++ b/packages/lint/src/runtime-gate.permission-residue.test.ts @@ -0,0 +1,255 @@ +// 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.permission-residue-advisory.test.ts`); 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: { + crm_ticket: { allowRead: true, allowEdit: true, 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 }, + }, +}); + +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); + + const advisory = result.advisories.find((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE); + expect(advisory, `advisories: ${JSON.stringify(result.advisories)}`).toBeDefined(); + 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, 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.filter((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE), + `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, 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, 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([]); + } + }); +}); diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index 4463447560f..a25de0e87fe 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -761,3 +761,165 @@ describe('runtime authoring gate on OBJECT writes (#4716)', () => { expect(objectRows(rows)).toHaveLength(1); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #17936 — the PERMISSION write door, advisory tier. +// +// #17425's ruling D named a population: authors who write permission metadata +// as JSON through Studio / REST `/meta` / MCP and never run `os lint`. For that +// population `validateRetiredPermissionResidue` was registered CLI_ONLY, so the +// one door they have gave no signal at all — `allowRestore: false` parsed +// clean, the residue stage swallowed it in silence, and the line they wrote had +// no effect and nothing said so. +// +// THE MEASUREMENT THIS BLOCK EXISTS TO TAKE. The entry's `surfaceReason` held +// the crossing back on an open question: does the gate's `body` reach the rule +// BEFORE the per-type `safeParse`, whose residue stage strips the only evidence +// the rule reads? It does — and not by luck. `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, +// each a walk over the authored keys that adds nothing and drops nothing else. +// So the residue is still there at the gate call, and the persisted row proves +// it from the other side. Post-parse the rule would indeed be structurally +// silent — which is why this is pinned end to end at the door rather than +// argued from the registry. +// +// The severity is ruling D's: an advisory on the 2xx the write earns. A residue +// key must NEVER refuse a publish. +// ───────────────────────────────────────────────────────────────────────────── + +describe('runtime authoring gate on PERMISSION writes — retired lifecycle residue (#17936)', () => { + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + afterEach(() => { + warn.mockRestore(); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + + const RESIDUE_RULE = 'permission-retired-lifecycle-residue'; + + const permissionRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'permission'); + + /** A permission set carrying the ONE value the tombstone's residue stage swallows. */ + const residuePermissionSet = () => ({ + name: 'sales_team', + label: 'Sales Team', + objects: { + leave_request: { allowRead: true, allowEdit: true, allowRestore: false }, + }, + }); + + /** The same set with the retired key removed — the document the hint asks for. */ + const cleanPermissionSet = () => ({ + name: 'sales_team', + label: 'Sales Team', + objects: { + leave_request: { allowRead: true, allowEdit: true }, + }, + }); + + const savePermission = (protocol: any, item: unknown, extra: Record = {}) => + protocol.saveMetaItem({ type: 'permission', name: 'sales_team', item, ...extra }); + + it('advises on a residue write, in the door\'s existing envelope, and STILL PUBLISHES', async () => { + const { protocol, rows } = makeProtocol(); + + const result = await savePermission(protocol, residuePermissionSet()); + + // Ruling D: advisory, never a refusal. The write lands. + expect(result.success).toBe(true); + expect(permissionRows(rows)).toHaveLength(1); + + const advisory = (result.advisories ?? []).find((a: any) => a.rule === RESIDUE_RULE); + expect( + advisory, + `advisories: ${JSON.stringify(result.advisories)} — this is the #17425 ruling D population's ONLY door`, + ).toBeDefined(); + expect(advisory.severity).toBe('warning'); + // The key, the site and the remedy — the three things the author needs + // to act, in the same six-key shape Studio and MCP already render for + // the 422's `issues[]`. + expect(advisory.message).toContain('allowRestore'); + expect(advisory.where).toContain('sales_team'); + expect(advisory.where).toContain('leave_request'); + expect(advisory.path).toBe('permissions.sales_team.objects.leave_request.allowRestore'); + expect(advisory.hint.length, 'the prescription is read from the tombstone, not retyped') + .toBeGreaterThan(10); + }); + + it('THE MEASUREMENT: the residue survives the per-type safeParse to reach the gate', async () => { + // The premise the crossing rests on, read off the persisted row: the + // body `saveMetaItem` hands the gate is the AUTHORED one, so the key + // the parse would have stripped is still present where the rule reads. + // Were it otherwise the advisory above could not exist, and wiring the + // rule here would have published a phantom check. + const { protocol, rows } = makeProtocol(); + await savePermission(protocol, residuePermissionSet()); + + const row = permissionRows(rows)[0]!; + const stored = JSON.parse(row.metadata); + expect( + stored.objects.leave_request, + 'the door persists the authored body verbatim — `parsed.data` would have stripped this', + ).toHaveProperty('allowRestore', false); + }); + + it('`allowPurge` is the second arm, and both keys together advise twice', async () => { + const { protocol } = makeProtocol(); + const result = await savePermission(protocol, { + name: 'sales_team', + objects: { leave_request: { allowRead: true, allowRestore: false, allowPurge: false } }, + }); + + expect(result.success).toBe(true); + const paths = (result.advisories ?? []) + .filter((a: any) => a.rule === RESIDUE_RULE) + .map((a: any) => a.path) + .sort(); + expect(paths).toEqual([ + 'permissions.sales_team.objects.leave_request.allowPurge', + 'permissions.sales_team.objects.leave_request.allowRestore', + ]); + }); + + it('a CLEAN permission write carries no advisories key at all', async () => { + const { protocol, rows } = makeProtocol(); + const result = await savePermission(protocol, cleanPermissionSet()); + + expect(result.success).toBe(true); + expect( + 'advisories' in result, + `advisories leaked on a clean write: ${JSON.stringify(result.advisories)}`, + ).toBe(false); + expect(permissionRows(rows)).toHaveLength(1); + }); + + it('a DRAFT save of the same body is not judged (D1 unchanged)', async () => { + const { protocol } = makeProtocol(); + const result = await savePermission(protocol, residuePermissionSet(), { mode: 'draft' }); + + expect(result.success).toBe(true); + expect( + 'advisories' in result, + `a draft is allowed to be half-finished: ${JSON.stringify(result.advisories)}`, + ).toBe(false); + }); + + it('the advisory reaches the operator log once, deduped per type|name|rule|path', async () => { + // The gate's own operator channel, unchanged by this crossing — kept + // because the wire advisory is the AUTHOR's channel and the log is the + // operator's, and Studio republishes the same body a lot. + const { protocol } = makeProtocol(); + await savePermission(protocol, residuePermissionSet()); + + const lines = (warn.mock.calls as unknown[][]) + .map((c) => String(c[0])) + .filter((m) => m.includes(RESIDUE_RULE)); + expect(lines.length).toBeGreaterThanOrEqual(1); + expect(lines[0]).toContain('sales_team'); + }); +}); From 670efa9cbe2aaaabb5ec13b708cf6bd294d79bfa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 16:26:04 +0000 Subject: [PATCH 2/3] feat(lint): cross the retired-permission-residue rule onto the runtime authoring door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule was registered CLI_ONLY on a stated open question: does the runtime gate's `body` reach it BEFORE the per-type `safeParse`, whose residue stage strips the only evidence it reads? Measured: it does. `saveMetaItem` keeps the AUTHORED body verbatim by design and grafts back exactly two normalizations, each a walk over the authored keys, so `assertRuntimeAuthoringRules` is handed the raw document and the gate passes it straight through as `item`. So the crossing is coverage, not a phantom check, and the population ruling D on #17425 named — a Studio / REST `/meta` / MCP author who never runs `os lint` — now gets the same signal at the only door they have. Advisory tier: 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 it reads. The `surfaceReason` recording why the gate did NOT run it is removed rather than reworded: the question it held open has an answer now, and the reasoning lives in the entry's comment beside the measurement that settled it. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- packages/lint/src/authoring-rules.ts | 30 ++++++++++++----- .../runtime-gate.permission-residue.test.ts | 30 ++++++++++++----- .../protocol.runtime-authoring-gate.test.ts | 33 +++++++++++++++---- 3 files changed, 70 insertions(+), 23 deletions(-) diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index fca5ac86ac2..dc8588c9138 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -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, diff --git a/packages/lint/src/runtime-gate.permission-residue.test.ts b/packages/lint/src/runtime-gate.permission-residue.test.ts index a886a81ec41..24040d5dc47 100644 --- a/packages/lint/src/runtime-gate.permission-residue.test.ts +++ b/packages/lint/src/runtime-gate.permission-residue.test.ts @@ -24,8 +24,8 @@ * 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.permission-residue-advisory.test.ts`); what is pinned HERE is the - * dispatch and the differential, at the layer that owns them. + * `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 @@ -69,7 +69,11 @@ const residueSet = () => ({ name: 'sales_team', label: 'Sales Team', objects: { - crm_ticket: { allowRead: true, allowEdit: true, allowRestore: false }, + // `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 }, }, }); @@ -78,7 +82,7 @@ const cleanSet = () => ({ name: 'sales_team', label: 'Sales Team', objects: { - crm_ticket: { allowRead: true, allowEdit: true }, + crm_ticket: { allowRead: true, allowEdit: true, readScope: 'own' }, }, }); @@ -127,8 +131,11 @@ describe('#17936 — the door verdict on a permission write', () => { ).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, `advisories: ${JSON.stringify(result.advisories)}`).toBeDefined(); 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]` @@ -145,7 +152,9 @@ describe('#17936 — the door verdict on a permission write', () => { it('⭐ LIT — `allowPurge` is the second arm, and both together advise twice', () => { const both = { name: 'sales_team', - objects: { crm_ticket: { allowRead: true, allowRestore: false, allowPurge: false } }, + objects: { + crm_ticket: { allowRead: true, readScope: 'own', allowRestore: false, allowPurge: false }, + }, }; const result = runRuntimeAuthoringRules({ type: 'permission', item: both }); const paths = result.advisories @@ -164,7 +173,7 @@ describe('#17936 — the door verdict on a permission write', () => { expect(result.rulesRun, 'the rule must have RUN — a silent rule is not a clean verdict') .toContain(RULE_NAME); expect( - result.advisories.filter((f) => f.rule === PERMISSION_RETIRED_LIFECYCLE_RESIDUE), + result.advisories, `clean write advised anyway: ${JSON.stringify(result.advisories)}`, ).toEqual([]); expect(result.errors).toEqual([]); @@ -176,7 +185,10 @@ describe('#17936 — the door verdict on a permission write', () => { // thing one layer earlier and in different words. const result = runRuntimeAuthoringRules({ type: 'permission', - item: { name: 'sales_team', objects: { crm_ticket: { allowRead: true, allowRestore: true } } }, + 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([]); }); @@ -187,7 +199,7 @@ describe('#17936 — the door verdict on a permission write', () => { // passes and cancels. const stored = { name: 'support_team', - objects: { crm_case: { allowRead: true, allowPurge: false } }, + objects: { crm_case: { allowRead: true, readScope: 'own', allowPurge: false } }, }; const result = runRuntimeAuthoringRules({ type: 'permission', diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index a25de0e87fe..21bea06d2b0 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -809,7 +809,11 @@ describe('runtime authoring gate on PERMISSION writes — retired lifecycle resi name: 'sales_team', label: 'Sales Team', objects: { - leave_request: { allowRead: true, allowEdit: true, allowRestore: false }, + // `readScope` is authored on purpose: without it `validateSecurityPosture` + // adds its own `security-private-no-readscope` info advisory to every + // one of these writes, and the DARK reading below would then be "one + // advisory instead of two" rather than a true zero. + leave_request: { allowRead: true, allowEdit: true, readScope: 'own', allowRestore: false }, }, }); @@ -818,7 +822,7 @@ describe('runtime authoring gate on PERMISSION writes — retired lifecycle resi name: 'sales_team', label: 'Sales Team', objects: { - leave_request: { allowRead: true, allowEdit: true }, + leave_request: { allowRead: true, allowEdit: true, readScope: 'own' }, }, }); @@ -839,6 +843,9 @@ describe('runtime authoring gate on PERMISSION writes — retired lifecycle resi advisory, `advisories: ${JSON.stringify(result.advisories)} — this is the #17425 ruling D population's ONLY door`, ).toBeDefined(); + // A true reading, not a filtered one: with `readScope` authored the + // residue advisory is the ONLY thing this write earns. + expect((result.advisories ?? []).map((a: any) => a.rule)).toEqual([RESIDUE_RULE]); expect(advisory.severity).toBe('warning'); // The key, the site and the remedy — the three things the author needs // to act, in the same six-key shape Studio and MCP already render for @@ -872,7 +879,9 @@ describe('runtime authoring gate on PERMISSION writes — retired lifecycle resi const { protocol } = makeProtocol(); const result = await savePermission(protocol, { name: 'sales_team', - objects: { leave_request: { allowRead: true, allowRestore: false, allowPurge: false } }, + objects: { + leave_request: { allowRead: true, readScope: 'own', allowRestore: false, allowPurge: false }, + }, }); expect(result.success).toBe(true); @@ -913,13 +922,25 @@ describe('runtime authoring gate on PERMISSION writes — retired lifecycle resi // The gate's own operator channel, unchanged by this crossing — kept // because the wire advisory is the AUTHOR's channel and the log is the // operator's, and Studio republishes the same body a lot. + // + // ⚠️ A NAME OF ITS OWN, and that is a correctness property of this case + // rather than tidiness: `_advisoryWarned` is a module-level Set keyed + // `type|name|rule|path` for the whole PROCESS, so reusing `sales_team` + // here would read 0 lines because an earlier case in this file already + // spent that key — a dedupe working exactly as designed, misread as a + // missing log. const { protocol } = makeProtocol(); - await savePermission(protocol, residuePermissionSet()); + await protocol.saveMetaItem({ + type: 'permission', + name: 'audit_team', + item: { ...residuePermissionSet(), name: 'audit_team' }, + }); const lines = (warn.mock.calls as unknown[][]) .map((c) => String(c[0])) .filter((m) => m.includes(RESIDUE_RULE)); - expect(lines.length).toBeGreaterThanOrEqual(1); - expect(lines[0]).toContain('sales_team'); + expect(lines.length).toBe(1); + expect(lines[0]).toContain('audit_team'); + expect(lines[0]).toContain('allowRestore'); }); }); From 3bee0e438ec64825e380a533edfeb17b8f82ed06 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 16:46:35 +0000 Subject: [PATCH 3/3] chore(changeset): the residue rule's runtime-door crossing Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude --- .../17936-residue-rule-runtime-authoring-door.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/17936-residue-rule-runtime-authoring-door.md diff --git a/.changeset/17936-residue-rule-runtime-authoring-door.md b/.changeset/17936-residue-rule-runtime-authoring-door.md new file mode 100644 index 00000000000..539e9b0c822 --- /dev/null +++ b/.changeset/17936-residue-rule-runtime-authoring-door.md @@ -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..objects..`, 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.