From 0d4797e0058d9e09a26c69e9fc28a4b2d1ae1c3b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:52:10 +0000 Subject: [PATCH 1/5] fix(lint): the runtime gate's object-write baseline keeps the written item's stored self On an update into a context collection the gate now also judges the stored universe (the baseline with the written item's stored self at the slot the item takes in the candidate). A finding located on another entry that the stored universe already holds is no longer charged to the write; findings on the written item itself, and findings whose path names no locatable entry, are judged against the baseline alone, as before. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- packages/lint/src/runtime-gate.ts | 186 +++++++++++++++++++++++++----- 1 file changed, 155 insertions(+), 31 deletions(-) diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index 514c8275e10..a63a40e1edb 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -31,8 +31,8 @@ * * The rules are `(stack) => findings`. A runtime write is one ITEM. The gate * therefore builds a per-write snapshot — the written item, plus the live - * registry's objects as resolution context — and runs the rules TWICE: once on - * the context alone, once with the item grafted in. Only findings the item + * registry's objects as resolution context — and runs the rules on the + * context alone and again with the item grafted in. Only findings the item * ADDED are attributable to this write. * * That is not defensive padding, it is the D4 requirement made structural: @@ -45,8 +45,26 @@ * subtraction, saving flow A would 422 because object B — untouched, already * stored, possibly shipped in a package — has a bad predicate. * - * The cost is two passes over a small in-memory snapshot, on a PUBLISH (not on - * a draft autosave). That is the correct place to spend it. + * ## What "added" means, exactly (#22118) + * + * The gate blocks new writes, never another stored row. Precisely: + * + * - a finding located on ANOTHER entry is this write's only when neither the + * universe without the item nor the stored universe already holds it. On an + * update into a context collection the stored universe includes the written + * item's stored self, so a stored detail's condition that only shows when + * its master is present is not charged to a label-only save of the master; + * - a finding located on the written item itself is this write's whenever the + * universe without the item does not hold it — whatever the stored row held. + * Re-saving a row is writing it; + * - a finding whose path names no entry the gate can locate + * ({@link isLocatedOnAnotherEntry}) is judged as one on the written item. + * That direction can leave a sibling's finding charged to a write; it can + * never wave the written item's own finding through. + * + * The cost is two passes over a small in-memory snapshot — three on an update + * into a context collection — on a PUBLISH (not on a draft autosave). That is + * the correct place to spend it. */ import { @@ -572,8 +590,8 @@ export function stackKeyForType(type: string): string | null { 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). + * The snapshots 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 — @@ -584,27 +602,44 @@ const fingerprint = (f: AuthoringFinding) => `${f.rule}\u0000${f.where}\u0000${f * 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). + * pre-existing condition and is not this write's to answer for (#4463 D4). + * 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 + * written type IS one of the context collections (an `object`, `permission`, + * `book` or `dataset` 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. + * - [#22118] `stored` is the STORED universe: the baseline with the written + * item's stored self put back, at the slot the item takes in the candidate. + * Present only on an UPDATE into a context collection — a create has no + * stored self, and a non-context type's item is never carried as context — + * so in every other case the gate runs exactly the two passes it always ran. + * It exists because the baseline alone answers the wrong question for a + * SIBLING's finding that needs the written item present: a detail's + * `lookupColumns` entry is judged against its master only when the master + * is in the snapshot, so with the master dropped the baseline cannot hold + * the finding, and a label-only master save was charged with a stored + * detail's condition. The stored universe holds it. + * Its slot is the point: every sibling sits at the same index in all three + * snapshots, and the stored self sits where the candidate puts the item, + * so a positional path names the same entry in `stored` as in `candidate`. + * {@link runRuntimeAuthoringRules} reads only the `stored` findings located + * on ANOTHER entry; the stored self's own findings never cancel anything. * * 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. + * nothing and would need cross-org invalidation the gate has no seam for. An + * update into a context collection pays a third pass of the same size, for + * `stored`. */ export function buildRuntimeWriteSnapshots(args: { /** Singular metadata type of the item being written. */ @@ -619,7 +654,7 @@ export function buildRuntimeWriteSnapshots(args: { * rules the WHOLE `objects` collection, exactly as before. */ packageScope?: RuntimePackageScope; -}): { baseline: AnyRec; candidate: AnyRec } | null { +}): { baseline: AnyRec; candidate: AnyRec; stored?: AnyRec } | null { const stackKey = stackKeyForType(args.type); if (!stackKey) return null; if (!args.item || typeof args.item !== 'object') return null; @@ -628,6 +663,10 @@ export function buildRuntimeWriteSnapshots(args: { const itemName = typeof item.name === 'string' ? item.name : undefined; const baseline: AnyRec = {}; + // [#22118] The written item's stored self(ves), in stored order — what the + // filter below drops from the baseline. Two only when two stored entries + // (illegitimately) share the name; both go back into `stored`. + let storedSelf: readonly AnyRec[] = []; for (const key of CONTEXT_STACK_KEYS) { // [#9612] `objects` — and only `objects` — is reduced to the written // item's package closure. The other collections are already bounded @@ -646,15 +685,77 @@ export function buildRuntimeWriteSnapshots(args: { const collection = key === 'objects' ? (narrowObjectsToPackageClosure(raw, args.packageScope) as readonly AnyRec[]) : raw; - baseline[key] = key === stackKey - ? collection.filter((o) => !itemName || o?.name !== itemName) - : collection; + if (key === stackKey) { + baseline[key] = collection.filter((o) => !itemName || o?.name !== itemName); + if (itemName) storedSelf = collection.filter((o) => o?.name === itemName); + } else { + baseline[key] = collection; + } } - const candidate: AnyRec = { - ...baseline, - [stackKey]: [...((baseline[stackKey] as readonly AnyRec[] | undefined) ?? []), item], - }; - return { baseline, candidate }; + const siblings = (baseline[stackKey] as readonly AnyRec[] | undefined) ?? []; + const candidate: AnyRec = { ...baseline, [stackKey]: [...siblings, item] }; + if (storedSelf.length === 0) return { baseline, candidate }; + const stored: AnyRec = { ...baseline, [stackKey]: [...siblings, ...storedSelf] }; + return { baseline, candidate, stored }; +} + +/** + * Whether a finding's RAW path (positional, as the rules emit it) positively + * names a collection entry other than the written item (#22118). + * + * It answers the ruling's "located on" question for the third pass, and it + * answers it once for every rule — ⛔ no rule name appears here, and none may: + * a per-rule exemption would be a second policy beside the one differential + * every door rule reads. It reads the three spellings the door's rules locate + * an entry with: + * + * - **positional** — `objects[3].fields.m.lookupColumns[0]`: the entry at + * that index of a collection the snapshot carries. The written item is the + * one at `writtenSlot` of its own collection ({@link buildRuntimeWriteSnapshots} + * puts the stored self and the item there, and every sibling at the same + * index in every snapshot); + * - **name-keyed** — `objects.acme_invoice.validations.x.regex`: the entry + * of that name in a collection the snapshot carries; + * - **an object named in prose** — `object 'fx_detail' · field 'qty' + * readonlyWhen`, the `path` a rule carrying its `where` as its path emits. + * Only the `object` spelling is read: it is the one in use, and the object + * collection is the one a sibling's finding most often needs the written + * item for. + * + * Anything else — a path into a collection the snapshot does not carry, a + * rule's source file on an `authoring-rule-threw` finding, prose naming + * something other than an object — is NOT positively located, and answers + * `false`. That is the deliberate direction: a finding the gate cannot locate + * keeps today's verdict (judged against the baseline alone), so a location the + * gate fails to read can leave a sibling's finding charged to a write, and can + * never wave a written item's own finding through. + */ +export function isLocatedOnAnotherEntry( + path: string, + args: { + /** The snapshot the finding came from (its collections decide what a path can name). */ + snapshot: AnyRec; + /** The written item's stack key. */ + stackKey: string; + /** The written item's name. */ + itemName: string; + /** The written item's index in its own collection. */ + writtenSlot: number; + }, +): boolean { + const positional = /^([A-Za-z_][A-Za-z0-9_]*)\[(\d+)\]/.exec(path); + if (positional && Array.isArray(args.snapshot[positional[1]!])) { + return !(positional[1] === args.stackKey && Number(positional[2]) === args.writtenSlot); + } + const named = /^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)(?=[.[]|$)/.exec(path); + if (named && Array.isArray(args.snapshot[named[1]!])) { + return !(named[1] === args.stackKey && named[2] === args.itemName); + } + const prose = /^object (['"])([A-Za-z_][A-Za-z0-9_]*)\1(?=\s|$)/.exec(path); + if (prose) { + return !(args.stackKey === stackKeyForType('object') && prose[2] === args.itemName); + } + return false; } /** @@ -958,8 +1059,9 @@ export function runRuntimeAuthoringRules(args: { // 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 + // collection otherwise, every context collection present in EVERY pass so + // sibling-derived findings cancel in the diff, and (#22118) the stored + // universe on an update into a context collection. See the builder's own // docblock; it is exported precisely so tests exercise this construction // and not a mirror of it. const snapshots = buildRuntimeWriteSnapshots({ @@ -981,7 +1083,10 @@ export function runRuntimeAuthoringRules(args: { // [#20611] Spelled against the CANDIDATE, the one snapshot that holds the // written item. The baseline pass shares the set and cannot match it: the // item is not in the baseline, and every other entry sits at an index the - // item does not. + // item does not. [#22118] The `stored` pass shares it too, and there it + // CAN match — the stored self sits at the item's index — but only + // findings located on the written item are affected, and the differential + // never reads those from that pass. ...(args.restoredCredentialPaths !== undefined && args.restoredCredentialPaths.length > 0 ? { restoredCredentialPaths: restoredCredentialStackPaths( @@ -993,6 +1098,25 @@ export function runRuntimeAuthoringRules(args: { : {}), }; const before = new Set(runRules(rules, snapshots.baseline, ctx).map(fingerprint)); + // [#22118] The stored universe answers for a sibling's finding that needs + // the written item present: one located on ANOTHER entry that the stored + // universe already holds is not new, so it is not this write's. Findings + // located on the written item are never read from this pass — the stored + // self's own condition cancels nothing — so they are judged as before, + // against the baseline alone. Positional paths compare across the passes + // because `stored` puts the stored self at the item's slot. + if (snapshots.stored) { + const stackKey = stackKeyForType(args.type)!; + const located = { + snapshot: snapshots.stored, + stackKey, + itemName: (args.item as AnyRec).name as string, + writtenSlot: (snapshots.candidate[stackKey] as readonly unknown[]).length - 1, + }; + for (const f of runRules(rules, snapshots.stored, ctx)) { + if (isLocatedOnAnotherEntry(f.path, located)) before.add(fingerprint(f)); + } + } const added = runRules(rules, snapshots.candidate, ctx) .filter((f) => !before.has(fingerprint(f))) // [commit def0d3e63] The wire shape: collection-resident findings key their From 21e0f16fbb806fe975b84aadfe3b48843c2e9155 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:58:27 +0000 Subject: [PATCH 2/5] =?UTF-8?q?test(lint):=20pin=20the=20stored-self=20bas?= =?UTF-8?q?eline=20=E2=80=94=20two=20measured=20cases,=20both=20controls,?= =?UTF-8?q?=20create=20and=20permission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- .../runtime-gate.stored-self-baseline.test.ts | 329 ++++++++++++++++++ packages/lint/src/runtime-gate.ts | 19 +- 2 files changed, 338 insertions(+), 10 deletions(-) create mode 100644 packages/lint/src/runtime-gate.stored-self-baseline.test.ts diff --git a/packages/lint/src/runtime-gate.stored-self-baseline.test.ts b/packages/lint/src/runtime-gate.stored-self-baseline.test.ts new file mode 100644 index 00000000000..c5061640eac --- /dev/null +++ b/packages/lint/src/runtime-gate.stored-self-baseline.test.ts @@ -0,0 +1,329 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22118 — the object-write differential judges a sibling's finding against + * the STORED universe, which includes the written object's stored self. + * + * ## The state this closes + * + * `buildRuntimeWriteSnapshots` drops the written item's stored self from the + * baseline, so the baseline is the universe WITHOUT the written object. A + * stored sibling whose finding only shows when that object is present — a + * detail's `lookupColumns` entry, judged against its master only when the + * master is in the snapshot; a detail's `readonlyWhen` read through `parent` + * — therefore had its finding absent from the baseline and present in the + * candidate, and a label-only save of the master answered 422 for a detail + * the author never touched. That contradicts the gate's own contract: a + * stored object already in violation is never charged to someone else's write. + * + * ## The ruling this pins + * + * - The baseline keeps the written object's stored self: a finding located on + * another object that already exists against the stored universe is not new. + * - Findings located on the written object itself are judged as before. + * - No per-rule exemptions: every door rule reads the one differential, so + * the location is read off the finding's path spelling, never its rule. + * + * The protocol-level half — the same verdicts through the real `saveMetaItem`, + * with the 422 envelope's `code` and `status` — is the #22118 block of + * `packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`. + */ +import { describe, expect, it } from 'vitest'; +import type { AuthoringFinding } from './authoring-rules.js'; +import { + buildRuntimeWriteSnapshots, + isLocatedOnAnotherEntry, + runRuntimeAuthoringRules, + runtimeAuthoringRulesFor, +} from './runtime-gate.js'; + +type Fields = Record>; + +/** The master. `sharingModel` keeps `security-owd-unset` quiet on every fixture here. */ +const master = (fields: Fields = {}, over: Record = {}) => ({ + name: 'fx_master', + label: 'Master', + sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Name' }, + code: { type: 'text', label: 'Code' }, + status: { type: 'text', label: 'Status' }, + acct: { type: 'lookup', label: 'Account', reference: 'fx_account' }, + ...fields, + }, + ...over, +}); + +/** The master as the author re-saves it: only its label changed. */ +const relabelled = (stored: ReturnType) => ({ ...stored, label: 'Master (renamed)' }); + +/** The master without one of its fields. */ +const without = (stored: ReturnType, field: string) => { + const fields = { ...stored.fields } as Fields; + delete fields[field]; + return { ...stored, label: 'Master (renamed)', fields }; +}; + +const account = { + name: 'fx_account', + label: 'Account', + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name' } }, +}; + +/** Measured case 1: a lookup into the master whose picker names `columns`. */ +const pickerDetail = (columns: string[]) => ({ + name: 'fx_detail2', + label: 'Picker Detail', + sharingModel: 'private', + fields: { m: { type: 'lookup', label: 'Master', reference: 'fx_master', lookupColumns: columns } }, +}); + +/** Measured case 2: a detail (one `master_detail`) whose `readonlyWhen` reads through `parent`. */ +const parentDetail = (predicate: string) => ({ + name: 'fx_detail', + label: 'Parent Detail', + sharingModel: 'private', + fields: { + hdr: { type: 'master_detail', label: 'Header', reference: 'fx_master' }, + qty: { type: 'number', label: 'Quantity', readonlyWhen: predicate }, + }, +}); + +const gateObject = (item: unknown, objects: unknown[]) => + runRuntimeAuthoringRules({ type: 'object', item, context: { objects } }); + +/** The door's own rules run over ONE snapshot — what that snapshot holds, not a verdict. */ +const findingsIn = (stack: Record, type = 'object'): AuthoringFinding[] => + runtimeAuthoringRulesFor(type).flatMap((r) => r.run(stack, { runtimeWriteType: type })); + +const dump = (r: unknown) => JSON.stringify(r, null, 2); + +/** The two measured cases: the stored detail, and the finding it carries. */ +const MEASURED = [ + { + label: 'an unknown `lookupColumns` entry on a detail', + detail: pickerDetail(['nope_col']), + rule: 'object-field-ref-unknown', + rawPath: 'objects[1].fields.m.lookupColumns[0]', + }, + { + label: 'a `readonlyWhen` read through `parent`', + detail: parentDetail("parent.acct.name == 'x'"), + rule: 'expression-invalid', + rawPath: "object 'fx_detail' · field 'qty' readonlyWhen", + }, +] as const; + +describe('#22118 — a label-only master save is not charged with a stored detail\'s finding', () => { + for (const { label, detail, rule, rawPath } of MEASURED) { + it(`⭐ RESOLVES — ${label}`, () => { + const stored = [master(), account, detail]; + const snapshots = buildRuntimeWriteSnapshots({ + type: 'object', + item: relabelled(master()), + context: { objects: stored }, + })!; + + // Non-vacuous: the detail's finding is NEW against the universe without + // the master — the state that answered 422 — and the stored universe + // already holds it, at the same raw path. + const at = (stack: Record) => + findingsIn(stack).filter((f) => f.rule === rule && f.path === rawPath); + expect(at(snapshots.baseline), dump(findingsIn(snapshots.baseline))).toEqual([]); + expect(at(snapshots.candidate), dump(findingsIn(snapshots.candidate))).toHaveLength(1); + expect(at(snapshots.stored!), dump(findingsIn(snapshots.stored!))).toHaveLength(1); + + const result = gateObject(relabelled(master()), stored); + + expect(result.rulesRun.length).toBeGreaterThan(0); + expect(result.errors, dump(result)).toEqual([]); + expect(result.advisories, dump(result)).toEqual([]); + }); + } +}); + +describe('#22118 — the controls: what the write changes is still the write\'s', () => { + it('⭐ CONTROL — a write that newly breaks a sibling is still REFUSED (positional path)', () => { + // The stored master has `code`, so the stored universe holds no finding; + // the write removes it, so the picker column now names nothing. + const result = gateObject(without(master(), 'code'), [master(), account, pickerDetail(['code'])]); + + expect(result.errors, dump(result)).toEqual([ + expect.objectContaining({ + severity: 'error', + rule: 'object-field-ref-unknown', + path: 'objects.fx_detail2.fields.m.lookupColumns[0]', + }), + ]); + expect(result.errors[0]!.message).toContain('"code" is not a field on object "fx_master"'); + }); + + it('⭐ CONTROL — a write that newly breaks a sibling is still REFUSED (an object named in prose)', () => { + // The stored master's `status` is text, so `parent.status.name` traverses + // nothing; the write turns it into a lookup, and the detail's predicate + // now reads through a reference field. + const before = master(); + const after = master({ status: { type: 'lookup', label: 'Status', reference: 'fx_account' } }, { label: 'Master (renamed)' }); + const result = gateObject(after, [before, account, parentDetail("parent.status.name == 'x'")]); + + const where = "object 'fx_detail' · field 'qty' readonlyWhen"; + expect(result.errors, dump(result)).toEqual([ + expect.objectContaining({ severity: 'error', rule: 'expression-invalid', where, path: where }), + ]); + expect(result.errors[0]!.message).toContain('through `parent.status`'); + }); + + // The written object's own finding, carried identically by its stored self: + // one body per path spelling the location reader reads, so each arm's + // written-item answer is exercised through the gate. + const OWN = [ + { + spelling: 'positional', + fields: { acct: { type: 'lookup', label: 'Account', reference: 'fx_account', lookupColumns: ['nope'] } }, + over: {}, + rule: 'object-field-ref-unknown', + path: 'objects.fx_master.fields.acct.lookupColumns[0]', + }, + { + spelling: 'name-keyed', + fields: {}, + over: { validations: [{ name: 'code_shape', type: 'format', field: 'code', regex: '(', message: 'Bad code' }] }, + rule: 'validation-rule-regex-uncompilable', + path: 'objects.fx_master.validations.code_shape.regex', + }, + { + spelling: 'an object named in prose', + fields: { code: { type: 'text', label: 'Code', readonlyWhen: "record.acct.name == 'x'" } }, + over: {}, + rule: 'expression-invalid', + path: "object 'fx_master' · field 'code' readonlyWhen", + }, + ] as const; + + for (const { spelling, fields, over, rule, path } of OWN) { + it(`⭐ CONTROL — a finding on the written object itself is still REFUSED, though its stored self carries it (${spelling})`, () => { + const stored = master(fields as Fields, over); + // Non-vacuous: the stored self carries the identical finding, so a + // baseline that let it cancel would wave this write through. + const snapshots = buildRuntimeWriteSnapshots({ + type: 'object', + item: relabelled(stored), + context: { objects: [stored, account] }, + })!; + expect(findingsIn(snapshots.stored!).filter((f) => f.rule === rule).length).toBeGreaterThan(0); + + const result = gateObject(relabelled(stored), [stored, account]); + + expect(result.errors, dump(result)).toEqual([expect.objectContaining({ severity: 'error', rule, path })]); + }); + } + + it('a CREATE is judged as before: no stored self, so the stored universe is the baseline', () => { + // Creating the master makes the stored detail's picker column judgeable; + // the stored universe never held that finding, so it is this write's. + const snapshots = buildRuntimeWriteSnapshots({ + type: 'object', + item: master(), + context: { objects: [account, pickerDetail(['nope_col'])] }, + })!; + expect(snapshots.stored).toBeUndefined(); + + const result = gateObject(master(), [account, pickerDetail(['nope_col'])]); + + expect(result.errors, dump(result)).toEqual([ + expect.objectContaining({ rule: 'object-field-ref-unknown', path: 'objects.fx_detail2.fields.m.lookupColumns[0]' }), + ]); + }); +}); + +describe('#22118 — the same differential on a PERMISSION write', () => { + // `security-master-detail-ungranted` is silent while the stack authors no + // permission set at all, so with the tenant's only set dropped from the + // baseline every ungranted detail read as this write's advisory. + const objects = [ + master(), + { + name: 'fx_line', + label: 'Line', + sharingModel: 'controlled_by_parent', + fields: { hdr: { type: 'master_detail', label: 'Header', reference: 'fx_master', required: true } }, + }, + ]; + const set = { name: 'fx_ops', label: 'Ops', objects: { fx_master: { allowRead: true, readScope: 'org' } } }; + const gatePermission = (permissions: unknown[]) => + runRuntimeAuthoringRules({ type: 'permission', item: { ...set, label: 'Ops (renamed)' }, context: { objects, permissions } }); + + it('a label-only re-save of the only set carries no stored detail\'s advisory', () => { + const result = gatePermission([set]); + + expect(result.rulesRun).toContain('validateSecurityPosture'); + expect(result.errors, dump(result)).toEqual([]); + expect(result.advisories, dump(result)).toEqual([]); + }); + + it('CONTROL — creating that set still reports it: the stored universe never held the finding', () => { + const result = gatePermission([]); + + expect(result.advisories, dump(result)).toEqual([ + expect.objectContaining({ rule: 'security-master-detail-ungranted', path: 'objects.fx_line.fields.hdr' }), + ]); + }); +}); + +describe('#22118 — the snapshot shape', () => { + it('`stored` puts the stored self at the slot the item takes in the candidate; siblings keep theirs', () => { + const stored = master(); + const s = buildRuntimeWriteSnapshots({ + type: 'object', + item: relabelled(stored), + context: { objects: [stored, account, pickerDetail([])] }, + })!; + const names = (stack: Record) => (stack.objects as { name: string }[]).map((o) => o.name); + + expect(names(s.baseline)).toEqual(['fx_account', 'fx_detail2']); + expect(names(s.candidate)).toEqual(['fx_account', 'fx_detail2', 'fx_master']); + expect(names(s.stored!)).toEqual(['fx_account', 'fx_detail2', 'fx_master']); + expect((s.stored!.objects as unknown[])[2]).toBe(stored); + expect((s.candidate.objects as { label: string }[])[2]!.label).toBe('Master (renamed)'); + }); + + it('`stored` is absent for a write whose type is not a context collection', () => { + const s = buildRuntimeWriteSnapshots({ + type: 'flow', + item: { name: 'f1' }, + context: { objects: [master()] }, + })!; + expect(s.stored).toBeUndefined(); + }); +}); + +describe('#22118 — `isLocatedOnAnotherEntry` reads the location off the path spelling', () => { + const snapshot = { objects: [{}, {}, {}], permissions: [{}], books: [], datasets: [] }; + const located = { snapshot, stackKey: 'objects', itemName: 'fx_master', writtenSlot: 2 }; + + const CASES: ReadonlyArray = [ + // positional + ['objects[2].fields.acct.lookupColumns[0]', false], + ['objects[1].fields.m.lookupColumns[0]', true], + ['permissions[0].objects.fx_master.readScope', true], + // name-keyed + ['objects.fx_master.validations.v.regex', false], + ['objects.fx_detail.validations.v.regex', true], + // an object named in prose + ["object 'fx_master' · field 'code' readonlyWhen", false], + ["object 'fx_detail' · field 'qty' readonlyWhen", true], + // NOT positively located: today's verdict, never a cancellation + ['flows[0].nodes[1].config', false], + ['packages/lint/src/validate-expressions.ts', false], + ['object "fx_detail" › fields.m', false], + ["flow 'f1' · node 'n1'", false], + ['', false], + ]; + + for (const [path, another] of CASES) { + it(`${JSON.stringify(path)} → ${another}`, () => { + expect(isLocatedOnAnotherEntry(path, located)).toBe(another); + }); + } +}); diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index a63a40e1edb..22069e387e2 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -704,10 +704,10 @@ export function buildRuntimeWriteSnapshots(args: { * names a collection entry other than the written item (#22118). * * It answers the ruling's "located on" question for the third pass, and it - * answers it once for every rule — ⛔ no rule name appears here, and none may: - * a per-rule exemption would be a second policy beside the one differential - * every door rule reads. It reads the three spellings the door's rules locate - * an entry with: + * answers it once for every rule — it keys on the path's spelling, ⛔ never on + * which rule emitted it: a per-rule exemption would be a second policy beside + * the one differential every door rule reads. It reads the three spellings the + * door's rules locate an entry with: * * - **positional** — `objects[3].fields.m.lookupColumns[0]`: the entry at * that index of a collection the snapshot carries. The written item is the @@ -717,10 +717,9 @@ export function buildRuntimeWriteSnapshots(args: { * - **name-keyed** — `objects.acme_invoice.validations.x.regex`: the entry * of that name in a collection the snapshot carries; * - **an object named in prose** — `object 'fx_detail' · field 'qty' - * readonlyWhen`, the `path` a rule carrying its `where` as its path emits. - * Only the `object` spelling is read: it is the one in use, and the object - * collection is the one a sibling's finding most often needs the written - * item for. + * readonlyWhen`, the `path` a rule carrying its `where` as its path emits + * (`validateStackExpressions`, `lintAutonumberFormats`). Only that spelling + * is read — single-quoted, `object` — because it is the one in use. * * Anything else — a path into a collection the snapshot does not carry, a * rule's source file on an `authoring-rule-threw` finding, prose naming @@ -751,9 +750,9 @@ export function isLocatedOnAnotherEntry( if (named && Array.isArray(args.snapshot[named[1]!])) { return !(named[1] === args.stackKey && named[2] === args.itemName); } - const prose = /^object (['"])([A-Za-z_][A-Za-z0-9_]*)\1(?=\s|$)/.exec(path); + const prose = /^object '([A-Za-z_][A-Za-z0-9_]*)'(?=\s|$)/.exec(path); if (prose) { - return !(args.stackKey === stackKeyForType('object') && prose[2] === args.itemName); + return !(args.stackKey === stackKeyForType('object') && prose[1] === args.itemName); } return false; } From 59353d73c71a14351811376a75d9635128376e77 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:02:16 +0000 Subject: [PATCH 3/5] test(metadata-protocol): pin the stored-self baseline at the object save door Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- .../protocol.runtime-authoring-gate.test.ts | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) 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 73cf232f878..8440d1735f8 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -2024,3 +2024,117 @@ describe('runtime authoring gate on OBJECT writes — the field-rule-slot verdic expect(buildFindings(fxField(VALID))).toEqual([]); }); }); + +/** + * [#22118] The object save door judges a stored sibling's finding against the + * STORED universe — the registry's objects WITH the written object's stored + * self — so a label-only save of a master is not refused for a detail the + * author never touched. + * + * The fix is in `@objectstack/lint` (`buildRuntimeWriteSnapshots` / + * `runRuntimeAuthoringRules`); no code here moves. Pinned through the REAL + * `saveMetaItem` with the registry holding the stored universe: + * + * (a) the two measured cases — a detail's unknown `lookupColumns` entry, and + * a detail's `readonlyWhen` read through `parent` — save, and the row + * lands; + * (b) a write that newly breaks a sibling, and a write whose object carries + * its own finding (which its stored self carries too), are still refused + * with the 422 `INVALID_METADATA` envelope, and nothing lands. + * + * Every spelling of a finding's location and the create path are pinned in + * `@objectstack/lint`'s `runtime-gate.stored-self-baseline.test.ts`. + * + * ⚠️ As in the blocks above: this package reaches `@objectstack/lint` through + * its built `dist/`, so an edit to the gate is invisible here until + * `pnpm --filter @objectstack/lint build` has run. + */ +describe('runtime authoring gate on OBJECT writes — the stored-self baseline (#22118)', () => { + const master = (fields: Record = {}, label = 'Master') => ({ + name: 'fx_master', + label, + sharingModel: 'private', + fields: { + name: { type: 'text', label: 'Name' }, + code: { type: 'text', label: 'Code' }, + acct: { type: 'lookup', label: 'Account', reference: 'fx_account' }, + ...fields, + }, + }); + const account = { + name: 'fx_account', + label: 'Account', + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name' } }, + }; + const pickerDetail = (columns: string[]) => ({ + name: 'fx_detail2', + label: 'Picker Detail', + sharingModel: 'private', + fields: { m: { type: 'lookup', label: 'Master', reference: 'fx_master', lookupColumns: columns } }, + }); + const parentDetail = { + name: 'fx_detail', + label: 'Parent Detail', + sharingModel: 'private', + fields: { + hdr: { type: 'master_detail', label: 'Header', reference: 'fx_master' }, + qty: { type: 'number', label: 'Quantity', readonlyWhen: "parent.acct.name == 'x'" }, + }, + }; + + /** A protocol whose live registry holds `objects` — the stored universe. */ + const hostWith = (objects: unknown[]) => { + const { engine, rows } = makeStubEngine(); + engine.registry.listItems = (type: string) => (type === 'object' ? objects : []); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any; + return { protocol, rows }; + }; + const masterRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'object' && r.name === 'fx_master'); + const saveMaster = (protocol: any, item: unknown) => + protocol.saveMetaItem({ type: 'object', name: 'fx_master', item }); + + for (const [label, detail] of [ + ['an unknown `lookupColumns` entry on a detail', pickerDetail(['nope_col'])], + ['a `readonlyWhen` read through `parent`', parentDetail], + ] as const) { + it(`(a) a label-only master save SAVES beside a stored detail with ${label}`, async () => { + const { protocol, rows } = hostWith([master(), account, detail]); + + const result = await saveMaster(protocol, master({}, 'Master (renamed)')); + + expect(result.success).toBe(true); + expect(masterRows(rows).map((r) => r.state)).toEqual(['active']); + }); + } + + it('(b) a write that newly breaks a sibling is still REFUSED with the 422 envelope', async () => { + // The stored master has `code`; the write drops it, so the detail's + // picker column now names nothing. + const { protocol, rows } = hostWith([master(), account, pickerDetail(['code'])]); + const dropped = master({}, 'Master (renamed)'); + delete (dropped.fields as Record).code; + + const err = await saveMaster(protocol, dropped).catch((e: any) => e); + + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issue = err.issues.find((i: any) => i.rule === 'object-field-ref-unknown'); + expect(issue, `issues: ${JSON.stringify(err.issues)}`).toBeDefined(); + expect(issue.path).toBe('objects.fx_detail2.fields.m.lookupColumns[0]'); + expect(masterRows(rows)).toEqual([]); + }); + + it('(b) a finding on the written object itself is still REFUSED, though its stored self carries it', async () => { + const broken = master({ acct: { type: 'lookup', label: 'Account', reference: 'fx_account', lookupColumns: ['nope'] } }); + const { protocol, rows } = hostWith([broken, account]); + + const err = await saveMaster(protocol, { ...broken, label: 'Master (renamed)' }).catch((e: any) => e); + + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issue = err.issues.find((i: any) => i.rule === 'object-field-ref-unknown'); + expect(issue, `issues: ${JSON.stringify(err.issues)}`).toBeDefined(); + expect(issue.path).toBe('objects.fx_master.fields.acct.lookupColumns[0]'); + expect(masterRows(rows)).toEqual([]); + }); +}); From d517f080b23fe3bed4f62bb9a8b0ad109a24b8b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:13:51 +0000 Subject: [PATCH 4/5] refactor(lint): keep the published snapshot builder's signature; the stored universe is the gate's own `buildRuntimeWriteSnapshots` is on both package entries, so its return type stays the baseline/candidate pair. The construction moves to the module-level `buildRuntimeWriteSnapshotSet`, which the gate and the pins read. Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- .../runtime-gate.stored-self-baseline.test.ts | 22 +++++-- packages/lint/src/runtime-gate.ts | 62 +++++++++++++------ 2 files changed, 59 insertions(+), 25 deletions(-) diff --git a/packages/lint/src/runtime-gate.stored-self-baseline.test.ts b/packages/lint/src/runtime-gate.stored-self-baseline.test.ts index c5061640eac..8a07a8922d5 100644 --- a/packages/lint/src/runtime-gate.stored-self-baseline.test.ts +++ b/packages/lint/src/runtime-gate.stored-self-baseline.test.ts @@ -31,6 +31,7 @@ import { describe, expect, it } from 'vitest'; import type { AuthoringFinding } from './authoring-rules.js'; import { + buildRuntimeWriteSnapshotSet, buildRuntimeWriteSnapshots, isLocatedOnAnotherEntry, runRuntimeAuthoringRules, @@ -119,7 +120,7 @@ describe('#22118 — a label-only master save is not charged with a stored detai for (const { label, detail, rule, rawPath } of MEASURED) { it(`⭐ RESOLVES — ${label}`, () => { const stored = [master(), account, detail]; - const snapshots = buildRuntimeWriteSnapshots({ + const snapshots = buildRuntimeWriteSnapshotSet({ type: 'object', item: relabelled(master()), context: { objects: stored }, @@ -206,7 +207,7 @@ describe('#22118 — the controls: what the write changes is still the write\'s' const stored = master(fields as Fields, over); // Non-vacuous: the stored self carries the identical finding, so a // baseline that let it cancel would wave this write through. - const snapshots = buildRuntimeWriteSnapshots({ + const snapshots = buildRuntimeWriteSnapshotSet({ type: 'object', item: relabelled(stored), context: { objects: [stored, account] }, @@ -222,7 +223,7 @@ describe('#22118 — the controls: what the write changes is still the write\'s' it('a CREATE is judged as before: no stored self, so the stored universe is the baseline', () => { // Creating the master makes the stored detail's picker column judgeable; // the stored universe never held that finding, so it is this write's. - const snapshots = buildRuntimeWriteSnapshots({ + const snapshots = buildRuntimeWriteSnapshotSet({ type: 'object', item: master(), context: { objects: [account, pickerDetail(['nope_col'])] }, @@ -274,7 +275,7 @@ describe('#22118 — the same differential on a PERMISSION write', () => { describe('#22118 — the snapshot shape', () => { it('`stored` puts the stored self at the slot the item takes in the candidate; siblings keep theirs', () => { const stored = master(); - const s = buildRuntimeWriteSnapshots({ + const s = buildRuntimeWriteSnapshotSet({ type: 'object', item: relabelled(stored), context: { objects: [stored, account, pickerDetail([])] }, @@ -288,8 +289,19 @@ describe('#22118 — the snapshot shape', () => { expect((s.candidate.objects as { label: string }[])[2]!.label).toBe('Master (renamed)'); }); + it('the published builder still returns exactly its baseline/candidate pair — `stored` is the gate\'s own', () => { + const args = { type: 'object', item: relabelled(master()), context: { objects: [master(), account] } }; + const published = buildRuntimeWriteSnapshots(args)!; + const set = buildRuntimeWriteSnapshotSet(args)!; + + expect(Object.keys(published)).toEqual(['baseline', 'candidate']); + expect(set.stored).toBeDefined(); + expect(published.baseline).toEqual(set.baseline); + expect(published.candidate).toEqual(set.candidate); + }); + it('`stored` is absent for a write whose type is not a context collection', () => { - const s = buildRuntimeWriteSnapshots({ + const s = buildRuntimeWriteSnapshotSet({ type: 'flow', item: { name: 'f1' }, context: { objects: [master()] }, diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index 22069e387e2..625a165b38b 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -615,22 +615,11 @@ const fingerprint = (f: AuthoringFinding) => `${f.rule}\u0000${f.where}\u0000${f * 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. - * - [#22118] `stored` is the STORED universe: the baseline with the written - * item's stored self put back, at the slot the item takes in the candidate. - * Present only on an UPDATE into a context collection — a create has no - * stored self, and a non-context type's item is never carried as context — - * so in every other case the gate runs exactly the two passes it always ran. - * It exists because the baseline alone answers the wrong question for a - * SIBLING's finding that needs the written item present: a detail's - * `lookupColumns` entry is judged against its master only when the master - * is in the snapshot, so with the master dropped the baseline cannot hold - * the finding, and a label-only master save was charged with a stored - * detail's condition. The stored universe holds it. - * Its slot is the point: every sibling sits at the same index in all three - * snapshots, and the stored self sits where the candidate puts the item, - * so a positional path names the same entry in `stored` as in `candidate`. - * {@link runRuntimeAuthoringRules} reads only the `stored` findings located - * on ANOTHER entry; the stored self's own findings never cancel anything. + * - [#22118] The gate also judges a third snapshot on an update into a + * context collection, the STORED universe — see + * {@link buildRuntimeWriteSnapshotSet}, which this function reads its two + * snapshots off. It is not returned here: this signature is on both package + * entries, and the third snapshot is the gate's own business. * * Cost (the #4463 D2 question, measured rather than assumed): built per * write, never cached. The construction is one filter + one spread over the @@ -638,8 +627,8 @@ const fingerprint = (f: AuthoringFinding) => `${f.rule}\u0000${f.where}\u0000${f * 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. An - * update into a context collection pays a third pass of the same size, for - * `stored`. + * update into a context collection pays one more spread, for the stored + * universe. */ export function buildRuntimeWriteSnapshots(args: { /** Singular metadata type of the item being written. */ @@ -654,7 +643,40 @@ export function buildRuntimeWriteSnapshots(args: { * rules the WHOLE `objects` collection, exactly as before. */ packageScope?: RuntimePackageScope; -}): { baseline: AnyRec; candidate: AnyRec; stored?: AnyRec } | null { +}): { baseline: AnyRec; candidate: AnyRec } | null { + const set = buildRuntimeWriteSnapshotSet(args); + return set ? { baseline: set.baseline, candidate: set.candidate } : null; +} + +/** + * {@link buildRuntimeWriteSnapshots}' baseline and candidate, plus — on an + * UPDATE into a context collection — `stored` (#22118). + * + * Exported for the pins in `runtime-gate.stored-self-baseline.test.ts` and for + * that only — it is on neither package entry. + * + * `stored` is the STORED universe: the baseline with the written item's stored + * self put back, at the slot the item takes in the candidate. Present only on + * an update into a context collection — a create has no stored self, and a + * non-context type's item is never carried as context — so in every other case + * the gate runs exactly the two passes it always ran. + * + * It exists because the baseline alone answers the wrong question for a + * SIBLING's finding that needs the written item present: a detail's + * `lookupColumns` entry is judged against its master only when the master is + * in the snapshot, so with the master dropped the baseline cannot hold the + * finding, and a label-only master save was charged with a stored detail's + * condition. The stored universe holds it. + * + * Its slot is the point: every sibling sits at the same index in all three + * snapshots, and the stored self sits where the candidate puts the item, so a + * positional path names the same entry in `stored` as in `candidate`. + * {@link runRuntimeAuthoringRules} reads only the `stored` findings located on + * ANOTHER entry; the stored self's own findings never cancel anything. + */ +export function buildRuntimeWriteSnapshotSet( + args: Parameters[0], +): { baseline: AnyRec; candidate: AnyRec; stored?: AnyRec } | null { const stackKey = stackKeyForType(args.type); if (!stackKey) return null; if (!args.item || typeof args.item !== 'object') return null; @@ -1063,7 +1085,7 @@ export function runRuntimeAuthoringRules(args: { // universe on an update into a context collection. See the builder's own // docblock; it is exported precisely so tests exercise this construction // and not a mirror of it. - const snapshots = buildRuntimeWriteSnapshots({ + const snapshots = buildRuntimeWriteSnapshotSet({ type: args.type, item: args.item, ...(args.context !== undefined ? { context: args.context } : {}), From 8d2a3c3d09dee7cc858c2763c90e0593f2ae842e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:14:05 +0000 Subject: [PATCH 5/5] chore(changeset): patch @objectstack/lint for the stored-self baseline Claude-Session: https://claude.ai/code/session_01LAi5BVvQNiYzepSAcsoFLK Co-authored-by: Claude --- .changeset/22118-gate-baseline-stored-self.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/22118-gate-baseline-stored-self.md diff --git a/.changeset/22118-gate-baseline-stored-self.md b/.changeset/22118-gate-baseline-stored-self.md new file mode 100644 index 00000000000..74c1ce3a88f --- /dev/null +++ b/.changeset/22118-gate-baseline-stored-self.md @@ -0,0 +1,15 @@ +--- +"@objectstack/lint": patch +--- + +The runtime publish gate no longer charges a write with a stored sibling's finding that only shows when the written item is present. Re-saving a master object with only its label changed answered `422 INVALID_METADATA` for a stored detail the author never touched: a detail's `lookupColumns` entry naming no field of the master, or a detail's `readonlyWhen` read through `parent`. + +Clause-②: no + +- On an update into a context collection (`object`, `permission`, `book`, `dataset`) the gate also judges the stored universe: the live collections with the written item's stored self in place. A finding located on another entry that the stored universe already holds is not this write's. +- A finding located on the written item itself is judged as before, even when the stored row carries the same finding. Re-saving a row is writing it. +- A write that newly breaks a sibling is still refused, for example removing the master field a detail's `lookupColumns` names. +- A create is judged as before. +- On a permission write the same change drops a stored detail's `security-master-detail-ungranted` advisory from a label-only re-save of the tenant's only permission set. +- A finding whose path names no entry the gate can locate keeps the previous verdict. +- ⛔ Nothing you author changes, and no export or signature changes.