diff --git a/.changeset/publish-gate-package-closure.md b/.changeset/publish-gate-package-closure.md new file mode 100644 index 0000000000..563db12964 --- /dev/null +++ b/.changeset/publish-gate-package-closure.md @@ -0,0 +1,46 @@ +--- +"@objectstack/lint": minor +"@objectstack/metadata-protocol": minor +--- + +The runtime publish gate judges a package write against that package's own closure (#9612) + +The gate handed every rule the tenant's **entire** `objects` collection on every +publish. That is the wrong validation unit, not merely a large one: a tenant +that has grown to hundreds of objects is many packages, and judging one +package's write against all of them asks a question nobody wanted answered. +Per the maintainer's ruling, the unit is now the package — +「客户开发开发,校验是否也应该基于软件包」·「当然这里面要考虑系统对象」. + +`buildRuntimeWriteSnapshots` accepts an optional `packageScope` +(`{ packageId, dependencies }`) and reduces `objects` to that closure: + +- the package being written; +- the transitive closure of its **declared** `manifest.dependencies` — a + package's declared dependencies bound what it may reference, so this is a set + the platform computes exactly rather than estimates; +- platform / system objects, **unconditionally** — a package legitimately + references `sys_*` objects it never declares, and a closure that dropped them + would report unresolved references that are not there; +- rows carrying no package provenance (tenant-authored overlays), because + nothing declares what they may reference and so nothing bounds them. + +`ObjectStackProtocolImplementation` resolves that scope from the package +registry and passes it through `evaluateRuntimeAuthoringGate`. + +**A write that names no package, or names one the registry cannot produce, +narrows nothing** and is judged exactly as before. That direction is the whole +design: an unresolvable package buys a write *more* validation input, never +less. There is no branch that skips rules, and none that skips them past a +size. + +One behaviour change follows from the unit being right: a **package-scoped** +write that references an object in a package it never declared a dependency on +is now judged against a closure that does not contain it, so the reference is +reported. That is the ruling's intended consequence — such a reference is not +resolvable by declaration — and it applies only to writes that state a package. + +Also exported: `narrowObjectsToPackageClosure` and the `RuntimePackageScope` +type from `@objectstack/lint` and `@objectstack/lint/runtime`, and +`isSystemObject` from the security-posture rule module so the closure and the +rules share one reading of what "system" means rather than two. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index c9c2b53732..10587612ef 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -663,18 +663,23 @@ export type { // The runtime publish gate over that registry. Also published as the // `@objectstack/lint/runtime` subpath — the entry the kernel boot path imports. -// That subpath narrows the EXPORT surface to these five names, not the module +// That subpath narrows the EXPORT surface to these six names, not the module // graph: measured, it reaches 70 of this entry's 72 modules and 93.8% of its // bundled bytes, and it does name the modules that reach the source parsers. // `runtime.ts`'s header carries the measurement and what the narrowing buys. export { buildRuntimeWriteSnapshots, + narrowObjectsToPackageClosure, runRuntimeAuthoringRules, runtimeAuthoringRulesFor, runtimeGatedTypes, stackKeyForType, } from './runtime-gate.js'; -export type { RuntimeGateResult, RuntimeStackContext } from './runtime-gate.js'; +export type { + RuntimeGateResult, + RuntimePackageScope, + RuntimeStackContext, +} from './runtime-gate.js'; // The shared page-component traversal every `properties`-inspecting rule is // built on (#3583). Exported because the CLI's i18n walker needs the same diff --git a/packages/lint/src/runtime-gate.package-closure.test.ts b/packages/lint/src/runtime-gate.package-closure.test.ts new file mode 100644 index 0000000000..d8021d363e --- /dev/null +++ b/packages/lint/src/runtime-gate.package-closure.test.ts @@ -0,0 +1,283 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #9612 — the publish gate judges a write against its PACKAGE's closure. + * + * The maintainer's ruling is the product decision under test, verbatim: + * + * > 大客户(420 个对象),就不应该出现在一个软件包中啊,这就是划分软件包的价值。 + * > 客户开发开发,校验是否也应该基于软件包 + * > 当然这里面要考虑系统对象 + * + * So this file is NOT a performance test. It pins the four limbs of the + * closure, the fallback direction when a package cannot be resolved, and — the + * part that matters — that narrowing does not change the gate's ANSWER. + * + * ## Why the equivalence assertions are paired with ablations + * + * "The verdict is unchanged" is worth nothing from a fixture where nothing + * could have changed it. Measured on the shipped rules: the OBJECT door has no + * objects×objects coupling at all — none of its seven rules builds a + * name→object index — so on that door a verdict-equivalence assertion passes + * for a reason that has nothing to do with the closure being right. The FLOW + * door does have the coupling, and that is where the ablations below live: drop + * the written item's own package and a phantom reference finding appears; drop + * system objects and real findings disappear. Both directions are asserted, so + * an equivalence test that stopped being able to fail would be caught here + * rather than read as a pass. + */ +import { describe, expect, it } from 'vitest'; +import { + narrowObjectsToPackageClosure, + runRuntimeAuthoringRules, + type RuntimePackageScope, +} from './runtime-gate.js'; + +type AnyRec = Record; + +const OWN = 'com.acme.crm'; +const DEP = 'com.acme.core'; +const STRANGER = 'com.other.analytics'; +const PLATFORM = 'com.objectstack.platform'; + +const SCOPE: RuntimePackageScope = { packageId: OWN, dependencies: [DEP] }; + +/** A minimal, spec-shaped object declaration. */ +function obj(name: string, packageId?: string | null, extra: AnyRec = {}): AnyRec { + return { + name, + label: name, + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name' } }, + ...(packageId === undefined ? {} : { _packageId: packageId }), + ...extra, + }; +} + +describe('narrowObjectsToPackageClosure — the four limbs (#9612)', () => { + const collection = [ + obj('crm_account', OWN), + obj('core_user', DEP), + obj('analytics_cube', STRANGER), + obj('sys_audit_log', PLATFORM), + obj('tenant_overlay', undefined), + obj('rehydrated_overlay', 'sys_metadata'), + obj('flagged_system', STRANGER, { isSystem: true }), + ]; + const kept = () => + (narrowObjectsToPackageClosure(collection, SCOPE) as AnyRec[]).map((o) => o.name); + + it('keeps the written package and its declared dependency', () => { + expect(kept()).toContain('crm_account'); + expect(kept()).toContain('core_user'); + }); + + it('drops a package that is neither the written one nor a declared dependency', () => { + expect(kept()).not.toContain('analytics_cube'); + }); + + it('keeps system objects unconditionally — by name prefix AND by isSystem flag', () => { + // The card's named hazard: a package references a platform object it never + // declares a dependency on. Judged against a closure that omitted it, the + // gate would report an unresolved reference that is not there. + expect(kept()).toContain('sys_audit_log'); + expect(kept()).toContain('flagged_system'); + }); + + it('keeps unpackaged rows, including the `sys_metadata` rehydration sentinel', () => { + // Nothing declares what a tenant-authored overlay row may reference, so + // nothing bounds it. Keeping it is the conservative direction. + expect(kept()).toContain('tenant_overlay'); + expect(kept()).toContain('rehydrated_overlay'); + }); + + it('narrows NOTHING when no scope is stated', () => { + expect(narrowObjectsToPackageClosure(collection, undefined)).toBe(collection); + }); + + it('narrows NOTHING when the scope carries an empty package id', () => { + // The host returns `undefined` rather than an empty id, but the guard is + // here too: an unresolvable package must buy MORE validation input, never + // less. ⛔ There is no branch anywhere that skips rules or skips them past + // a size — that is the fail-open at scale this card was forbidden to build. + const empty = { packageId: '', dependencies: [] } as RuntimePackageScope; + expect(narrowObjectsToPackageClosure(collection, empty)).toBe(collection); + }); + + it('keeps a non-object member rather than inspecting it', () => { + const ragged = [null, 'not-an-object', obj('analytics_cube', STRANGER)]; + expect(narrowObjectsToPackageClosure(ragged, SCOPE)).toEqual([null, 'not-an-object']); + }); + + it('follows the dependency set the host resolved, not a graph of its own', () => { + // The transitive walk lives in the host (it holds the package registry). + // This function reads the set it is given and must not try to extend it. + const transitive: RuntimePackageScope = { packageId: OWN, dependencies: [DEP, STRANGER] }; + const names = (narrowObjectsToPackageClosure(collection, transitive) as AnyRec[]).map((o) => o.name); + expect(names).toContain('analytics_cube'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// The gate's ANSWER under narrowing — flow door, where objects×objects coupling +// actually exists. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * A flow that fires on a record trigger against `objectName`. + * + * The shape is the shipped one (`nodes[0].type: 'start'`, the object named in + * `config.objectName`) — that is what `flow-trigger-unknown-object` reads, and + * a fixture in any other shape produces no findings at all and would make every + * assertion in this file pass vacuously. + */ +function flowOn(name: string, objectName: string): AnyRec { + return { + name, + label: name, + status: 'active', + type: 'autolaunched', + nodes: [ + { + id: 'start', + type: 'start', + label: 'On update', + config: { objectName, triggerType: 'record-after-update' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end', type: 'default', isDefault: false }], + }; +} + +/** Targets an object in the written package — resolves under the closure. */ +const FLOW_INTO_OWN_PACKAGE = flowOn('crm_account_touched', 'crm_account'); +/** + * Targets a PLATFORM object no package declares — the card's named hazard. + * + * `platform_ledger` rather than `sys_audit_log` on purpose: + * `validateFlowTriggerReadiness` exempts any target whose NAME starts with + * `sys_` (its own header calls such targets legitimate), so a `sys_`-named + * fixture would make the ablation below unable to fail for a reason that has + * nothing to do with the closure. `platform_ledger` carries `isSystem: true` + * instead — the OTHER limb of the same predicate — so the rule still judges it + * while the closure still keeps it. + */ +const FLOW_INTO_SYSTEM_OBJECT = flowOn('platform_ledger_touched', 'platform_ledger'); +/** Targets an object that exists in NO package — a genuine finding, always. */ +const FLOW_INTO_GHOST = flowOn('ghost_touched', 'ghost_object'); +/** Targets a package the written one does NOT declare a dependency on. */ +const FLOW_INTO_UNDECLARED = flowOn('stranger_touched', 'other_0_0'); + +/** A tenant of many packages — the shape the ruling says a big tenant really is. */ +function packagedTenant(strangerPackages: number): AnyRec[] { + const out: AnyRec[] = [ + obj('crm_account', OWN), + obj('crm_contact', OWN), + obj('core_user', DEP), + obj('sys_audit_log', PLATFORM), + obj('platform_ledger', PLATFORM, { isSystem: true }), + ]; + for (let i = 0; i < strangerPackages; i++) { + for (let k = 0; k < 5; k++) out.push(obj(`other_${i}_${k}`, `com.other.pkg_${i}`)); + } + return out; +} + +/** Every finding the write ADDED, as stable text. Array indices normalised. */ +function verdict(result: { errors: AnyRec[]; advisories: AnyRec[] }): string[] { + return [...result.errors, ...result.advisories] + .map((f) => `${f.rule}|${f.where}|${String(f.path ?? '').replace(/\[\d+\]/g, '[i]')}|${f.message}`) + .sort(); +} + +describe('the differential verdict survives package narrowing (#9612)', () => { + const objects = packagedTenant(20); + const scoped = (type: string, item: AnyRec) => + verdict(runRuntimeAuthoringRules({ type, item, context: { objects }, packageScope: SCOPE })); + const whole = (type: string, item: AnyRec) => + verdict(runRuntimeAuthoringRules({ type, item, context: { objects } })); + + it('a REAL finding survives narrowing — the equivalence is not vacuous', () => { + // Asserted first and by content: every other case in this block compares + // two empty verdicts, which would also be equal if the gate had stopped + // working. This one pins that the narrowed gate still FINDS things. + expect(whole('flow', FLOW_INTO_GHOST)).toHaveLength(1); + expect(scoped('flow', FLOW_INTO_GHOST)).toEqual(whole('flow', FLOW_INTO_GHOST)); + }); + + it('reaches the whole-stack answer exactly for a flow into its own package', () => { + expect(scoped('flow', FLOW_INTO_OWN_PACKAGE)).toEqual(whole('flow', FLOW_INTO_OWN_PACKAGE)); + expect(whole('flow', FLOW_INTO_OWN_PACKAGE)).toEqual([]); + }); + + it('reaches the whole-stack answer exactly for a flow into a PLATFORM object', () => { + // The closure keeps `sys_audit_log` although no package declares it. The + // ablation below is what proves this assertion could have failed. + expect(scoped('flow', FLOW_INTO_SYSTEM_OBJECT)).toEqual(whole('flow', FLOW_INTO_SYSTEM_OBJECT)); + expect(whole('flow', FLOW_INTO_SYSTEM_OBJECT)).toEqual([]); + }); + + it('reaches the whole-stack answer exactly, object door', () => { + const written = obj('crm_opportunity', OWN, { + validations: [ + { name: 'shape', type: 'json_schema', schema: { type: 'not-a-type' }, message: 'bad' }, + ], + }); + // Non-vacuous in the same way: the broken schema is a finding the narrowed + // gate must still produce. + expect(whole('object', written).length).toBeGreaterThan(0); + expect(scoped('object', written)).toEqual(whole('object', written)); + }); + + it('narrows nothing — byte-identical to the pre-#9612 gate — with no scope', () => { + const written = obj('crm_opportunity', OWN); + const before = runRuntimeAuthoringRules({ type: 'object', item: written, context: { objects } }); + const after = runRuntimeAuthoringRules({ + type: 'object', + item: written, + context: { objects }, + packageScope: undefined, + }); + expect(verdict(after)).toEqual(verdict(before)); + }); + + it('DOES judge a reference outside the declared dependencies — the ruling\'s intended consequence', () => { + // ⭐ The one place the answer legitimately differs, pinned so it is a + // decision on the record rather than a surprise. A package's declared + // `dependencies` bound what it MAY reference; a flow reaching into a + // package this one never declared is not resolvable BY DECLARATION, and + // the closure says so. Measured severity here is advisory (`warning`), so + // it reports rather than refuses. + expect(whole('flow', FLOW_INTO_UNDECLARED)).toEqual([]); + const narrowed = scoped('flow', FLOW_INTO_UNDECLARED); + expect(narrowed).toHaveLength(1); + expect(narrowed[0]).toContain('flow-trigger-unknown-object'); + }); +}); + +describe('ablations — the equivalence assertions above can fail (#9612)', () => { + const objects = packagedTenant(20); + + it('DROPPING the written package manufactures a phantom the closure prevents', () => { + // The #7886 mechanism reproduced on `objects` rather than `permissions`. + // ⭐ #7886's phantoms came from narrowing `permissions`; the PM's original + // premise that narrowing PER SE manufactures them was falsified on #9612. + // What manufactures them is a closure missing a limb — this one. + const broken = objects.filter((o) => o._packageId !== OWN); + const mutilated = verdict( + runRuntimeAuthoringRules({ type: 'flow', item: FLOW_INTO_OWN_PACKAGE, context: { objects: broken } }), + ); + expect(mutilated).toHaveLength(1); + expect(mutilated[0]).toContain('flow-trigger-unknown-object'); + }); + + it('DROPPING system objects changes the answer — which is why that limb is unconditional', () => { + const broken = objects.filter((o) => o.isSystem !== true && !String(o.name).startsWith('sys_')); + const mutilated = verdict( + runRuntimeAuthoringRules({ type: 'flow', item: FLOW_INTO_SYSTEM_OBJECT, context: { objects: broken } }), + ); + expect(mutilated).toHaveLength(1); + expect(mutilated[0]).toContain('flow-trigger-unknown-object'); + }); +}); diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index cfe5c1f47f..3c4e303375 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -55,6 +55,7 @@ import { type AuthoringRule, type AuthoringRuleContext, } from './authoring-rules.js'; +import { isSystemObject } from './validate-security-posture.js'; type AnyRec = Record; @@ -165,6 +166,102 @@ export interface RuntimeStackContext { datasets?: readonly unknown[]; } +/** + * Which package a write belongs to, and what that package is allowed to reach. + * + * [#9612] The maintainer's ruling, verbatim, is the product decision this type + * exists for: + * + * > 大客户(420 个对象),就不应该出现在一个软件包中啊,这就是划分软件包的价值。 + * > 客户开发开发,校验是否也应该基于软件包 + * > 当然这里面要考虑系统对象 + * + * A tenant that has grown to 420 objects is not ONE package — it is many — and + * judging one package's write against all 420 is validating against the wrong + * unit, not merely validating slowly. A package's declared `dependencies` + * bound what it MAY reference, so `package + declared deps + platform/system` + * is a closure the platform computes EXACTLY rather than estimates. That is + * what separates this from "narrow to whatever the rule can be proven to + * reach", which was considered and refused: a per-rule proof is a second + * opinion that drifts the moment a rule changes. + * + * ⛔ Absent — or carrying a package whose dependency declaration cannot be + * read — narrows NOTHING. The whole collection is handed over, exactly as + * before. The fallback direction is deliberate and is the opposite of a size + * threshold: an unknown provenance buys MORE validation input, never less, so + * the gate never stops judging (the #9798 / #9261 / ADR-0110 D3 fail-open + * shape this card was explicitly forbidden from re-creating). + */ +export interface RuntimePackageScope { + /** The package the written item belongs to. */ + packageId: string; + /** + * Every OTHER package this one may reference — the transitive closure of the + * written package's declared `dependencies`. Resolved by the host, which is + * the side that holds the package registry; this module only reads the set. + */ + dependencies: readonly string[]; +} + +/** + * `objects` reduced to the written item's package closure (#9612). + * + * An object survives when ANY of these holds — the four limbs are the closure + * the ruling names, and each one is load-bearing: + * + * 1. it is a **platform / system object** ({@link isSystemObject}, imported + * rather than re-decided). ⛔ Unconditional: a package legitimately + * references `sys_*` objects it never declares a dependency on, and a + * closure that dropped them would manufacture "unresolved reference" + * findings that describe nothing — the false-positive class PR #7886 + * already paid for on the `permissions` collection; + * 2. it carries **no package provenance** — a tenant-authored overlay row. + * Nothing declares what such a row may reference, so nothing bounds it and + * it is kept. Conservative by construction; + * 3. it belongs to the **written package** itself; + * 4. it belongs to one of that package's **declared dependencies**. + * + * Pure, allocation-light, and total: a non-object member is kept rather than + * inspected, because deciding it is not this function's job. + */ +export function narrowObjectsToPackageClosure( + objects: readonly unknown[], + scope: RuntimePackageScope | undefined, +): readonly unknown[] { + if (!scope || typeof scope.packageId !== 'string' || scope.packageId === '') return objects; + const reachable = new Set([scope.packageId, ...scope.dependencies]); + return objects.filter((entry) => { + if (!entry || typeof entry !== 'object') return true; + const owner = (entry as AnyRec)[PACKAGE_PROVENANCE_KEY]; + // Limb 2 — no provenance, or the registry's rehydration sentinel, which + // marks an overlay row rather than a real package (`registry.ts`'s + // `isArtifactBacked` reads it the same way). + if (typeof owner !== 'string' || owner === '' || owner === OVERLAY_PROVENANCE_SENTINEL) return true; + // Limbs 3 and 4. + if (reachable.has(owner)) return true; + // Limb 1 — checked last only because it is the rarest, never because it is + // the weakest: it is the one limb with no escape. + return isSystemObject(entry as AnyRec); + }); +} + +/** + * The provenance key the registry stamps an item's owning package onto. + * `registry.ts` writes it (`_packageId = this.getObjectOwner(fqn)?.packageId`) + * and `listItems` tags with it, which is why the closure can be read off the + * collection the host already hands over instead of needing a second lookup. + */ +const PACKAGE_PROVENANCE_KEY = '_packageId'; + +/** + * The value `_packageId` carries for a row rehydrated from `sys_metadata` + * rather than delivered by a package. Not a package id — `registry.ts` tests + * for exactly this string before treating an item as artifact-backed — so the + * closure reads it as "unpackaged" (limb 2) rather than as a package nobody + * declared a dependency on. + */ +const OVERLAY_PROVENANCE_SENTINEL = 'sys_metadata'; + /** * The context collections the snapshot carries, in stack-key order. Derived * facts: every entry is a key of {@link RuntimeStackContext} AND a stack key @@ -255,6 +352,12 @@ export function buildRuntimeWriteSnapshots(args: { item: unknown; /** Live resolution context from the host runtime. */ context?: RuntimeStackContext; + /** + * [#9612] The written item's package and what it may reach. Omitted — or + * carrying a package whose dependencies the host could not read — hands the + * rules the WHOLE `objects` collection, exactly as before. + */ + packageScope?: RuntimePackageScope; }): { baseline: AnyRec; candidate: AnyRec } | null { const stackKey = stackKeyForType(args.type); if (!stackKey) return null; @@ -265,7 +368,23 @@ export function buildRuntimeWriteSnapshots(args: { const baseline: AnyRec = {}; for (const key of CONTEXT_STACK_KEYS) { - const collection = (args.context?.[key] ?? []) as readonly AnyRec[]; + // [#9612] `objects` — and only `objects` — is reduced to the written + // item's package closure. The other three collections are already bounded + // by what a tenant authors (permission sets, books, datasets), and the + // measured bill is entirely in what the rules walk over `objects`. + // + // ⭐ Narrowing here rather than at either call site is what makes this ONE + // change covering BOTH doors: every gated write type is built through this + // function, so a flow publish and an object publish are narrowed by the + // same rule and cannot drift into two policies. + // + // ⛔ Applied to BOTH passes, necessarily. The gate's verdict is + // candidate MINUS baseline, so narrowing one side and not the other would + // not be a smaller input — it would be a different question. + const raw = (args.context?.[key] ?? []) as readonly AnyRec[]; + const collection = key === 'objects' + ? (narrowObjectsToPackageClosure(raw, args.packageScope) as readonly AnyRec[]) + : raw; baseline[key] = key === stackKey ? collection.filter((o) => !itemName || o?.name !== itemName) : collection; @@ -323,6 +442,12 @@ export function runRuntimeAuthoringRules(args: { item: unknown; /** Live resolution context from the host runtime. */ context?: RuntimeStackContext; + /** + * [#9612] The written item's package closure. Omit to judge against the + * whole collection — the pre-#9612 behaviour, and the behaviour every caller + * that cannot state a package keeps. + */ + packageScope?: RuntimePackageScope; /** ADR-0080 SDUI manifest, when the host has one. */ sduiManifest?: unknown; }): RuntimeGateResult { @@ -340,6 +465,7 @@ export function runRuntimeAuthoringRules(args: { type: args.type, item: args.item, ...(args.context !== undefined ? { context: args.context } : {}), + ...(args.packageScope !== undefined ? { packageScope: args.packageScope } : {}), }); if (!snapshots) return empty; diff --git a/packages/lint/src/runtime.ts b/packages/lint/src/runtime.ts index c5af8b3ec0..40d09463fd 100644 --- a/packages/lint/src/runtime.ts +++ b/packages/lint/src/runtime.ts @@ -63,10 +63,15 @@ export { buildRuntimeWriteSnapshots, + narrowObjectsToPackageClosure, runRuntimeAuthoringRules, runtimeAuthoringRulesFor, runtimeGatedTypes, stackKeyForType, } from './runtime-gate.js'; -export type { RuntimeGateResult, RuntimeStackContext } from './runtime-gate.js'; +export type { + RuntimeGateResult, + RuntimePackageScope, + RuntimeStackContext, +} from './runtime-gate.js'; export type { AuthoringFinding, AuthoringSeverity } from './authoring-rules.js'; diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index 38f68f05df..8d15a5f6d3 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -131,7 +131,17 @@ function owdOf(obj: AnyRec): unknown { return obj.sharingModel; } -function isSystemObject(obj: AnyRec): boolean { +/** + * A platform / system object: one the tenant did not author. + * + * Exported (#9612) so the runtime gate's package-closure narrowing keeps + * system objects unconditionally inside the closure using THIS predicate, + * rather than a second opinion about what "system" means. A package that + * references a platform object, judged against a closure that omitted it, + * would report an unresolved reference that is not there — so the two + * readings have to be one reading. + */ +export function isSystemObject(obj: AnyRec): boolean { return obj.isSystem === true || String(obj.name ?? '').startsWith('sys_'); } diff --git a/packages/metadata-protocol/src/protocol.package-closure-gate.test.ts b/packages/metadata-protocol/src/protocol.package-closure-gate.test.ts new file mode 100644 index 0000000000..c219ce123b --- /dev/null +++ b/packages/metadata-protocol/src/protocol.package-closure-gate.test.ts @@ -0,0 +1,245 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #9612 — the publish gate resolves the write's PACKAGE closure from the + * registry, end to end through `saveMetaItem`. + * + * The maintainer's ruling is the product decision under test, verbatim: + * + * > 大客户(420 个对象),就不应该出现在一个软件包中啊,这就是划分软件包的价值。 + * > 客户开发开发,校验是否也应该基于软件包 + * > 当然这里面要考虑系统对象 + * + * `runtime-gate.package-closure.test.ts` pins the closure FUNCTION. This file + * pins the half that lives here: reading the written package's declared + * `manifest.dependencies` off the registry and handing the result to the gate. + * The two halves fail differently — a correct closure function reached with no + * scope narrows nothing, silently, forever — so both are pinned. + * + * ## The discriminating test is the one that expects SILENCE + * + * Asserting only that an out-of-closure reference gets reported would also pass + * if the closure were empty, i.e. if narrowing had gone too far and every + * reference dangled. So the load-bearing case here is the flow into a DECLARED + * DEPENDENCY: it must stay clean, which it can only do if the dependency walk + * really ran and really put `com.acme.core` in the closure. That is the #4449 + * wired-and-running-on-nothing shape, in the other direction. + * + * Harness: the real repository write path over a stub engine, the same shape as + * `protocol.dashboard-dataset-publish-gate.test.ts`. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +// The producer's OWN write-verb dispatch decisions, so the fake engine cannot +// accept a call ObjectQL refuses. From `@objectstack/metadata-core`, never from +// `@objectstack/objectql` — that import would close a cycle turbo rejects. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const OWN = 'com.acme.crm'; +const DEP = 'com.acme.core'; +const STRANGER = 'com.other.analytics'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + state: string; + metadata: string; + checksum?: string; +} + +const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`; + +const object = (name: string, packageId: string) => ({ + name, + label: name, + _packageId: packageId, + fields: [{ name: 'title', type: 'text', label: 'Title' }], +}); + +/** A Zod-valid autolaunched flow whose start node fires on `objectName`. */ +const flowOn = (name: string, objectName: string) => ({ + name, + label: name, + description: `fires on ${objectName}`, + version: 1, + status: 'active', + type: 'autolaunched', + variables: [], + nodes: [ + { + id: 'start', + type: 'start', + label: 'On update', + config: { objectName, triggerType: 'record-after-update' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end', type: 'default', isDefault: false }], +}); + +function makeStubEngine() { + const rows = new Map(); + let nextId = 0; + const findRow = (w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + for (const [k, r] of rows) { + if (w.type !== undefined && r.type !== w.type) continue; + if (w.name !== undefined && r.name !== w.name) continue; + if (w.organization_id !== undefined && r.organization_id !== w.organization_id) continue; + if (w.state !== undefined && r.state !== w.state) continue; + return { key: k, row: r }; + } + return null; + }; + /** Three packages: the written one, the one it declares, and a stranger. */ + const packages: Record }> = { + [OWN]: { manifest: { name: OWN, version: '1.0.0', dependencies: { [DEP]: '^1.0.0' } } }, + [DEP]: { manifest: { name: DEP, version: '1.0.0' } }, + [STRANGER]: { manifest: { name: STRANGER, version: '1.0.0' } }, + }; + const engine: any = { + async findOne(_t: string, opts: { where: Record }) { + return findRow(opts.where)?.row ?? null; + }, + async find(_t: string, opts: { where: Record }) { + return Array.from(rows.values()).filter((r) => { + if (opts.where.type && r.type !== opts.where.type) return false; + if (opts.where.organization_id !== undefined + && r.organization_id !== opts.where.organization_id) return false; + if (opts.where.state && r.state !== opts.where.state) return false; + return true; + }); + }, + async insert(_t: string, data: Record) { + if (_t === 'sys_metadata_audit') return { id: 'audit_skip' }; + nextId += 1; + const row = { id: `r_${nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const found = findRow(opts.where); + if (!found) return { id: null }; + rows.set(found.key, { ...found.row, ...(data as any) }); + return { id: found.row.id }; + }, + async delete(_t: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + const found = findRow(opts.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + listItems: (type: string) => + type === 'object' + ? [object('crm_account', OWN), object('core_user', DEP), object('other_widget', STRANGER)] + : [], + getItem: () => undefined, + getPackage: (id: string) => packages[id], + }, + }; + return { engine, rows }; +} + +function makeProtocol() { + const { engine, rows } = makeStubEngine(); + return { protocol: new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any, rows }; +} + +const save = (protocol: any, item: any, extra: Record = {}) => + protocol.saveMetaItem({ type: 'flow', name: item.name, item, ...extra }); + +const triggerAdvisories = (result: any): string[] => + (result.advisories ?? []).map((a: any) => a.rule).filter((r: string) => r === 'flow-trigger-unknown-object'); + +describe('the write package closure reaches the gate (#9612)', () => { + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + warn.mockRestore(); + }); + + it('keeps a DECLARED DEPENDENCY inside the closure — the discriminating case', async () => { + const { protocol } = makeProtocol(); + + const result = await save(protocol, flowOn('crm_on_core_user', 'core_user'), { packageId: OWN }); + + expect( + triggerAdvisories(result), + `'${DEP}' is declared in '${OWN}'’s manifest.dependencies, so 'core_user' MUST be in the ` + + `closure. A report here means the dependency walk never ran and the gate is judging a ` + + `package against a closure that holds only itself — narrowing gone too far, which reads ` + + `as a phantom to every author in the product.`, + ).toEqual([]); + expect(result.success).toBe(true); + }); + + it('keeps the written package itself inside the closure', async () => { + const { protocol } = makeProtocol(); + const result = await save(protocol, flowOn('crm_on_own', 'crm_account'), { packageId: OWN }); + expect(triggerAdvisories(result)).toEqual([]); + }); + + it('reports a reference into a package the written one never declared', async () => { + // ⭐ The ruling's intended consequence, pinned as a decision rather than + // left to surprise someone: a package's declared `dependencies` bound + // what it MAY reference, so a flow reaching into `com.other.analytics` + // is not resolvable BY DECLARATION. Advisory severity — it reports, it + // does not refuse. + const { protocol } = makeProtocol(); + + const result = await save(protocol, flowOn('crm_on_stranger', 'other_widget'), { packageId: OWN }); + + expect(triggerAdvisories(result)).toEqual(['flow-trigger-unknown-object']); + expect(result.success).toBe(true); + }); + + it('narrows NOTHING when the write names no package', async () => { + // ⛔ The fallback direction, and the whole reason this is not a size + // threshold: an unstated package buys MORE validation input, never + // less. The identical body that reports above is silent here, because + // the gate is handed the whole collection exactly as before #9612. + const { protocol } = makeProtocol(); + + const result = await save(protocol, flowOn('crm_on_stranger', 'other_widget')); + + expect(triggerAdvisories(result)).toEqual([]); + }); + + it('narrows NOTHING when the registry cannot produce the written package', async () => { + // Its `manifest.dependencies` is the ONLY declaration of what it may + // reference; without it there is no bound, and an unbounded closure is + // the whole collection. + const { protocol } = makeProtocol(); + + const result = await save(protocol, flowOn('crm_on_stranger', 'other_widget'), { + packageId: 'com.never.installed', + }); + + expect(triggerAdvisories(result)).toEqual([]); + }); + + it('narrows NOTHING for the `sys_metadata` overlay sentinel', async () => { + // Not a package id — the registry itself tests for exactly this string + // before treating an item as artifact-backed. + const { protocol } = makeProtocol(); + + const result = await save(protocol, flowOn('crm_on_stranger', 'other_widget'), { + packageId: 'sys_metadata', + }); + + expect(triggerAdvisories(result)).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index dfd1c27a33..9ba1eb6b91 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3823,6 +3823,14 @@ export class ObjectStackProtocolImplementation implements * which is one limb of the #6285 refusal combination. */ organizationId?: string | null; + /** + * [#9612] The package this write belongs to (`saveMetaItem`'s + * `packageId`, or the promoted draft row's). It decides the CLOSURE the + * write is judged against — see {@link resolveWritePackageScope}. + * Absent, unknown to the registry, or the `sys_metadata` overlay + * sentinel ⇒ nothing is narrowed. + */ + packageId?: string | null; }): RuntimeAuthoringIssue[] { // [#6710] The ADR-0005 carve-out, now DECLARED instead of inferred. // @@ -3909,6 +3917,11 @@ export class ObjectStackProtocolImplementation implements // dangling (see RuntimeStackContext.datasets). const datasets = listCollection('dataset', 'datasets'); + // [#9612] The closure this write is judged against. Resolved from the + // package registry — the impure read — and handed to the pure gate as + // an argument, the same shape `orgWallEnforced` uses below. + const packageScope = this.resolveWritePackageScope(evt.packageId); + const verdict = evaluateRuntimeAuthoringGate({ type: singular, name: evt.name, @@ -3918,6 +3931,7 @@ export class ObjectStackProtocolImplementation implements permissions, books, datasets, + ...(packageScope !== undefined ? { packageScope } : {}), ...(evt.organizationId !== undefined ? { organizationId: evt.organizationId } : {}), orgWallEnforced: this.orgWallEnforced(), }); @@ -3925,6 +3939,89 @@ export class ObjectStackProtocolImplementation implements return verdict.advisories; } + /** + * [#9612] The package closure a write is judged against, or `undefined` + * when the gate must keep receiving the whole tenant. + * + * ## What it computes + * + * `{ packageId, dependencies }` where `dependencies` is the TRANSITIVE + * closure of the written package's declared `manifest.dependencies`. The + * lint gate then keeps an object when it belongs to one of those packages, + * is a platform/system object, or carries no package provenance at all + * (`narrowObjectsToPackageClosure`). + * + * ## Why it can be exact rather than an estimate + * + * A package's declared dependencies bound what it MAY reference. So the + * set of objects a package write can legitimately resolve against is a + * DECLARED fact the platform already stores, not something the gate has to + * infer from the body it is judging. That is the whole reason the + * maintainer's ruling picks the package as the validation unit + * (「客户开发开发,校验是否也应该基于软件包」) rather than "whatever this + * rule can be proven to reach", which would be a second opinion per rule. + * + * ## Every way this returns `undefined`, and why that direction is safe + * + * - no package id on the write (a bare tenant overlay row); + * - the `sys_metadata` sentinel, which marks a rehydrated overlay row and + * is not a package id (`registry.ts` reads it the same way); + * - **the registry cannot produce the package** — so its dependency + * declaration cannot be read, and an undeclared bound is not a bound; + * - the registry has no `getPackage` at all (a metadata-only test double), + * or throws. + * + * In every one of those the gate receives the WHOLE collection and behaves + * exactly as it did before this change. ⛔ There is deliberately no branch + * that narrows on a guess and no branch that skips rules: the failure + * direction is more validation input, never less. A "skip when N is large" + * fast path is the fail-open at scale this card was explicitly forbidden to + * build (#9798 declared-but-unenforced, #9261 an outage read as emptiness, + * ADR-0110 D3 — a miss and a fault are different facts). + */ + private resolveWritePackageScope( + packageId: string | null | undefined, + ): { packageId: string; dependencies: string[] } | undefined { + if (typeof packageId !== 'string' || packageId === '' || packageId === 'sys_metadata') return undefined; + try { + const registry = this.engine.registry as + | { getPackage?: (id: string) => { manifest?: unknown } | undefined } + | undefined; + if (typeof registry?.getPackage !== 'function') return undefined; + // The written package itself must be readable: its manifest is the + // ONLY declaration of what it may reference, and narrowing without + // it would be narrowing on nothing. + if (!registry.getPackage(packageId)) return undefined; + + const dependencies = new Set(); + const frontier = [packageId]; + while (frontier.length > 0) { + const current = frontier.pop() as string; + const manifest = registry.getPackage(current)?.manifest as + | { dependencies?: Record } + | undefined; + const declared = manifest?.dependencies; + if (!declared || typeof declared !== 'object') continue; + for (const dep of Object.keys(declared)) { + // A dependency the registry cannot resolve is still IN the + // closure: the package declared it, so an object stamped + // with it is reachable by declaration. Only the transitive + // walk stops there — `getPackage` returns undefined and the + // loop above simply contributes nothing further. + if (dep === packageId || dependencies.has(dep)) continue; + dependencies.add(dep); + frontier.push(dep); + } + } + return { packageId, dependencies: [...dependencies] }; + } catch { + // Context gathering must never fail a write (the same contract + // `listCollection` above holds). An unreadable registry means an + // unnarrowed gate, not a refused publish. + return undefined; + } + } + /** * [#6285] Does this deployment enforce an organization wall (ADR-0105 D1)? * @@ -12903,6 +13000,11 @@ export class ObjectStackProtocolImplementation implements // it simply never travelled to the gate, which is the whole reason // the "platform-level flow" limb could not be judged before. organizationId: request.organizationId ?? null, + // [#9612] Which package this write belongs to — the unit the gate + // now judges it against. Same story as the line above: the request + // has carried it all along, it just never reached the gate. Null + // (a bare tenant overlay) narrows nothing. + packageId: request.packageId ?? null, }); // Pre-persistence authoring gate (#3050): a domain plugin may veto the @@ -14107,6 +14209,23 @@ export class ObjectStackProtocolImplementation implements // it the draft door would be a bypass for this refusal alone, // which is the exact hole #4463 D1 closed for the other 26. organizationId: orgId, + // [#9612] The package binding the CALLER stated for this + // promotion — the same value threaded into `repo.promoteDraft` + // below, so the gate and the write resolve the draft under one + // key rather than two. `publishPackageDrafts` states it (a + // package publish, which is exactly the write this card is + // about); bare `publishMetaItem` names no package and so + // narrows nothing, which is the correct answer rather than a + // gap — a promotion whose package is unstated has no declared + // dependency set to bound it. + // + // ⚠️ Deliberately NOT read off `draftForGate`: `rowToItem` + // projects `sys_metadata` into a `MetadataItem`, which carries + // no package id at all, so a read from there would be + // silently `undefined` on every path — narrowing that never + // fires while looking like it does. Filed rather than widened + // here, because `MetadataItem` is a `packages/spec` contract. + ...(request.packageId !== undefined ? { packageId: request.packageId } : {}), }) : []; diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index f2da53320b..da1c3dbdda 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -46,7 +46,11 @@ * refusal to a loud log for a migration window. */ -import { runRuntimeAuthoringRules, type AuthoringFinding } from '@objectstack/lint/runtime'; +import { + runRuntimeAuthoringRules, + type AuthoringFinding, + type RuntimePackageScope, +} from '@objectstack/lint/runtime'; // The ONE declaration of "which `config` key on which container node type holds // a nested region" (#4401, `spec/src/automation/region-slots.ts`). Four passes // already walk regions over this table — three in `spec`, one in `lint` — and @@ -424,6 +428,24 @@ export function evaluateRuntimeAuthoringGate(args: { datasets?: readonly unknown[]; /** ADR-0080 SDUI manifest when the host has one. */ sduiManifest?: unknown; + /** + * [#9612] The package this write belongs to, and the transitive closure of + * that package's DECLARED dependencies — resolved by the caller, which is + * the side holding the package registry. + * + * Present ⇒ the gate judges the write against + * `package + declared deps + platform/system + unpackaged overlay rows` + * instead of against every object in the tenant, per the maintainer's + * ruling that the validation unit is the package (「客户开发开发,校验是否 + * 也应该基于软件包」·「当然这里面要考虑系统对象」). + * + * ⛔ Absent ⇒ nothing is narrowed and the whole collection is judged. That + * is the ONLY fallback, and its direction is deliberate: an unresolvable + * package buys the write MORE input, never less. A fallback that skipped + * rules — or skipped them past some size — would be the fail-open at scale + * this card was forbidden to build. + */ + packageScope?: RuntimePackageScope; /** * [#6285] The organization partition this write lands in — `saveMetaItem`'s * `organizationId`, absent/null for a platform-level (environment) write. @@ -451,6 +473,7 @@ export function evaluateRuntimeAuthoringGate(args: { const result = runRuntimeAuthoringRules({ type: args.type, item: args.body, + ...(args.packageScope !== undefined ? { packageScope: args.packageScope } : {}), context: { objects: args.objects ?? [], permissions: args.permissions ?? [], diff --git a/scripts/bench/runtime-publish-gate.bench.mts b/scripts/bench/runtime-publish-gate.bench.mts index 40aac17d4c..2b9a7aca25 100644 --- a/scripts/bench/runtime-publish-gate.bench.mts +++ b/scripts/bench/runtime-publish-gate.bench.mts @@ -10,6 +10,13 @@ * door dispatches. * --mode closure (#9905) the whole-gate cost when the `objects` collection * is narrowed to the written item's reference closure. + * --mode package (#9612) the whole-gate cost when `objects` is narrowed to + * the written item's PACKAGE closure, by the SHIPPED + * deriver — plus the differential-verdict check and two + * positive controls. Unlike `--mode closure` this is not a + * hypothetical: it calls the same + * `narrowObjectsToPackageClosure` the gate calls, on a + * PACKAGED seed, so the number describes shipped behaviour. * * ⛔ `--mode closure` measures a HYPOTHETICAL. It narrows what the rules are * handed HERE, in this script, and changes nothing about the shipped gate — @@ -132,6 +139,7 @@ import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor, buildRuntimeWriteSnapshots, + narrowObjectsToPackageClosure, } from '../../packages/lint/dist/runtime.js'; import * as showcaseObjects from '../../examples/app-showcase/src/data/objects/index.js'; import { allFlows } from '../../examples/app-showcase/src/automation/flows/index.js'; @@ -405,6 +413,189 @@ function closureMode() { } } + +// ───────────────────────────────────────────────────────────────────────────── +// #9612 — `--mode package`: what narrowing to the written item's PACKAGE +// closure buys, measured against the SHIPPED deriver rather than a hypothetical. +// ───────────────────────────────────────────────────────────────────────────── + +/** Objects per synthetic package in the packaged seed. */ +const PKG_SIZE = 22; +/** The package the written item belongs to. */ +const OWN_PKG = 'pkg_0'; +/** The one package `OWN_PKG` declares a dependency on. */ +const DEP_PKG = 'pkg_1'; +/** The platform's package — never declared as a dependency by anyone. */ +const PLATFORM_PKG = 'pkg_platform'; +/** A platform object the written item legitimately references. */ +const SYSTEM_OBJECT = 'sys_bench_ledger'; + +/** + * The seed, stamped with package provenance — the shape the ruling describes. + * + * A tenant that has grown to N objects is MANY packages, not one, so the seed + * distributes the corpus across `ceil(N / PKG_SIZE)` packages. It also carries + * ONE platform object (`isSystem`, `sys_` prefix, owned by a package nobody + * declares) so the "system objects are unconditionally in the closure" limb has + * something to be right or wrong about — control B below is what makes that a + * measurement rather than an assertion. + */ +function seedPackaged(shape: string, n: number): AnyRec[] { + const objects = SHAPES[shape]!(n).map((o, i) => ({ + ...o, + _packageId: `pkg_${Math.floor(i / PKG_SIZE)}`, + })); + const platform = JSON.parse(JSON.stringify(SHOWCASE_OBJECTS[0]!)) as AnyRec; + platform.name = SYSTEM_OBJECT; + platform.isSystem = true; + platform._packageId = PLATFORM_PKG; + return [...objects, platform]; +} + +/** `{ packageId, dependencies }` as the host resolves it from the registry. */ +const PACKAGE_SCOPE = { packageId: OWN_PKG, dependencies: [DEP_PKG] } as const; + +/** + * The written item, stamped into `OWN_PKG` and — for a flow — repointed at the + * platform object so control B has a reference that must survive the closure. + */ +function packagedWrittenItem(): AnyRec { + if (WRITE_TYPE !== 'flow') return { ...WRITTEN_OBJECT, _packageId: OWN_PKG }; + const flow = JSON.parse(JSON.stringify(SHOWCASE_FLOW)) as AnyRec; + const primary = referenceClosure(flow, SHAPES.real!(PKG_SIZE))[0]; + const primaryName = primary ? String(primary.name) : undefined; + const repointed = primaryName + ? (JSON.parse( + JSON.stringify(flow).split(JSON.stringify(primaryName).slice(1, -1)).join(SYSTEM_OBJECT), + ) as AnyRec) + : flow; + repointed._packageId = OWN_PKG; + return repointed; +} + +/** + * The item positive control A drives: one that really does reference objects in + * its OWN package, so dropping that package from the closure has something to + * break. For a flow that is the shipped showcase flow untouched; for an object + * write it is the same body the timing legs use, because the object door offers + * no cross-object reference to break — which is itself the measurement. + */ +function controlItemOwnPackage(): AnyRec { + if (WRITE_TYPE !== 'flow') return { ...WRITTEN_OBJECT, _packageId: OWN_PKG }; + const flow = JSON.parse(JSON.stringify(SHOWCASE_FLOW)) as AnyRec; + flow._packageId = OWN_PKG; + return flow; +} + +/** + * A finding's identity for verdict comparison, in two readings. + * + * ⚠️ `raw` includes `path`, and on the OBJECT door every finding's path is + * INDEX-based (`objects[417].sharingModel`). Narrowing moves the written item's + * index, so `raw` differs between a full and a narrowed run even when the two + * runs found the identical defect on the identical object — a naive comparison + * reads that as a phantom. `semantic` drops the array index and keeps + * everything a reader acts on (`where` already carries the object NAME), so it + * is the reading that answers "did narrowing change the ANSWER". + * + * BOTH are printed. The index difference is a real, wire-visible consequence of + * this change and hiding it behind the normalisation would be dishonest. + */ +const stripIndices = (p: unknown) => String(p ?? '').replace(/\[\d+\]/g, '[i]'); +const verdictRaw = (r: { errors: AnyRec[]; advisories: AnyRec[] }) => + [...r.errors, ...r.advisories].map((f) => `${f.rule}|${f.where}|${f.path}|${f.message}`).sort(); +const verdictSemantic = (r: { errors: AnyRec[]; advisories: AnyRec[] }) => + [...r.errors, ...r.advisories] + .map((f) => `${f.rule}|${f.where}|${stripIndices(f.path)}|${f.message}`) + .sort(); + +function diffCount(a: string[], b: string[]): { added: number; removed: number } { + const bs = new Set(b); + const as = new Set(a); + return { + added: b.filter((x) => !as.has(x)).length, + removed: a.filter((x) => !bs.has(x)).length, + }; +} + +function packageMode() { + const item = packagedWrittenItem(); + console.log(''); + console.log(` package closure — own '${OWN_PKG}' + declared dep '${DEP_PKG}' + platform/system + unpackaged`); + console.log(` seed: ${PKG_SIZE} objects per package, plus one '${SYSTEM_OBJECT}' owned by '${PLATFORM_PKG}' (declared by nobody)`); + + for (const shape of Object.keys(SHAPES)) { + for (const n of SIZES) { + const objects = seedPackaged(shape, n); + const closure = narrowObjectsToPackageClosure(objects, PACKAGE_SCOPE) as AnyRec[]; + + const runFull = () => runRuntimeAuthoringRules({ type: WRITE_TYPE, item, context: { objects } }); + const runScoped = () => + runRuntimeAuthoringRules({ type: WRITE_TYPE, item, context: { objects }, packageScope: PACKAGE_SCOPE }); + + const full = timeMedian(() => { runFull(); }); + const scoped = timeMedian(() => { runScoped(); }); + const fullV = runFull(); + const scopedV = runScoped(); + + console.log(''); + console.log( + ` shape=${shape} N=${objects.length}: |closure| = ${closure.length}` + + ` (closure/N = ${((closure.length / objects.length) * 100).toFixed(1)}%)`, + ); + console.log( + ` whole gate: full ${full.toFixed(2)} ms → package closure ${scoped.toFixed(2)} ms` + + ` — saving ${(((full - scoped) / full) * 100).toFixed(1)}%`, + ); + const rawD = diffCount(verdictRaw(fullV), verdictRaw(scopedV)); + const semD = diffCount(verdictSemantic(fullV), verdictSemantic(scopedV)); + console.log( + ` differential verdict — semantic: ${semD.added === 0 && semD.removed === 0 ? 'UNCHANGED' : `CHANGED (+${semD.added} / -${semD.removed})`}` + + ` · raw incl. array index: ${rawD.added === 0 && rawD.removed === 0 ? 'unchanged' : `+${rawD.added} / -${rawD.removed} (index-only differences show here)`}`, + ); + console.log(` findings: full ${fullV.errors.length}e/${fullV.advisories.length}a → scoped ${scopedV.errors.length}e/${scopedV.advisories.length}a`); + + // ── Positive control A — the closure loses the written package itself. + // + // Driven by {@link controlItemOwnPackage}, NOT by `item`: the timed item + // above was repointed at the platform object for control B's sake, so it + // no longer references its own package and would make this control + // vacuous BY CONSTRUCTION rather than by a property of the door. This + // control needs an item whose references live in `pkg_0`. + // + // If it prints a zero delta the verdict check above is not falsifiable on + // this door, so it is printed whatever it says. + const ctrlItem = controlItemOwnPackage(); + const ctrlAFull = runRuntimeAuthoringRules({ type: WRITE_TYPE, item: ctrlItem, context: { objects } }); + const ctrlA = objects.filter((o) => { + const owner = String(o._packageId ?? ''); + return owner === DEP_PKG || o.isSystem === true || String(o.name ?? '').startsWith('sys_'); + }); + const ctrlAV = runRuntimeAuthoringRules({ type: WRITE_TYPE, item: ctrlItem, context: { objects: ctrlA } }); + const ctrlAD = diffCount(verdictSemantic(ctrlAFull), verdictSemantic(ctrlAV)); + console.log( + ` positive control A (own package dropped, |set| = ${ctrlA.length}): ` + + `${ctrlAD.added === 0 && ctrlAD.removed === 0 ? '⚠️ NO DELTA — control is vacuous on this door' : `delta +${ctrlAD.added} / -${ctrlAD.removed}`}`, + ); + + // ── Positive control B — the card's named hazard: a closure that omits + // SYSTEM objects. The written item references `sys_bench_ledger`, which + // no package declares, so only the unconditional system limb keeps it. + const ctrlB = objects.filter((o) => { + const owner = String(o._packageId ?? ''); + return owner === OWN_PKG || owner === DEP_PKG; + }); + const ctrlBV = runRuntimeAuthoringRules({ type: WRITE_TYPE, item, context: { objects: ctrlB } }); + const ctrlBD = diffCount(verdictSemantic(fullV), verdictSemantic(ctrlBV)); + console.log( + ` positive control B (system objects dropped, |set| = ${ctrlB.length}): ` + + `${ctrlBD.added === 0 && ctrlBD.removed === 0 ? '⚠️ NO DELTA — this door has no objects×objects coupling' : `delta +${ctrlBD.added} / -${ctrlBD.removed}`}`, + ); + } + } +} + if (MODE === 'per-rule') perRuleMode(); else if (MODE === 'closure') closureMode(); +else if (MODE === 'package') packageMode(); else totalMode(); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 747cbf351e..cb40c4658a 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -406,6 +406,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.package-closure-gate.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.package-closure-gate.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts", "verb": "delete",