From ba96022405c15ddf122cd61cf1b768cd8d41b82b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 15:53:18 +0000 Subject: [PATCH] feat(lint): the runtime gate's per-write snapshot carries permissions/books, and permission/book map to stack keys (#8309) Slice 2 of #7891. RuntimeStackContext gains permissions and books; the baseline/candidate differential carries every context collection in both passes with replace-not-erase semantics generalized from objects; the construction is extracted and exported as buildRuntimeWriteSnapshots so tests drive the real thing instead of a mirror. TYPE_TO_STACK_KEY maps permission -> permissions and book -> books ahead of their runtimeTypes registration (#8310 remains open; no registration flip here). The metadata-protocol gate call site gathers both collections from the live registry per publish, the same best-effort pattern objects always used. Kills the measured RUNTIME_NEEDS_FULL_SNAPSHOT defect for the three cross-collection rules: 38 phantom security-master-detail-ungranted findings per-write vs 4 whole-stack (PR #7886). Agreement between the per-write and whole-stack verdicts is pinned, with the pre-#8309 phantom shape kept executable as in-tree reverse verification. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- .changeset/permission-book-snapshot-wiring.md | 10 + packages/lint/src/authoring-rules.ts | 71 ++++--- packages/lint/src/index.ts | 1 + packages/lint/src/runtime-gate.test.ts | 56 ++++++ packages/lint/src/runtime-gate.ts | 160 ++++++++++++--- packages/lint/src/runtime.ts | 1 + ...e-security-posture.runtime-surface.test.ts | 183 ++++++++++++------ packages/metadata-protocol/src/protocol.ts | 31 ++- .../src/runtime-authoring-gate.ts | 21 +- 9 files changed, 402 insertions(+), 132 deletions(-) create mode 100644 .changeset/permission-book-snapshot-wiring.md diff --git a/.changeset/permission-book-snapshot-wiring.md b/.changeset/permission-book-snapshot-wiring.md new file mode 100644 index 0000000000..1cdcde3ba1 --- /dev/null +++ b/.changeset/permission-book-snapshot-wiring.md @@ -0,0 +1,10 @@ +--- +"@objectstack/lint": minor +"@objectstack/metadata-protocol": minor +--- + +The runtime publish gate's per-write snapshot now carries the sibling collections the three cross-collection security rules compare against (#8309, slice 2 of #7891). + +`RuntimeStackContext` gains `permissions` and `books` beside `objects`, the gate's baseline/candidate differential carries all three in both passes (with replace-not-erase semantics for a write into any of them), and `TYPE_TO_STACK_KEY` maps `permission` → `permissions` and `book` → `books` ahead of their `runtimeTypes` registration (#8310). The snapshot construction is exported as `buildRuntimeWriteSnapshots` so tests exercise the real thing instead of a mirror. `@objectstack/metadata-protocol`'s gate call site gathers the two collections from the live registry per publish, the same way it always gathered `objects`. + +This repairs the measured defect behind `RUNTIME_NEEDS_FULL_SNAPSHOT`: a per-write snapshot holding exactly one permission set invented 38 phantom `security-master-detail-ungranted` findings where the whole-stack run produces 4 (PR #7886). Per-write and whole-stack verdicts for `security-master-detail-ungranted`, `security-private-no-readscope` and `security-book-audience-unknown-set` now agree. No verdict changes at the door until #8310 declares `permission`/`book` in `runtimeTypes` — the sibling collections cancel in the differential for every currently-gated type. diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 14edd31d56..8714960d3f 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -324,12 +324,20 @@ const CLI_AND_RUNTIME: readonly AuthoringSurface[] = ['cli', 'runtime-publish']; /** * The rule reads a stack-wide COLLECTION the per-write snapshot does not carry - * (pages, dashboards, navigation, translations, seeds, permission sets). The - * runtime universe can answer these — it is strictly more complete than the - * CLI's single-package view — but building that snapshot is #4463 P2, and - * shipping the rule against a partial snapshot would invent findings for - * metadata the tenant simply did not include in THIS write. A false 422 on the - * only door a Studio tenant has is worse than the gap it would close. + * (pages, dashboards, navigation, translations). The runtime universe can + * answer these — it is strictly more complete than the CLI's single-package + * view — but shipping a rule against a partial snapshot would invent findings + * for metadata the tenant simply did not include in THIS write. A false 422 on + * the only door a Studio tenant has is worse than the gap it would close. + * + * [#8309] The snapshot is widened by measurement, never wholesale: it now + * carries `permissions` and `books` (`RuntimeStackContext` in + * `runtime-gate.ts`) because the three cross-collection security rules were + * measured inventing findings without them (38 phantom + * `security-master-detail-ungranted` per-write vs 4 whole-stack, PR #7886) — + * so for a rule whose only missing collection was one of those two, this + * reason no longer holds and widening it is a `runtimeTypes` edit plus its + * own rollout decision. For every other collection the sentence above stands. */ const RUNTIME_NEEDS_FULL_SNAPSHOT = 'P2 (#4463): reads a stack-wide collection the per-write snapshot does not carry, so running it ' + @@ -1131,17 +1139,21 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // — carries no `sharingModel` either. That is a strictness rollout // (#4001 pattern), not a registry-honesty fix, and its repair sites are in // packages this card may not edit. - // - `permission` and `book` fail for a different, structural reason. The gate - // carries `objects` as resolution context and nothing else - // (`RuntimeStackContext`), so the three cross-collection rules judge a - // snapshot missing the collection they compare against. Measured: one - // simulated runtime write per shipped permission set produces 38 - // `security-master-detail-ungranted` warnings where the same rule over the - // whole stack produces 4 — with one set in the snapshot, every detail - // object the tenant's OTHER sets grant reads as ungranted. - // `security-private-no-readscope` and `security-book-audience-unknown-set` - // fail identically. That is RUNTIME_NEEDS_FULL_SNAPSHOT (#4463 P2), and it - // is a snapshot change in the protocol package, not a `runtimeTypes` edit. + // - `permission` and `book` failed for a different, structural reason — + // REPAIRED under #8309. The gate used to carry `objects` as resolution + // context and nothing else (`RuntimeStackContext`), so the three + // cross-collection rules judged a snapshot missing the collection they + // compare against. Measured: one simulated runtime write per shipped + // permission set produced 38 `security-master-detail-ungranted` warnings + // where the same rule over the whole stack produces 4 — with one set in + // the snapshot, every detail object the tenant's OTHER sets grant read as + // ungranted. `security-private-no-readscope` and + // `security-book-audience-unknown-set` failed identically. The snapshot + // now carries `permissions` and `books` in both differential passes and + // `TYPE_TO_STACK_KEY` maps both types, so the per-write verdict agrees + // with the whole-stack one (pinned in + // `validate-security-posture.runtime-surface.test.ts`); what remains for + // these two types is ONLY the `runtimeTypes` declaration itself — #8310. // // The residue that WAS ready, and now crosses (#8307): the two ADR-0091 seed // rules (`security-grant-expired-at-authoring`, `security-delegation-missing- @@ -1154,14 +1166,14 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // `sales_role` is not, which is the #7220 failure this table already refuses // to build. // - // `object` / `permission` / `book` remain UNDECLARED here (that is #8310, - // still blocked): (a) declaring `object` makes `security-owd-unset` refuse - // every OWD-less runtime object publish — 26 refusals across 8 files of - // `@objectstack/metadata-protocol`'s own suite, and `METADATA_CREATE_SEEDS.object` - // carries no `sharingModel` — so it is a strictness rollout (#4001), not a - // wiring fix; (b) `permission` / `book` need a second collection the per-write - // snapshot does not carry, and were measured inventing findings without it (38 - // vs 4 over the shipped corpus) — RUNTIME_NEEDS_FULL_SNAPSHOT, #4463 P2. This + // `object` / `permission` / `book` remain UNDECLARED here (that is #8310): + // (a) declaring `object` makes `security-owd-unset` refuse every OWD-less + // runtime object publish — a strictness rollout (#4001), not a wiring fix; + // (b) `permission` / `book` needed a second collection the per-write + // snapshot did not carry, and were measured inventing findings without it + // (38 vs 4 over the shipped corpus). #8309 closed (b) — the snapshot now + // carries `permissions`/`books` and both types map to stack keys — so + // declaring them here is #8310's remaining one-line flip. This // entry is the WHOLE `validateSecurityPosture` function (all 13 rule ids), not // a per-rule split: `runtimeTypes: ['seed']` is safe to declare on the whole // entry ONLY because of `runtime-gate.ts`'s baseline/candidate differential — @@ -1239,11 +1251,10 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ source: 'packages/lint/src/validate-rls-predicate-enforceability.ts', surfaces: CLI_ONLY, surfaceReason: - 'P2 (#4463): the rule reads `stack.permissions[]`, a stack-wide collection the per-write snapshot ' - + 'does not carry, and P1 gates `flow` alone. It is otherwise snapshot-ready — it needs no other ' - + 'collection — so widening it is a `runtimeTypes: [\'permission_set\']` edit once the gate builds ' - + 'that snapshot, not new wiring. Recorded as pending rather than done, because a rule that has ' - + 'never run at a door should not claim it.', + 'The rule reads `stack.permissions[]`, which the per-write snapshot DOES carry since #8309 — ' + + 'the remaining gap is only the declaration: no `runtimeTypes` names `permission` here, and that ' + + 'flip is a rollout decision on #8310\'s axis, not a wiring fix. Recorded as pending rather than ' + + 'done, because a rule that has never run at a door should not claim it.', run: (stack) => validateRlsPredicateEnforceability(stack), }, // #4762 — the same "declared but enforces nothing" question, for the two diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index c42cfb5215..175ff88cff 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -627,6 +627,7 @@ export type { // `@objectstack/lint/runtime` subpath — the entry the kernel boot path imports, // so a consumer there never names the graph that reaches the source parsers. export { + buildRuntimeWriteSnapshots, runRuntimeAuthoringRules, runtimeAuthoringRulesFor, runtimeGatedTypes, diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index e7d228b426..a3fa85eb23 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect } from 'vitest'; import { + buildRuntimeWriteSnapshots, runRuntimeAuthoringRules, runtimeAuthoringRulesFor, runtimeGatedTypes, @@ -196,6 +197,61 @@ describe('runtime publish gate (#4463)', () => { expect(runtimeGatedTypes()).toContain('flow'); expect(stackKeyForType('flow')).toBe('flows'); expect(stackKeyForType('no_such_type')).toBeNull(); + // [#8309] The two mappings that landed ahead of their registration (#8310) + // — the `seed` order (#7576 → #8307) repeated. Inert until a rule declares + // the type, because dispatch filters by `runtimeTypes` before this table. + expect(stackKeyForType('permission')).toBe('permissions'); + expect(stackKeyForType('book')).toBe('books'); + }); + + // ── #8309: the per-write snapshot carries the sibling collections ──── + + describe('buildRuntimeWriteSnapshots (#8309)', () => { + const context = { + objects: [{ name: 'acct' }], + permissions: [{ name: 'ops' }, { name: 'sales' }], + books: [{ name: 'guide' }], + }; + + it('returns null for an unmapped type or a non-object body', () => { + expect(buildRuntimeWriteSnapshots({ type: 'translation', item: { name: 'x' }, context })).toBeNull(); + expect(buildRuntimeWriteSnapshots({ type: 'flow', item: 'not-an-object', context })).toBeNull(); + }); + + it('carries every context collection IDENTICALLY in both passes for a non-context write', () => { + // The isolation property PR #8390's seed proof rests on, now for all + // three collections: a `flow` write may only differ from its baseline in + // its own collection, so any finding derived from objects/permissions/ + // books cancels in the gate's diff. + const s = buildRuntimeWriteSnapshots({ type: 'flow', item: { name: 'f1' }, context })!; + for (const key of ['objects', 'permissions', 'books'] as const) { + expect(s.baseline[key]).toEqual(context[key]); + expect(s.candidate[key]).toEqual(context[key]); + } + expect(s.baseline).not.toHaveProperty('flows'); + expect(s.candidate.flows).toEqual([{ name: 'f1' }]); + }); + + it('REPLACES the stored self for a write into a context collection', () => { + // The replace-not-erase rule, generalized from `objects` to every + // context collection: updating `ops` judges a universe with ONE `ops`. + const s = buildRuntimeWriteSnapshots({ + type: 'permission', + item: { name: 'ops', objects: {} }, + context, + })!; + expect(s.baseline.permissions).toEqual([{ name: 'sales' }]); + expect(s.candidate.permissions).toEqual([{ name: 'sales' }, { name: 'ops', objects: {} }]); + // The sibling collections ride along untouched, in both passes. + expect(s.baseline.objects).toEqual(context.objects); + expect(s.candidate.books).toEqual(context.books); + }); + + it('an absent context still yields empty collections, never a throw', () => { + const s = buildRuntimeWriteSnapshots({ type: 'book', item: { name: 'b1' } })!; + expect(s.baseline).toEqual({ objects: [], permissions: [], books: [] }); + expect(s.candidate.books).toEqual([{ name: 'b1' }]); + }); }); it('a rule that throws degrades to a warning instead of failing the write', () => { diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index e1f7217104..2dbaa3b240 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -91,9 +91,30 @@ const TYPE_TO_STACK_KEY: Readonly> = { // over. The ADR-0091 seed pair now DOES declare `seed` (#8307), so this // mapping is load-bearing today, not merely inert-and-correct. seed: 'data', + // [#8309] `permission`/`book` map ahead of their registration (#8310), the + // same order `seed` arrived in: the mapping plus the enriched snapshot below + // are this card's halves, and the `runtimeTypes` flip is deliberately NOT — + // a mapping without a declaring rule is inert by construction (the gate + // filters by `runtimeTypes` before it ever consults this table), while a + // declaration without the mapping is the wired-onto-nothing state the wiring + // guard refuses. Landing the mapping first keeps #8310 a registry data edit. + permission: 'permissions', + book: 'books', }; -/** Everything the gate needs from the host runtime to build a snapshot. */ +/** + * Everything the gate needs from the host runtime to build a snapshot. + * + * [#8309] Each key here doubles as the stack key the collection occupies in + * the per-write snapshot — see {@link CONTEXT_STACK_KEYS}, which is derived + * from this shape and keeps the two from drifting. The set is deliberately + * BOUNDED to what the runtime-wired rules actually read (measured, not + * projected): the three cross-collection security rules compare + * objects × permissions × books, and nothing on the runtime surface reads + * `positions` / `apps` — so those are NOT carried. Widening the snapshot is a + * one-key edit here plus a `CONTEXT_STACK_KEYS` entry, made when a rule that + * reads the collection actually crosses the wall, never in advance. + */ export interface RuntimeStackContext { /** * The live object declarations (registry + tenant overlay), as authored. @@ -104,8 +125,37 @@ export interface RuntimeStackContext { * package's config file and has to hedge. */ objects?: readonly unknown[]; + /** + * The live permission-set declarations (stack key `permissions`). + * + * [#8309] The collection the three cross-collection security rules compare + * against. Without it a per-write snapshot holds exactly ONE permission set + * (the written item), so `security-master-detail-ungranted` reads every + * detail object the tenant's OTHER sets grant as ungranted — measured as 38 + * phantom findings per-write vs 4 whole-stack over the shipped corpus + * (PR #7886). `security-book-audience-unknown-set` needs the set NAMES to + * resolve a book's `audience.permissionSet` for the same reason. + */ + permissions?: readonly unknown[]; + /** + * The live documentation-book declarations (stack key `books`). + * + * [#8309] Carried so a `book` write is judged with its siblings present and + * replace-not-erase semantics apply to it (an updated book must not read as + * a second book of the same name), and so book-derived findings cancel in + * the differential for every other write type. + */ + books?: readonly unknown[]; } +/** + * The context collections the snapshot carries, in stack-key order. Derived + * facts: every entry is a key of {@link RuntimeStackContext} AND a stack key + * some runtime-wired rule reads (`runtime-gate.test.ts` pins membership). + */ +const CONTEXT_STACK_KEYS = ['objects', 'permissions', 'books'] as const satisfies + readonly (keyof RuntimeStackContext)[]; + /** One rule's verdict at the runtime surface, carrying which rule produced it. */ export interface RuntimeGateResult { /** Findings the written item ADDED, severity `error` — the reason to refuse the write. */ @@ -146,6 +196,70 @@ export function stackKeyForType(type: string): string | null { /** Stable identity of a finding, so two rule passes can be set-differenced. */ const fingerprint = (f: AuthoringFinding) => `${f.rule}\u0000${f.where}\u0000${f.path}\u0000${f.message}`; +/** + * The baseline/candidate stack pair the gate judges one write against, or + * `null` when no snapshot can be built (unmapped type, non-object body). + * + * Exported (#8309) so the tests that measure per-write vs whole-stack + * agreement exercise the REAL construction instead of a hand-kept mirror — + * `validate-security-posture.runtime-surface.test.ts` used to mirror this + * logic in a local `wouldGateAdd`, which is exactly the drift surface a + * snapshot change here would have missed. + * + * Shape: + * - The baseline carries every context collection ({@link CONTEXT_STACK_KEYS}) + * WITHOUT the written item. Anything found there is somebody else's + * pre-existing condition and is not this write's to answer for (#4463 D4 — + * the gate blocks new writes, never stored rows). Carrying the sibling + * collections in BOTH passes is what makes their findings cancel in the + * diff — and what gives the cross-collection rules the sibling collection + * they compare against, so the per-write verdict agrees with the + * whole-stack one instead of inventing findings (the 38-vs-4 measurement, + * PR #7886). + * - The candidate is the same context with this write's item added. When the + * written type IS one of the context collections (an `object`, `permission` + * or `book` write), the item REPLACES its stored self rather than appearing + * beside it — otherwise an update reads as a duplicate name, and for + * `objects` every lookup in the tenant's model would read as dangling. For + * any other type the item is the sole member of its own collection, so + * index-0 paths in the findings are unambiguously this write. + * + * Cost (the #4463 D2 question, measured rather than assumed): built per + * write, never cached. The construction is one filter + one spread over the + * written type's collection; the sibling collections are passed by reference. + * Over the shipped corpus (30 objects, 10 permission sets, 1 book) that is + * microseconds on a PUBLISH (never a draft autosave, D1) — a cache would buy + * nothing and would need cross-org invalidation the gate has no seam for. + */ +export function buildRuntimeWriteSnapshots(args: { + /** Singular metadata type of the item being written. */ + type: string; + /** The item body as it will be persisted. */ + item: unknown; + /** Live resolution context from the host runtime. */ + context?: RuntimeStackContext; +}): { baseline: AnyRec; candidate: AnyRec } | null { + const stackKey = stackKeyForType(args.type); + if (!stackKey) return null; + if (!args.item || typeof args.item !== 'object') return null; + + const item = args.item as AnyRec; + const itemName = typeof item.name === 'string' ? item.name : undefined; + + const baseline: AnyRec = {}; + for (const key of CONTEXT_STACK_KEYS) { + const collection = (args.context?.[key] ?? []) as readonly AnyRec[]; + baseline[key] = key === stackKey + ? collection.filter((o) => !itemName || o?.name !== itemName) + : collection; + } + const candidate: AnyRec = { + ...baseline, + [stackKey]: [...((baseline[stackKey] as readonly AnyRec[] | undefined) ?? []), item], + }; + return { baseline, candidate }; +} + function runRules( rules: readonly AuthoringRule[], stack: AnyRec, @@ -199,38 +313,22 @@ export function runRuntimeAuthoringRules(args: { const empty: RuntimeGateResult = { errors: [], advisories: [], rulesRun: [] }; if (rules.length === 0) return empty; - const stackKey = stackKeyForType(args.type); - if (!stackKey) return empty; - if (!args.item || typeof args.item !== 'object') return empty; + // The baseline/candidate construction — replace-not-erase for a write into + // a context collection, the written item as sole member of its own + // collection otherwise, every context collection present in BOTH passes so + // sibling-derived findings cancel in the diff. See the builder's own + // docblock; it is exported precisely so tests exercise this construction + // and not a mirror of it. + const snapshots = buildRuntimeWriteSnapshots({ + type: args.type, + item: args.item, + ...(args.context !== undefined ? { context: args.context } : {}), + }); + if (!snapshots) return empty; - const item = args.item as AnyRec; - const itemName = typeof item.name === 'string' ? item.name : undefined; - const contextObjects = (args.context?.objects ?? []) as AnyRec[]; const ctx: AuthoringRuleContext = { sduiManifest: args.sduiManifest }; - - // When the written type IS the context collection (an `object` write), the - // item must REPLACE its stored self rather than erase the other objects — - // otherwise every lookup in the tenant's model reads as dangling. Written - // generally so widening `runtimeTypes` to `object` is a data edit, not a - // rewrite of this function. - const writesIntoContext = stackKey === 'objects'; - const baselineObjects = writesIntoContext - ? contextObjects.filter((o) => !itemName || o?.name !== itemName) - : contextObjects; - - // Baseline: the resolution context WITHOUT the written item. Anything found - // here is somebody else's pre-existing condition and is not this write's to - // answer for (#4463 D4 — the gate blocks new writes, never stored rows). - const baseline: AnyRec = { objects: baselineObjects }; - // Candidate: the same context with this write's item added. For a non-object - // type it is the SOLE member of its own collection, so index-0 paths in the - // findings are unambiguously this write. - const candidate: AnyRec = writesIntoContext - ? { objects: [...baselineObjects, item] } - : { objects: baselineObjects, [stackKey]: [item] }; - - const before = new Set(runRules(rules, baseline, ctx).map(fingerprint)); - const added = runRules(rules, candidate, ctx).filter((f) => !before.has(fingerprint(f))); + const before = new Set(runRules(rules, snapshots.baseline, ctx).map(fingerprint)); + const added = runRules(rules, snapshots.candidate, ctx).filter((f) => !before.has(fingerprint(f))); return { errors: added.filter((f) => f.severity === 'error'), diff --git a/packages/lint/src/runtime.ts b/packages/lint/src/runtime.ts index 3b49359027..42c47a8a03 100644 --- a/packages/lint/src/runtime.ts +++ b/packages/lint/src/runtime.ts @@ -22,6 +22,7 @@ */ export { + buildRuntimeWriteSnapshots, runRuntimeAuthoringRules, runtimeAuthoringRulesFor, runtimeGatedTypes, diff --git a/packages/lint/src/validate-security-posture.runtime-surface.test.ts b/packages/lint/src/validate-security-posture.runtime-surface.test.ts index 83901bb8d3..1aa3554168 100644 --- a/packages/lint/src/validate-security-posture.runtime-surface.test.ts +++ b/packages/lint/src/validate-security-posture.runtime-surface.test.ts @@ -16,21 +16,23 @@ // nothing wires them), and the seed cases now measure the REAL gate instead of // a mirror of it. // -// ## Why cases 1-3 still run a MIRROR of the gate +// ## Why cases about unwired types consume the REAL snapshot builder // // No rule declares `object` / `permission` / `book` in `runtimeTypes` today — // that is the state #8310 is about — so `runRuntimeAuthoringRules` cannot be // asked what this block would find there: it filters the registry by declared -// type and correctly returns nothing for them. The snapshot construction is -// therefore mirrored from `runtime-gate.ts` in `wouldGateAdd()` below, and -// `the mirror still matches the real gate` pins the two together against a type -// that IS wired, so the mirror cannot drift into measuring something the gate -// would not do. +// type and correctly returns nothing for them. `wouldGateAdd()` below +// therefore drives `buildRuntimeWriteSnapshots` — the gate's OWN construction, +// exported under #8309 — through this block directly. Before #8309 this file +// kept a hand-written mirror of the snapshot logic instead, which is exactly +// the drift surface #8309's snapshot change would have invalidated silently; +// `the mirror still matches the real gate` keeps pinning builder-vs-gate +// parity against a type that IS wired. // // ## What each case is evidence FOR // // 1. `object` / `permission` / `book` / `position` / `app` still reach no rule -// here — the residual state #8310 will change, stated once so the mirrored +// here — the residual state #8310 will change, stated once so the builder // cases below read as "what would happen", not "what happens". // 2. `an OWD-less object write would be REFUSED` — the positive control for the // escalation. This is why the `object` half of the move is a strictness @@ -38,12 +40,14 @@ // runtime create door emits (`METADATA_CREATE_SEEDS.object` carries no // `sharingModel`), and declaring the type turned 26 writes into 422s across // 8 files of `@objectstack/metadata-protocol`'s suite when measured. -// 3. `a permission-set write INVENTS findings` — the positive control for the -// other blocker. The three cross-collection rules compare against a -// collection `RuntimeStackContext` does not carry, so the per-write verdict -// is not a narrower version of the whole-stack verdict: it is a different -// and wrong one. Over the shipped corpus this was 38 findings against the -// whole-stack run's 4; the fixture reproduces the mechanism in miniature. +// 3. `a permission-set write agrees with the whole-stack verdict` — #8309's +// acceptance. The three cross-collection rules compare against sibling +// collections the snapshot now carries (`permissions`/`books`), so the +// per-write verdict for them equals the whole-stack one. The case ALSO +// reproduces the pre-#8309 defect in miniature (an objects-only context +// still invents the phantom finding — the 38-vs-4 mechanism, PR #7886), +// which is the reverse verification kept live in-tree: deleting the +// enrichment turns the agreement half red, in the predicted direction. // 4. `the ADR-0091 seed pair now crosses the runtime publish gate (#8307)` — // the pair enforces for `seed`-typed writes through the REAL gate, on the // `seed` → `data` stack key corrected under #7886/#8308, and the diff @@ -59,12 +63,15 @@ import { getMetadataCreateSeed } from '@objectstack/spec/kernel'; import { AUTHORING_RULES } from './authoring-rules.js'; import { + buildRuntimeWriteSnapshots, runRuntimeAuthoringRules, runtimeAuthoringRulesFor, runtimeGatedTypes, stackKeyForType, + type RuntimeStackContext, } from './runtime-gate.js'; import { + SECURITY_BOOK_AUDIENCE_UNKNOWN_SET, SECURITY_DELEGATION_MISSING_REASON, SECURITY_GRANT_EXPIRED_AT_AUTHORING, SECURITY_MASTER_DETAIL_UNGRANTED, @@ -82,33 +89,29 @@ const ENTRY = AUTHORING_RULES.find((r) => r.name === 'validateSecurityPosture')! const fingerprint = (f: SecurityFinding) => `${f.rule}\u0000${f.where}\u0000${f.path}\u0000${f.message}`; /** - * What the runtime publish gate WOULD attribute to one write landing on - * `stackKey`, against `contextObjects` as the live object universe. + * What the runtime publish gate WOULD attribute to one write of `type`, + * against `context` as the live universe — this block's verdict only. * - * Mirrors `runRuntimeAuthoringRules`: same baseline/candidate construction, - * same replace-not-erase rule when the written type IS the context collection, - * same set difference. Pinned against the real function below. - * - * Takes the stack KEY rather than the metadata type because two of the - * collections this block reads have no `TYPE_TO_STACK_KEY` entry at all — see - * `the gate cannot address every collection this block reads`, which measures - * that rather than papering over it with a speculative mapping. + * [#8309] Drives `buildRuntimeWriteSnapshots`, the gate's OWN baseline/ + * candidate construction, rather than a hand-kept mirror of it: same + * replace-not-erase rule when the written type IS a context collection, same + * set difference. `the mirror still matches the real gate (flow)` pins this + * helper against `runRuntimeAuthoringRules` on a wired type, so the two ways + * of asking cannot drift apart. */ -function wouldGateAdd(stackKey: string, item: AnyRec, contextObjects: AnyRec[] = []): SecurityFinding[] { - const itemName = typeof item.name === 'string' ? item.name : undefined; - const writesIntoContext = stackKey === 'objects'; - const baselineObjects = writesIntoContext - ? contextObjects.filter((o) => !itemName || o?.name !== itemName) - : contextObjects; - const baseline: AnyRec = { objects: baselineObjects }; - const candidate: AnyRec = writesIntoContext - ? { objects: [...baselineObjects, item] } - : { objects: baselineObjects, [stackKey]: [item] }; - const before = new Set(validateSecurityPosture(baseline).map(fingerprint)); - return validateSecurityPosture(candidate).filter((f) => !before.has(fingerprint(f))); +function wouldGateAdd(type: string, item: AnyRec, context: RuntimeStackContext = {}): SecurityFinding[] { + const snapshots = buildRuntimeWriteSnapshots({ type, item, context }); + expect(snapshots, `no snapshot for type '${type}' — is its TYPE_TO_STACK_KEY entry gone?`).not.toBeNull(); + const before = new Set(validateSecurityPosture(snapshots!.baseline).map(fingerprint)); + return validateSecurityPosture(snapshots!.candidate).filter((f) => !before.has(fingerprint(f))); } -/** Two permission sets that between them grant the detail — the ordinary shape. */ +/** + * Two permission sets that between them grant the detail, plus a book gated on + * one of them — the ordinary shape, and the miniature of PR #7886's 38-vs-4 + * measurement: any per-write snapshot that drops a sibling collection makes + * one of the three cross-collection rules invent a finding here. + */ const TWO_SET_STACK = { objects: [ { name: 'shop_invoice', label: 'Invoice', sharingModel: 'private', fields: { title: { type: 'text', label: 'T' } } }, @@ -130,6 +133,16 @@ const TWO_SET_STACK = { }, }, ], + books: [ + { name: 'billing_guide', label: 'Billing Guide', audience: { permissionSet: 'shop_billing' } }, + ], +}; + +/** {@link TWO_SET_STACK} as the gate's live-universe context. */ +const TWO_SET_CONTEXT: RuntimeStackContext = { + objects: TWO_SET_STACK.objects, + permissions: TWO_SET_STACK.permissions, + books: TWO_SET_STACK.books, }; describe('validateSecurityPosture at the runtime publish surface (#7576, crossed for `seed` under #8307)', () => { @@ -177,26 +190,33 @@ describe('validateSecurityPosture at the runtime publish surface (#7576, crossed expect(runtimeGatedTypes()).toContain('seed'); }); - it('the gate cannot address every collection this block reads', () => { - // A second, independent way the move is not a one-field edit: `permission` - // and `book` are runtime-creatable metadata types, and the gate's - // type→stack-key table does not name them at all. Declaring either in - // `runtimeTypes` without adding the mapping fails the wiring guard's - // `every runtime-gated metadata type maps to a stack key` case — which is - // the guard working, since without a mapping `runRuntimeAuthoringRules` - // returns an empty verdict and the rules would be wired onto nothing. - expect(stackKeyForType('permission')).toBeNull(); - expect(stackKeyForType('book')).toBeNull(); - // The two the block CAN reach today, one of them corrected under this card. + it('[#8309] the gate can now address `permission` and `book` — the wiring half of the card', () => { + // Until #8309 this case pinned the OPPOSITE: `stackKeyForType` answered + // null for both, so declaring either in `runtimeTypes` would have wired + // the rules onto nothing (the state the wiring guard's `every + // runtime-gated metadata type maps to a stack key` case refuses). The + // mappings now exist AHEAD of the registration — the `seed` order (#7576 → + // #8307) repeated — so #8310's flip is a registry data edit. The entries + // are inert until then: `runRuntimeAuthoringRules` filters by declared + // type before consulting the table, and case 1 above pins that nothing + // declares these types yet. + expect(stackKeyForType('permission')).toBe('permissions'); + expect(stackKeyForType('book')).toBe('books'); expect(stackKeyForType('object')).toBe('objects'); expect(stackKeyForType('seed')).toBe('data'); + // The collections this block reads that stay UNMAPPED, measured: only the + // excluded-by-family `security-role-word` reads `positions`/`apps`, so + // #8309 deliberately does not carry or map them (whole family or not at + // all — #8310's call). + expect(stackKeyForType('position')).toBeNull(); + expect(stackKeyForType('app')).toBeNull(); }); it('an OWD-less object write WOULD be refused — the strictness #8310 would escalate', () => { // The body `METADATA_CREATE_SEEDS.object` carried BEFORE #8308: name, // label, pluralLabel, fields — and no `sharingModel`. Kept literal as the // refusal's positive control. - const added = wouldGateAdd('objects', { name: 'new_object', label: 'New Object', fields: {} }); + const added = wouldGateAdd('object', { name: 'new_object', label: 'New Object', fields: {} }); expect(added.map((f) => f.rule)).toEqual([SECURITY_OWD_UNSET]); expect(added[0].severity).toBe('error'); expect(added[0].path).toBe('objects[0].sharingModel'); @@ -204,7 +224,7 @@ describe('validateSecurityPosture at the runtime publish surface (#7576, crossed // And the same write with the OWD authored is clean, so the refusal is // about the missing decision and not about object writes as such. expect( - wouldGateAdd('objects', { name: 'new_object', label: 'New Object', sharingModel: 'private', fields: {} }), + wouldGateAdd('object', { name: 'new_object', label: 'New Object', sharingModel: 'private', fields: {} }), ).toEqual([]); }); @@ -217,23 +237,62 @@ describe('validateSecurityPosture at the runtime publish surface (#7576, crossed // re-opens THIS pin rather than passing silently. const seed = getMetadataCreateSeed('object') as AnyRec; expect(seed.sharingModel).toBe('private'); - expect(wouldGateAdd('objects', seed)).toEqual([]); + expect(wouldGateAdd('object', seed)).toEqual([]); }); - it('a permission-set write INVENTS a finding the whole-stack run does not', () => { - // Whole stack: `shop_clerk` grants the detail, so nothing is ungranted. + it('[#8309] a permission-set write AGREES with the whole-stack verdict — the phantom is gone', () => { + // Whole stack: `shop_clerk` grants the detail and the book's audience + // resolves, so the three cross-collection rules find nothing. expect( validateSecurityPosture(TWO_SET_STACK).filter((f) => f.rule === SECURITY_MASTER_DETAIL_UNGRANTED), ).toEqual([]); - // One write of the set that does NOT grant it, against the same objects: - // the snapshot cannot see `shop_clerk`, so the detail reads as ungranted by - // anyone. The verdict is not narrower than the whole-stack one — it is - // different, and wrong. - const added = wouldGateAdd('permissions', TWO_SET_STACK.permissions[0], TWO_SET_STACK.objects); - expect(added.map((f) => f.rule)).toEqual([SECURITY_MASTER_DETAIL_UNGRANTED]); + // The pre-#8309 defect, kept executable as the in-tree reverse + // verification (predicted direction: MORE findings than whole-stack, not + // fewer): a context carrying objects only — exactly what the gate used to + // build — cannot see `shop_clerk`, so the detail reads as ungranted by + // anyone. This is PR #7886's 38-vs-4 mechanism in miniature; deleting the + // snapshot enrichment makes the agreement half below fail INTO this shape. + const phantoms = wouldGateAdd('permission', TWO_SET_STACK.permissions[0], { + objects: TWO_SET_STACK.objects, + }); + expect(phantoms.map((f) => f.rule)).toEqual([SECURITY_MASTER_DETAIL_UNGRANTED]); + expect(phantoms[0].severity).toBe('warning'); + expect(phantoms[0].where).toBe('object "shop_invoice_line"'); + + // #8309's acceptance: the same write against the FULL context — the + // snapshot the gate now builds — adds nothing, agreeing with the + // whole-stack run. Both directions: the set that does not grant the + // detail, and the one that does. + expect(wouldGateAdd('permission', TWO_SET_STACK.permissions[0], TWO_SET_CONTEXT)).toEqual([]); + expect(wouldGateAdd('permission', TWO_SET_STACK.permissions[1], TWO_SET_CONTEXT)).toEqual([]); + }); + + it('[#8309] a permission-set UPDATE replaces its stored self — no duplicate-name double set', () => { + // Replace-not-erase now applies to `permissions` the way it always did to + // `objects`: re-publishing `shop_clerk` unchanged must judge a universe + // with ONE `shop_clerk`, not two, and attribute nothing to the write. + expect(wouldGateAdd('permission', { ...TWO_SET_STACK.permissions[1] }, TWO_SET_CONTEXT)).toEqual([]); + }); + + it('[#8309] a book write resolves its audience against the live permission sets', () => { + // The third cross-collection rule. Against the full context the audience + // resolves and the write is clean — agreeing with the whole-stack run. + const book = { name: 'clerk_handbook', label: 'Clerk Handbook', audience: { permissionSet: 'shop_clerk' } }; + expect(wouldGateAdd('book', book, TWO_SET_CONTEXT)).toEqual([]); + + // A genuinely dangling audience is still caught — enrichment kills the + // phantom, not the rule. + const dangling = { name: 'ghost_guide', label: 'Ghost Guide', audience: { permissionSet: 'no_such_set' } }; + const added = wouldGateAdd('book', dangling, TWO_SET_CONTEXT); + expect(added.map((f) => f.rule)).toEqual([SECURITY_BOOK_AUDIENCE_UNKNOWN_SET]); expect(added[0].severity).toBe('warning'); - expect(added[0].where).toBe('object "shop_invoice_line"'); + + // And the pre-#8309 shape for books, kept as the reverse-verification + // twin: with no `permissions` in the context every `{ permissionSet }` + // audience read as unknown, so a CLEAN book write drew the same warning. + const phantoms = wouldGateAdd('book', book, { objects: TWO_SET_STACK.objects }); + expect(phantoms.map((f) => f.rule)).toEqual([SECURITY_BOOK_AUDIENCE_UNKNOWN_SET]); }); it('[#8307] the ADR-0091 seed pair is REFUSED at the real runtime gate, on the corrected `data` stack key', () => { @@ -256,7 +315,7 @@ describe('validateSecurityPosture at the runtime publish surface (#7576, crossed // real gate now that both are askable — this is the mirror/gate parity // pin `the mirror still matches the real gate (flow)` establishes for // `flow`, repeated for the type this card actually wires. - expect(wouldGateAdd(stackKeyForType('seed')!, expiredGrant).map((f) => f.rule)).toEqual( + expect(wouldGateAdd('seed', expiredGrant).map((f) => f.rule)).toEqual( real.errors.map((f) => f.rule), ); @@ -325,9 +384,9 @@ describe('validateSecurityPosture at the runtime publish surface (#7576, crossed // rules and the two seed rules are self-contained, and it is the THREE // cross-collection rules that need a snapshot the gate does not build. A // seed write reaches no permission-set rule and vice versa. - expect(wouldGateAdd('data', { object: 'crm_account', records: [{ name: 'a' }] })).toEqual([]); + expect(wouldGateAdd('seed', { object: 'crm_account', records: [{ name: 'a' }] })).toEqual([]); expect( - wouldGateAdd('objects', { + wouldGateAdd('object', { name: 'shop_line', label: 'Line', sharingModel: 'controlled_by_parent', diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ebc9e712c5..1e27536f9b 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3290,15 +3290,28 @@ export class ObjectStackProtocolImplementation implements // Resolution context. Best-effort: a host without a registry (a // metadata-only test double) still writes, it just gets the rules that // need no object universe. Never let context-gathering fail a write. - let objects: unknown[] = []; - try { - if (typeof this.engine.registry?.listItems === 'function') { - objects = [...this.engine.registry.listItems('object')]; - if (objects.length === 0) objects = [...this.engine.registry.listItems('objects')]; + // + // [#8309] `permissions`/`books` join `objects` — the sibling + // collections the three cross-collection security rules compare + // against (RuntimeStackContext's own docblock carries the 38-vs-4 + // measurement). Gathered PER WRITE like `objects` always was, never + // cached: the read is a registry map walk plus one array copy of item + // references, it runs only on an `active`-state publish (D1), and a + // cache would need invalidation across every org's overlay writes. + // Each collection is guarded independently so a registry that can + // answer one question still answers the others. + const listCollection = (singularType: string, pluralType: string): unknown[] => { + try { + if (typeof this.engine.registry?.listItems !== 'function') return []; + const items = [...this.engine.registry.listItems(singularType)]; + return items.length > 0 ? items : [...this.engine.registry.listItems(pluralType)]; + } catch { + return []; } - } catch { - objects = []; - } + }; + const objects = listCollection('object', 'objects'); + const permissions = listCollection('permission', 'permissions'); + const books = listCollection('book', 'books'); const verdict = evaluateRuntimeAuthoringGate({ type: singular, @@ -3306,6 +3319,8 @@ export class ObjectStackProtocolImplementation implements state: evt.state, body: evt.body, objects, + permissions, + books, ...(evt.organizationId !== undefined ? { organizationId: evt.organizationId } : {}), orgWallEnforced: this.orgWallEnforced(), }); diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index bc11e3a093..142806c4b7 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -398,6 +398,21 @@ export function evaluateRuntimeAuthoringGate(args: { body: unknown; /** Live object declarations, the resolution universe for the rules. */ objects?: readonly unknown[]; + /** + * [#8309] Live permission-set declarations — the sibling collection the + * three cross-collection security rules compare against. Without it a + * per-write snapshot holds exactly one permission set (the written item), + * which was measured inventing 38 phantom + * `security-master-detail-ungranted` findings per-write against the + * whole-stack run's 4 (PR #7886). + */ + permissions?: readonly unknown[]; + /** + * [#8309] Live documentation-book declarations, so a `book` write is + * judged with its siblings present and book-derived findings cancel in + * the gate's differential for every other write type. + */ + books?: readonly unknown[]; /** ADR-0080 SDUI manifest when the host has one. */ sduiManifest?: unknown; /** @@ -427,7 +442,11 @@ export function evaluateRuntimeAuthoringGate(args: { const result = runRuntimeAuthoringRules({ type: args.type, item: args.body, - context: { objects: args.objects ?? [] }, + context: { + objects: args.objects ?? [], + permissions: args.permissions ?? [], + books: args.books ?? [], + }, ...(args.sduiManifest !== undefined ? { sduiManifest: args.sduiManifest } : {}), });