From 8583bfbbace3aa719df1e332080c247c7deabca9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 07:30:54 +0000 Subject: [PATCH 1/4] test(cli): ledger the four `@objectstack/verify` option-B losses (#15229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of the card's ordered pair: make the loss visible BEFORE fixing it, so the fix has something to be checked against. The by-shape sweep (#15210) found these four reads; neither `@objectstack/verify` site had a row in the #15004 acceptance pin, which is why the sweep and not the pin is what found them. Four rows, all at B2 (`os verify` has one door — `loadConfig` — and never opens a compiled artifact, so a B1 twin would measure a path no command drives): - `deriveCrudCases` · objects — zero CRUD round-trip cases derived - `deriveCrudCases` · datasources — the ADR-0015 double write gate - `declaredPositionNames` · positions — no persona for any declared position - `rlsProbePermissionSet` · objects — an empty probe permission set Every row calls a reader the package SHIPS; none re-implements one. The datasource row needed a fixture member the zoo lacked: the datasource-by- name map decides exactly one thing — whether a federated object's probe insert clears ADR-0015's double opt-in — so watching it requires a federated object. It is declared in the module package while the datasource that gates it is in the App package, which makes the row a cross-package resolution. The control's registry list grows by that object; the control itself is untouched. RED, with only the rows added and the ledger lines absent: A subsystem lost a collection that the ledger does not carry — this is the failure #15004 exists to make loud. B2 · verify declaredPositionNames (one RLS persona per declared position) · positions B2 · verify deriveCrudCases (CRUD round-trip case derivation) · objects B2 · verify deriveCrudCases federated write gate (ADR-0015 double opt-in) · datasources B2 · verify rlsProbePermissionSet (RLS probe grants + owner narrowing) · objects The BASELINE (additive shape) stayed green in that same run, so the red is a discrimination and not a broken fixture. The four lines are then added to OPTION_B_LOSSES, which is what this commit leaves in the tree; the next commit fixes the readers and deletes them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --- .../test/fixtures/option-b-collection-zoo.ts | 51 ++++++++++++++++ .../test/fixtures/option-b-reader-probe.ts | 59 +++++++++++++++++++ .../option-b-reader-acceptance.pin.test.ts | 17 +++++- 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/fixtures/option-b-collection-zoo.ts b/packages/cli/test/fixtures/option-b-collection-zoo.ts index 16ccf6f84b..8d78a35ec4 100644 --- a/packages/cli/test/fixtures/option-b-collection-zoo.ts +++ b/packages/cli/test/fixtures/option-b-collection-zoo.ts @@ -79,6 +79,28 @@ export const ORDERS_PACKAGE_ID = 'com.example.probe.orders'; export const PROBE_DATASOURCE = 'probe_primary'; /** Relative to the project root — `resolve-project-database` anchors it there. */ export const PROBE_DATASOURCE_FILE = '.objectstack/data/probe-primary.db'; +/** + * A FEDERATED datasource (ADR-0015), write gate OPEN. + * + * It is here for one reader: `deriveCrudCases` builds a datasource-by-name map + * and consults it for exactly ONE decision — whether a federated object's probe + * insert is allowed through the double write gate — so the only way to watch + * that map is to carry an external object whose gate depends on it. Declared in + * the App package while the object it gates lives in the module package, so the + * row watching it measures a CROSS-PACKAGE resolution and not a within-body + * read. + * + * ⚠️ The lean probe kernel builds no live driver for it, so every boot in this + * fixture's probe logs one `federated (external) object(s) are NOT bound to + * their remote table` ERROR. That is the truthful verdict for a federated + * object with no remote behind it; it touches no row here (nothing in this + * probe reads the object's data) and is expected output, not a fixture defect. + */ +export const PROBE_FEDERATED_DATASOURCE = 'probe_federated'; +/** Relative to the project root, same anchoring as the primary. */ +export const PROBE_FEDERATED_FILE = '.objectstack/data/probe-federated.db'; +/** The write-opted-in federated object the datasource gate above admits. */ +export const PROBE_FEDERATED_OBJECT = 'probe_federated_order'; /** The `isDefault` permission set `appSecurityPluginOptions` must resolve. */ export const PROBE_DEFAULT_PERMISSION_SET = 'probe_default_profile'; export const PROBE_POSITION = 'probe_position'; @@ -137,6 +159,19 @@ const coreStack = (): ObjectStackDefinition => config: { filename: PROBE_DATASOURCE_FILE }, active: true, }, + // The federated half — see `PROBE_FEDERATED_DATASOURCE`. `datasourceMapping` + // does NOT route to it (the project default stays `probe_primary`), so the + // only thing that reaches it is the object in the OTHER package binding to + // it by name. + { + name: PROBE_FEDERATED_DATASOURCE, + label: 'Probe Federated', + driver: 'sqlite', + config: { filename: PROBE_FEDERATED_FILE }, + schemaMode: 'external', + external: { allowWrites: true }, + active: true, + }, ], datasourceMapping: [ { datasource: PROBE_DATASOURCE, default: true }, @@ -199,6 +234,22 @@ const ordersStack = (): ObjectStackDefinition => name: { name: 'name', type: 'text', label: 'Number', required: true }, }, }, + // The FEDERATED object, bound to the datasource the OTHER package + // declares and write-opted-in on its own half of ADR-0015's double gate. + // A reader that resolves objects but not datasources still gets this one + // wrong — it reports the object `blocked` as read-only — which is what + // makes the two collections separately observable through one function. + { + name: PROBE_FEDERATED_OBJECT, + label: 'Probe Federated Order', + pluralLabel: 'Probe Federated Orders', + sharingModel: 'private', + datasource: PROBE_FEDERATED_DATASOURCE, + external: { remoteName: 'remote_orders', writable: true }, + fields: { + name: { name: 'name', type: 'text', label: 'Number', required: true }, + }, + }, ], actions: [ { diff --git a/packages/cli/test/fixtures/option-b-reader-probe.ts b/packages/cli/test/fixtures/option-b-reader-probe.ts index 7bbbbe8dc6..d7e7ef71ed 100644 --- a/packages/cli/test/fixtures/option-b-reader-probe.ts +++ b/packages/cli/test/fixtures/option-b-reader-probe.ts @@ -62,6 +62,11 @@ import { resolveStandaloneDatabase, } from '@objectstack/runtime'; import { appSecurityPluginOptions } from '@objectstack/plugin-security'; +import { + declaredPositionNames, + deriveCrudCases, + rlsProbePermissionSet, +} from '@objectstack/verify'; import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; // The lowering itself, not a copy of it — reached as SOURCE, by relative path, @@ -72,6 +77,7 @@ import { lowerCallables } from '../../src/utils/lower-callables.js'; import { PROBE_DEFAULT_PERMISSION_SET, + PROBE_FEDERATED_OBJECT, PROBE_FUNCTION, PROBE_FUNCTION_EFFECT, } from './option-b-collection-zoo.js'; @@ -369,6 +375,59 @@ export async function measureShape(project: unknown, projectRoot: string): Promi Object.keys(collectBundleFunctionEntries(project as never)).length, )); + // ── B2 · `os verify`'s readers (#15229) ────────────────────────────────── + // + // `os verify` has exactly ONE door — `loadConfig` (`verify.ts:92`) — and it + // never opens a compiled artifact, so these rows are B2 and there is + // deliberately no B1 twin. A B1 row here would measure a path no command + // drives, which reads as coverage this probe does not have. + // + // Every row calls a reader `@objectstack/verify` SHIPS, per this file's rule. + // What makes these the most expensive rows in the table is what the readers + // are FOR: `os verify` derives its whole proof set from the app's metadata, + // so a collection it cannot see is not a missing feature — it is a run that + // asserts nothing and still prints `✓ verify passed`. + + const crudCases = deriveCrudCases(project) as Array<{ object: string; blocked?: string }>; + rows.push(countRow( + 'B2 · verify deriveCrudCases (CRUD round-trip case derivation) · objects', + crudCases.length, + )); + + // The datasource-by-name map, watched through the ONE decision it makes: the + // ADR-0015 double write gate. A reader that resolved `objects` but not + // `datasources` still fails this row — the case comes back `blocked` as + // "external read-only", which is a verifier silently skipping an object the + // app explicitly opted into writes for. + const federated = crudCases.find((c) => c.object === PROBE_FEDERATED_OBJECT); + rows.push(row( + 'B2 · verify deriveCrudCases federated write gate (ADR-0015 double opt-in) · datasources', + federated ? (federated.blocked ?? 'derived — probe insert allowed') : 'no case derived at all', + !federated || Boolean(federated.blocked), + )); + + rows.push(countRow( + 'B2 · verify declaredPositionNames (one RLS persona per declared position) · positions', + declaredPositionNames(project).length, + )); + + // The probe permission set is what makes an RLS run a PROBE at all: the + // object grants are what stop `checkObjectPermission` answering 403 before + // record scope is consulted, and the owner-scoped SELECT narrowing is what + // puts the persona outside the record scope. Empty on either half and every + // verdict the run prints is about neither. + const probeSet = rlsProbePermissionSet(project) as unknown as { + objects?: Record; + rowLevelSecurity?: unknown[]; + }; + const grants = Object.keys(probeSet?.objects ?? {}).length; + const narrowings = (probeSet?.rowLevelSecurity ?? []).length; + rows.push(row( + 'B2 · verify rlsProbePermissionSet (RLS probe grants + owner narrowing) · objects', + `${grants} granted object(s), ${narrowings} owner-scope rule(s)`, + grants === 0 || narrowings === 0, + )); + // ── The booted AppPlugin, on BOTH entry paths ──────────────────────────── const bootedFromArtifact = await bootAndRecord(bundle); diff --git a/packages/cli/test/option-b-reader-acceptance.pin.test.ts b/packages/cli/test/option-b-reader-acceptance.pin.test.ts index 62ae5777d1..03427b9a0a 100644 --- a/packages/cli/test/option-b-reader-acceptance.pin.test.ts +++ b/packages/cli/test/option-b-reader-acceptance.pin.test.ts @@ -147,6 +147,10 @@ const OPTION_B_LOSSES: readonly string[] = [ 'B2 · runtime collectBundleActions over the from-source config · actions + objects[].actions', 'B2 · runtime collectBundleFunctionEntries over the from-source config · functions', 'B2 · runtime collectBundleHooks over the from-source config · hooks', + 'B2 · verify declaredPositionNames (one RLS persona per declared position) · positions', + 'B2 · verify deriveCrudCases (CRUD round-trip case derivation) · objects', + 'B2 · verify deriveCrudCases federated write gate (ADR-0015 double opt-in) · datasources', + 'B2 · verify rlsProbePermissionSet (RLS probe grants + owner narrowing) · objects', 'B5 · resolve-project-database readConfigDeclaredDefault (project database tier) · datasourceMapping + datasources', ]; @@ -204,10 +208,19 @@ describe('#15004 — option-B acceptance pin: every subsystem must see its colle // control for every LOST row below: it proves the option-B fixture really // carries every definition under `packages[]`, so a zero is a reader losing // a collection and never a fixture that shipped an empty package. - expect(additive.registryObjectsFromArtifact).toEqual(['probe_account', 'probe_order']); + // `probe_federated_order` is the ADR-0015 federated object #15229 added to + // the zoo: `deriveCrudCases`'s datasource-by-name map decides exactly one + // thing — whether that object's probe insert clears the double write gate — + // so watching the map required carrying one. It registers like any other + // object, which is why it is in this control's list. + expect(additive.registryObjectsFromArtifact).toEqual( + ['probe_account', 'probe_federated_order', 'probe_order'], + ); expect(optionB.registryObjectsFromArtifact).toEqual(additive.registryObjectsFromArtifact); expect(optionB.registryObjectsFromSource).toEqual(additive.registryObjectsFromSource); - expect(optionB.registryObjectsFromSource).toEqual(['probe_account', 'probe_order']); + expect(optionB.registryObjectsFromSource).toEqual( + ['probe_account', 'probe_federated_order', 'probe_order'], + ); }); // ── The baseline: green today, and it must stay green ──────────────────── From 5c0de742c1f89953efa47d4de95e87f6ce978309 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 07:47:00 +0000 Subject: [PATCH 2/4] fix(verify): resolve objects, datasources and positions from `packages[]` (#15229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of the card's ordered pair: the readers the previous commit ledgered now see a multi-package app, and the four ledger lines are deleted in this commit. `os verify` derives its entire proof set from the app's metadata, so a collection it cannot see is not a missing feature — it is a run that asserts nothing and still prints `✓ verify passed`. Under an option-B artifact `deriveCrudCases` derived ZERO cases and `rlsProbePermissionSet` built an EMPTY probe permission set: the persona that makes an RLS run a probe granted nothing and narrowed nothing, and no persona was minted for any declared position. The four reads go through one resolver (`declaredCollection`), which answers the caller's ORIGINAL expression first and consults `packages[]` only where that came back falsy: - the top level is returned untouched when truthy, so today's additive artifact answers bit-identically and this card is revertible on its own; - `objects: []` is TRUTHY, so a declared-empty collection stays empty — the behaviour a re-expression as `resolve(...).length > 0` would have changed (measured on the sibling card #15006); - package order comes from `resolveArtifactPackageOrder` (`@objectstack/core`, ADR-0130 D4+D5), never from a second traversal of `config.packages`; - a malformed `packages` now raises that function's ADR-0112 refusal instead of reading as "this app declares nothing". Both `deriveCrudCases` reads move together on purpose. Objects alone would leave ADR-0015's double write gate judging against an empty datasource map, which reports a write-opted-in federated object as read-only — a verifier silently skipping an object the app explicitly opted into writes for. The pin's RED before this commit named exactly the four rows to delete: A ledgered subsystem now SEES its collections under option B — the reader program moved forward. Delete these lines from OPTION_B_LOSSES: B2 · verify declaredPositionNames (one RLS persona per declared position) · positions B2 · verify deriveCrudCases (CRUD round-trip case derivation) · objects B2 · verify deriveCrudCases federated write gate (ADR-0015 double opt-in) · datasources B2 · verify rlsProbePermissionSet (RLS probe grants + owner narrowing) · objects `composeStacks`, `packages/spec/src/stack.zod.ts` and what every command emits are untouched: the artifact stays additive through this card. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --- .../verify-reads-package-owned-collections.md | 37 ++++ .../option-b-reader-acceptance.pin.test.ts | 11 +- .../verify/src/artifact-collections.test.ts | 167 ++++++++++++++++++ packages/verify/src/artifact-collections.ts | 80 +++++++++ packages/verify/src/derive.ts | 12 +- packages/verify/src/rls.ts | 15 +- 6 files changed, 310 insertions(+), 12 deletions(-) create mode 100644 .changeset/verify-reads-package-owned-collections.md create mode 100644 packages/verify/src/artifact-collections.test.ts create mode 100644 packages/verify/src/artifact-collections.ts diff --git a/.changeset/verify-reads-package-owned-collections.md b/.changeset/verify-reads-package-owned-collections.md new file mode 100644 index 0000000000..66c861ab06 --- /dev/null +++ b/.changeset/verify-reads-package-owned-collections.md @@ -0,0 +1,37 @@ +--- +"@objectstack/verify": patch +--- + +fix(verify): `os verify` no longer reports a green run over a multi-package app it measured nothing about + +Every reader in this package took the artifact's **flattened** top level and +nothing else. A multi-package app whose definitions live under `packages[]` — +the shape ADR-0130 D4's option B emits — therefore reached `deriveCrudCases` +with no objects and no datasources, and reached `rlsProbePermissionSet` and +`declaredPositionNames` with no objects and no positions. Nothing threw. The run +derived zero CRUD round-trip cases, built an empty RLS probe permission set, +minted no persona for any declared position, and printed `✓ verify passed`. + +That is the most expensive place in the platform for a false green: `verify`'s +entire job is to be the thing that notices. A missing collection is at least +missing — zero coverage dressed as a passing run is not. + +The four reads now resolve through `resolveArtifactPackageOrder` +(`@objectstack/core`, ADR-0130 D4+D5), **flattened top level first**: + +- `deriveCrudCases` — the objects it derives cases for, and the datasource-by- + name map behind ADR-0015's double write gate. Both, because objects alone + would leave a write-opted-in federated object judged against an empty + datasource map and reported read-only, i.e. skipped by a verifier that says it + covered it. +- `declaredPositionNames` — one RLS persona per declared position. +- `rlsProbePermissionSet` — the object grants and the owner-scoped narrowing + that are what make an RLS run a probe rather than a report about the object + gate. + +The top-level read still answers first and is returned untouched, so an app on +today's additive artifact gets a bit-identical answer, and a stack that declares +an empty collection (`objects: []` is truthy) still gets an empty one. Only a +top level that does not carry the key at all consults `packages[]`. A malformed +`packages` array now surfaces `resolveArtifactPackageOrder`'s ADR-0112 refusal +instead of reading as "this app declares nothing". diff --git a/packages/cli/test/option-b-reader-acceptance.pin.test.ts b/packages/cli/test/option-b-reader-acceptance.pin.test.ts index 03427b9a0a..194c97a25b 100644 --- a/packages/cli/test/option-b-reader-acceptance.pin.test.ts +++ b/packages/cli/test/option-b-reader-acceptance.pin.test.ts @@ -122,6 +122,13 @@ import { measureShape, type ProbeRow, type ShapeMeasurement } from './fixtures/o * * ⛔ SHRINK-ONLY, audited in BOTH directions (see the header). Each line names * a boundary, a subsystem and the collection it reads. + * + * `@objectstack/verify`'s four rows are absent for a reason worth stating + * rather than inferring: the by-shape sweep (#15210) found those sites, not + * this pin, so card 5/4 (#15229) ADDED them here and DELETED them again inside + * one PR — ledgered red first, then fixed. Both halves are in that card's + * history; the probe still measures all four, which is what keeps a regression + * in them red. */ const OPTION_B_LOSSES: readonly string[] = [ 'B1 · AppPlugin declared-datasource auto-connect (compiled artifact) · datasources', @@ -147,10 +154,6 @@ const OPTION_B_LOSSES: readonly string[] = [ 'B2 · runtime collectBundleActions over the from-source config · actions + objects[].actions', 'B2 · runtime collectBundleFunctionEntries over the from-source config · functions', 'B2 · runtime collectBundleHooks over the from-source config · hooks', - 'B2 · verify declaredPositionNames (one RLS persona per declared position) · positions', - 'B2 · verify deriveCrudCases (CRUD round-trip case derivation) · objects', - 'B2 · verify deriveCrudCases federated write gate (ADR-0015 double opt-in) · datasources', - 'B2 · verify rlsProbePermissionSet (RLS probe grants + owner narrowing) · objects', 'B5 · resolve-project-database readConfigDeclaredDefault (project database tier) · datasourceMapping + datasources', ]; diff --git a/packages/verify/src/artifact-collections.test.ts b/packages/verify/src/artifact-collections.test.ts new file mode 100644 index 0000000000..17f5baf5d5 --- /dev/null +++ b/packages/verify/src/artifact-collections.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0130 D4 / option B — this package's readers over a multi-package app + * (#15229, reader program 5/4 of the ruling on #14512). + * + * The acceptance pin for the program lives in `@objectstack/cli` + * (`option-b-reader-acceptance.pin.test.ts`, #15004) and measures these readers + * through a booted two-package fixture. What is here instead is the CONTRACT of + * the resolution itself, which that pin cannot see: that the flattened top level + * still answers FIRST — including when it is an empty array — and that a + * malformed `packages` refuses rather than reading as "no collections". + * + * Every test drives a SHIPPED reader (`deriveCrudCases`, `declaredPositionNames`, + * `rlsProbePermissionSet`), never `declaredCollection` directly: a test shaped + * like the helper would pass over a reader that never calls it. + */ + +import { describe, expect, it } from 'vitest'; + +import { deriveCrudCases } from './derive.js'; +import { declaredPositionNames, rlsProbePermissionSet } from './rls.js'; + +/** + * One package body, as `packages[i].manifest` carries it: an + * `AssembledPackageBodySchema` — `ManifestSchema` fields at the TOP of the body + * with the collections beside them, never a nested `manifest` key. + * `resolveArtifactPackageOrder` parses each entry whole, so these bodies are + * real definitions and not sketches. + */ +const corePackage = { + id: 'com.example.readers.core', + name: 'Readers Core', + version: '1.0.0', + type: 'app', + objects: [ + { + name: 'reader_account', + label: 'Reader Account', + fields: { name: { name: 'name', type: 'text', label: 'Name', required: true } }, + }, + ], + datasources: [ + { + name: 'reader_warehouse', + label: 'Reader Warehouse', + driver: 'sqlite', + config: { filename: '.objectstack/data/reader-warehouse.db' }, + schemaMode: 'external', + external: { allowWrites: true }, + }, + ], + positions: [{ name: 'reader_position', label: 'Reader Position' }], +}; + +const ordersPackage = { + id: 'com.example.readers.orders', + name: 'Readers Orders', + version: '1.0.0', + type: 'module', + dependencies: { 'com.example.readers.core': '^1.0.0' }, + objects: [ + { + name: 'reader_wh_order', + label: 'Warehouse Order', + datasource: 'reader_warehouse', + external: { remoteName: 'orders', writable: true }, + fields: { name: { name: 'name', type: 'text', label: 'Number', required: true } }, + }, + ], + positions: [{ name: 'reader_second_position', label: 'Second Position' }], +}; + +/** The option-B shape: `packages[]` carries everything, nothing is flattened. */ +const optionB = () => ({ + manifest: { id: 'com.example.readers.app', name: 'Readers App', version: '1.0.0', type: 'app' }, + packages: [{ manifest: corePackage }, { manifest: ordersPackage }], +}); + +describe('#15229 — `@objectstack/verify` reads its collections from `packages[]` too', () => { + it('deriveCrudCases derives a case per package-owned object', () => { + const cases = deriveCrudCases(optionB()); + expect(cases.map((c) => c.object).sort()).toEqual(['reader_account', 'reader_wh_order']); + }); + + it('the ADR-0015 write gate resolves the datasource from the OTHER package', () => { + // The object is in `orders`, the datasource that opens the write gate is in + // `core`. A reader that resolved `objects` but not `datasources` reports the + // app's write-opted-in external object as read-only and skips it — a + // verifier quietly proving less, which is the failure mode of this card. + const federated = deriveCrudCases(optionB()).find((c) => c.object === 'reader_wh_order'); + expect(federated?.blocked).toBeUndefined(); + }); + + it('declaredPositionNames covers every package, in package order', () => { + expect(declaredPositionNames(optionB())).toEqual(['reader_position', 'reader_second_position']); + }); + + it('rlsProbePermissionSet grants AND narrows every package-owned object', () => { + const set = rlsProbePermissionSet(optionB()) as unknown as { + objects: Record; + rowLevelSecurity: Array<{ object: string; operation: string }>; + }; + expect(Object.keys(set.objects).sort()).toEqual(['reader_account', 'reader_wh_order']); + // Both halves are load-bearing: the grants stop the OBJECT gate answering + // 403 first, the owner-scoped select is what puts the persona outside the + // record scope. A set with grants and no narrowing is not a probe. + expect(set.rowLevelSecurity.map((r) => r.object).sort()) + .toEqual(['reader_account', 'reader_wh_order']); + expect(new Set(set.rowLevelSecurity.map((r) => r.operation))).toEqual(new Set(['select'])); + }); + + describe('the flattened top level answers FIRST — `packages[]` supplies only what it lacks', () => { + it("today's additive artifact answers bit-identically, and does not merge the second copy", () => { + // The additive shape carries every definition TWICE. `packages[]` here + // deliberately carries an object the top level does NOT — if the reader + // merged instead of preferring, this would come back with three cases and + // every app on the additive artifact would be verified against a stack + // that is not the one it composed. + const additive = { + objects: [ + { name: 'reader_account', label: 'Account', fields: { name: { name: 'name', type: 'text' } } }, + { name: 'reader_order', label: 'Order', fields: { name: { name: 'name', type: 'text' } } }, + ], + packages: [{ manifest: corePackage }, { manifest: ordersPackage }], + }; + expect(deriveCrudCases(additive).map((c) => c.object)) + .toEqual(['reader_account', 'reader_order']); + }); + + it('a DECLARED-EMPTY collection stays empty (`objects: []` is truthy)', () => { + // Measured on the sibling card #15006: re-expressing one of these reads as + // "resolve, then take what came back" silently changes the answer for a + // stack that declares an empty collection. Falsy — absent or null — is the + // only thing that reaches `packages[]`. + const declaredEmpty = { objects: [], positions: [], packages: [{ manifest: corePackage }] }; + expect(deriveCrudCases(declaredEmpty)).toEqual([]); + expect(declaredPositionNames(declaredEmpty)).toEqual([]); + expect(Object.keys( + (rlsProbePermissionSet(declaredEmpty) as unknown as { objects: Record }).objects, + )).toEqual([]); + }); + + it('a single-package app with no `packages` key is unchanged', () => { + const flat = { objects: [{ name: 'reader_solo', fields: { name: { name: 'name', type: 'text' } } }] }; + expect(deriveCrudCases(flat).map((c) => c.object)).toEqual(['reader_solo']); + expect(declaredPositionNames({ positions: [{ name: 'solo_position' }] })) + .toEqual(['solo_position']); + expect(deriveCrudCases(undefined)).toEqual([]); + expect(declaredPositionNames(null)).toEqual([]); + }); + }); + + it('a malformed `packages` REFUSES with the ADR-0112 envelope, never as "no collections"', () => { + // `resolveArtifactPackageOrder` owns this verdict (`@objectstack/core`, + // ADR-0130 D4) — asserted here as the envelope (`code` + `status`) rather + // than as a bare throw, so a driver that throws a plain Error cannot pass. + let raised: (Error & { code?: string; status?: number }) | undefined; + try { + deriveCrudCases({ packages: [{ notAManifest: true }] }); + } catch (e) { + raised = e as Error & { code?: string; status?: number }; + } + expect(raised?.code).toBe('INVALID_ARTIFACT_PACKAGE_ENTRY'); + expect(raised?.status).toBe(422); + }); +}); diff --git a/packages/verify/src/artifact-collections.ts b/packages/verify/src/artifact-collections.ts new file mode 100644 index 0000000000..6525b9a8ba --- /dev/null +++ b/packages/verify/src/artifact-collections.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Where this package reads the app's declared collections from + * (ADR-0130 D4/D5 — option B; #15229, reader program 5/4 of the ruling on + * #14512). + * + * ## The loss this closes + * + * A multi-package artifact today serializes every definition TWICE: once + * flattened onto the top level, once inside `packages[i].manifest`. Option B + * removes the flattened copy. Every reader in this package took the flattened + * copy and nothing else, so under option B `deriveCrudCases` derived ZERO cases + * and `rlsProbePermissionSet` built an EMPTY probe permission set — and + * `os verify` printed `✓ verify passed` over an app it had asserted nothing + * about. That is the reason this package's card carries `priority:p2` while the + * rest of the reader program carries p3: every other reader loses a capability, + * this one loses the verification itself and reports success while doing it. + * + * ## The rule, and why it is in this order + * + * **The caller's original expression answers first; `packages[]` supplies only + * what the top level LACKS.** Not a re-expression of the same question: + * + * - `config.objects` is truthy for an EMPTY ARRAY, so re-expressing a read as + * "resolve everything, then take what came back" silently changes the + * answer for a stack that declares `objects: []` — measured on the sibling + * card #15006. A falsy top level (absent / null) is the only thing that + * reaches `packages[]` here, so a declared-empty collection stays empty. + * - Today's artifact is still additive, so the top level is present and the + * answer is bit-identical to the one before this change. That is what makes + * this card revertible on its own and safe to land BEFORE the emitter half + * (#14512) — it is a widening, never a switch. + * + * ## Why `resolveArtifactPackageOrder` and never `config.packages` + * + * The package order is `@objectstack/core`'s decision (ADR-0130 D4+D5, since + * #14643): dependency-topological, entry-gated, duplicate-refusing. Iterating + * `config.packages` here would be a SECOND traversal and therefore a second + * ordering — two answers to a question the artifact contract answers once. The + * one behavioural consequence worth stating: a malformed `packages` array now + * raises that function's ADR-0112 refusal (`code` + `status: 422`) instead of + * being read as "no collections", which is the loud-over-silent direction this + * whole card is about. + */ + +import { resolveArtifactPackageOrder } from '@objectstack/core'; + +/** + * The app's declared members of one collection — the flattened top level when + * it carries the key, otherwise every package's contribution in dependency + * order. + * + * @param config - The loaded app config (`os verify` gets it from `loadConfig`), + * or a compiled artifact. Nullish is tolerated exactly as the `?.` reads it + * replaced were. + * @param key - The collection key, e.g. `objects` / `datasources` / `positions`. + * @returns The top-level value UNTOUCHED when it is truthy — including a + * non-array one, so a malformed config still fails where it used to rather + * than being quietly repaired here — otherwise the concatenation of the + * package bodies' arrays. + * @throws The ADR-0112 refusal from `resolveArtifactPackageOrder` when the + * artifact carries a malformed `packages` array or a duplicate package id. + */ +export function declaredCollection(config: any, key: string): any[] { + const declared = config?.[key]; + if (declared) return declared as any[]; + + // `resolveArtifactPackageOrder` reads a bare config with no `packages` key as + // a single-package artifact and hands it straight back, so this branch on + // today's single-package apps re-reads the same absent key and answers `[]` — + // the same empty array the `?? []` it replaced produced. + const bodies = resolveArtifactPackageOrder(config) as Array> | undefined; + const merged: any[] = []; + for (const body of bodies ?? []) { + const items = body?.[key]; + if (Array.isArray(items)) merged.push(...items); + } + return merged; +} diff --git a/packages/verify/src/derive.ts b/packages/verify/src/derive.ts index c3e2b59b44..8c0602eab9 100644 --- a/packages/verify/src/derive.ts +++ b/packages/verify/src/derive.ts @@ -19,6 +19,8 @@ // is reported `blocked` with a precise reason — the gate stays honest. +import { declaredCollection } from './artifact-collections.js'; + const COMPUTED = new Set(['formula', 'summary', 'autonumber', 'rollup', 'vector']); const RELATIONAL = new Set(['lookup', 'master_detail', 'master-detail', 'masterdetail', 'tree']); const STRUCTURED = new Set(['composite', 'repeater', 'record', 'location', 'address']); @@ -173,11 +175,17 @@ interface Draft { * - a required non-relational field that can't be synthesized (unchanged from v0). */ export function deriveCrudCases(config: any): CrudCase[] { - const objects: any[] = config?.objects ?? []; + // ADR-0130 D4 (#15229): the flattened top level answers first and + // `packages[]` supplies only what it lacks, so a multi-package app under + // option B derives its cases instead of deriving NONE and passing. Both reads + // go through the same resolver — `objects` alone would leave the federated + // write gate below judging against an empty datasource map, which reports an + // app's write-opted-in external objects as read-only and silently skips them. + const objects: any[] = declaredCollection(config, 'objects'); const byName = new Map(); for (const o of objects) if (o?.name) byName.set(o.name, o); const dsByName = new Map(); - for (const ds of (config?.datasources ?? [])) if (ds?.name) dsByName.set(ds.name, ds); + for (const ds of declaredCollection(config, 'datasources')) if (ds?.name) dsByName.set(ds.name, ds); const drafts = new Map(); diff --git a/packages/verify/src/rls.ts b/packages/verify/src/rls.ts index 5e26da8fb0..f1073bb9d9 100644 --- a/packages/verify/src/rls.ts +++ b/packages/verify/src/rls.ts @@ -51,8 +51,9 @@ // against `positions: ['contributor']` rules. // // So a run now FANS OUT: one persona per position the app DECLARES -// (`declaredPositionNames`, read from `config.positions` — never a transcribed -// list, so a position added next month is covered without touching this file), +// (`declaredPositionNames`, read from the app's DECLARED positions — never a +// transcribed list, so a position added next month is covered without touching +// this file), // each holding that position and nothing else, i.e. exactly the capability the app // itself binds to it. The same invariant then runs unchanged for each. // @@ -84,6 +85,7 @@ import type { PermissionSet } from '@objectstack/spec/security'; import type { VerifyStack } from './harness.js'; import { deriveCrudCases, fillRelationalRefs } from './derive.js'; +import { declaredCollection } from './artifact-collections.js'; const PROBE_TYPES = new Set(['text', 'textarea', 'string']); const MUTATION = 'rls-mutated-by-B'; @@ -111,8 +113,9 @@ export function rlsPositionProbeEmail(position: string): string { } /** - * Machine names of the positions the app DECLARES (`config.positions`), in - * declaration order, deduplicated. + * Machine names of the positions the app DECLARES, in declaration order, + * deduplicated — read from the flattened `config.positions` when it carries + * them and from `packages[]` when it does not (ADR-0130 D4, #15229). * * ⛔ DERIVED, never transcribed. A hand-written roster is how a verifier quietly * stops covering the position someone adds next month — it keeps passing, over a @@ -131,7 +134,7 @@ export function declaredPositionNames(config: any): string[] { const anchors = AUDIENCE_ANCHOR_POSITIONS as readonly string[]; const seen = new Set(); const names: string[] = []; - for (const declared of (config?.positions ?? []) as any[]) { + for (const declared of declaredCollection(config, 'positions')) { const name = typeof declared === 'string' ? declared : declared?.name; if (typeof name !== 'string' || name.length === 0) continue; if (anchors.includes(name) || seen.has(name)) continue; @@ -343,7 +346,7 @@ function rowsOf(payload: any): any[] { export function rlsProbePermissionSet(config: any): PermissionSet { const objects: Record = {}; const rowLevelSecurity: Array> = []; - for (const o of (config?.objects ?? []) as any[]) { + for (const o of declaredCollection(config, 'objects')) { if (!o?.name) continue; objects[o.name] = { allowRead: true, allowEdit: true }; rowLevelSecurity.push({ From 4525eee11f04076a6e99b33ace37ee2235312e6f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 08:17:15 +0000 Subject: [PATCH 3/4] tooling(verify,cli): resolve the new cross-package imports to SOURCE (#15229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both halves are what `check:test-source-alias` and `check:type-source-resolution` prescribe for a dep a change newly reaches; neither registry is widened (both are shrink-only, and widening is never the fix): - `packages/verify/vitest.config.ts` gains its first `resolve.alias` — one ANCHORED entry for `@objectstack/core`, which `artifact-collections.ts` now imports and every test in the package reaches through `derive.ts` / `rls.ts`. Without it those tests resolve core through its `exports` map to `dist/`, i.e. they become verdicts about build state, and the dangerous half of that is a suite that passes GREEN over a stale artifact. - `packages/verify/tsconfig.json` gains the matching `paths` rule, and the `rootDir` widening that pulling core's source into the program requires (`packages/core/tsconfig.json`'s own precedent; emit is unaffected, this package builds with tsup). - `packages/cli/tsconfig.test.json` gains a FOURTH bare-name rule beside the three #15004 added, for the `@objectstack/verify` the probe rows now import. Bare names, no stars: `@objectstack/core` publishes a `./logger` subpath that keeps resolving through its own `exports` map, and a starred rule would swallow it and resolve it THROUGH a file (ENOTDIR). Measured after: `pnpm check:test-source-alias` OK (72 packages scanned), `pnpm check:type-source-resolution` OK (121 programs / 78 packages), `pnpm --filter @objectstack/verify typecheck` and `pnpm --filter @objectstack/cli typecheck` both clean, and the two suites the alias re-resolves stay green: verify 58/58, the option-B pin 6/6. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --- packages/cli/tsconfig.test.json | 14 ++++++++------ packages/verify/tsconfig.json | 26 ++++++++++++++++++++++++-- packages/verify/vitest.config.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/packages/cli/tsconfig.test.json b/packages/cli/tsconfig.test.json index e87ce6c578..736b30bb30 100644 --- a/packages/cli/tsconfig.test.json +++ b/packages/cli/tsconfig.test.json @@ -126,9 +126,10 @@ "moduleResolution": "bundler", "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["node"], - // [#15004] THREE bare-name rules, no star, for the three workspace deps the - // option-B acceptance pin reaches (`test/option-b-reader-acceptance.pin.test.ts` - // and its two fixtures). Without them tsc resolves those specifiers through + // [#15004, #15229] FOUR bare-name rules, no star, for the four workspace + // deps the option-B acceptance pin reaches + // (`test/option-b-reader-acceptance.pin.test.ts` and its two fixtures; + // `@objectstack/verify` joined them with card 5/4's probe rows). Without them tsc resolves those specifiers through // each package's `exports` map to `dist/index.d.ts` — A BUILD ARTIFACT — so // this suite's type verdict about the readers the reader program is about to // CHANGE would be a verdict about the last `pnpm build` instead, which is @@ -142,8 +143,8 @@ // the specifier EXACTLY, which is load-bearing here rather than incidental: // `@objectstack/objectql` publishes a second subpath (`./core`), and that // specifier deliberately keeps resolving through the package's own - // `exports` map, untouched by this table. `plugin-security` and `runtime` - // each publish only `"."`. And a target matching nothing on disk is worse + // `exports` map, untouched by this table. `plugin-security`, `runtime` and + // `verify` each publish only `"."`. And a target matching nothing on disk is worse // than absent, because tsc then falls back to node resolution — i.e. to // `dist` — silently. // @@ -152,7 +153,8 @@ "paths": { "@objectstack/objectql": ["../objectql/src/index.ts"], "@objectstack/plugin-security": ["../plugins/plugin-security/src/index.ts"], - "@objectstack/runtime": ["../runtime/src/index.ts"] + "@objectstack/runtime": ["../runtime/src/index.ts"], + "@objectstack/verify": ["../verify/src/index.ts"] } }, "include": ["test/**/*", "vitest.config.ts", "vitest-tiers.ts", "vitest-tiers.fixtures.ts"], diff --git a/packages/verify/tsconfig.json b/packages/verify/tsconfig.json index 1294309a21..9d84f3674f 100644 --- a/packages/verify/tsconfig.json +++ b/packages/verify/tsconfig.json @@ -2,8 +2,30 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src", - "types": ["node"] + // [#15229] Widened from `./src` as a CONSEQUENCE of the `paths` rule below, + // exactly as `packages/core/tsconfig.json` documents: redirecting + // `@objectstack/core` to its source puts `packages/core/src/**` into this + // program, and `rootDir` is enforced over every program file even under + // `--noEmit`. `..` (= `packages/`) is the directory that contains every + // file in the program. Emit is unaffected: this package builds with tsup. + "rootDir": "..", + "types": ["node"], + // [#15229] `@objectstack/core` is imported as a VALUE by + // `src/artifact-collections.ts` (`resolveArtifactPackageOrder`, the + // ADR-0130 D4+D5 package ordering every reader in this package now resolves + // its collections through). Without this rule its types resolve through the + // workspace link to `dist/*.d.ts` — a build artifact — so this package's + // type verdict would be a function of build state rather than of the source + // in the checkout, which `check:type-source-resolution` refuses (its + // registry is shrink-only; widening it is not the fix). + // + // Bare name, no star: core publishes a second subpath (`./logger`) that + // deliberately keeps resolving through its own `exports` map, and a star + // would swallow it and resolve it THROUGH a file + // (`…/core/src/index.ts/logger`, ENOTDIR). Nothing here imports it. + "paths": { + "@objectstack/core": ["../core/src/index.ts"] + } }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] diff --git a/packages/verify/vitest.config.ts b/packages/verify/vitest.config.ts index 2c755392b2..4efc91612b 100644 --- a/packages/verify/vitest.config.ts +++ b/packages/verify/vitest.config.ts @@ -29,9 +29,36 @@ // runner would make every log reading in tests a reading of something other // than production. The request lives HERE, in the harness, where the test // author can see it. +// ── Why there is now a `resolve.alias` (#15229) ──────────────────────────── +// +// `artifact-collections.ts` reads the app's collections through +// `resolveArtifactPackageOrder` — `@objectstack/core`'s ADR-0130 D4+D5 package +// ordering — and `derive.ts` / `rls.ts` reach it from every test in this +// package. Without an anchored alias that specifier resolves through core's +// `exports` to its **dist**, which makes each of those tests a verdict about +// build state rather than about the source in this checkout, and the dangerous +// half of that is not a loud error but a suite that passes GREEN over a stale +// artifact with nothing in the output saying so. +// `scripts/check-test-source-alias.mjs` names it; that script's registry is +// SHRINK-ONLY, so widening `KNOWN_UNALIASED_TEST_IMPORTS['@objectstack/verify']` +// was never the fix. ANCHORED (`/^…$/`, array form) so the entry cannot swallow +// core's published subpaths and resolve `@objectstack/core/logger` to +// `…/core/src/index.ts/logger` — the ENOTDIR shape that gate's rule 5 exists +// for. The 14 other entries in this package's ledger are real and untouched: +// this adds nothing to it and removes nothing from it. +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { defineConfig } from 'vitest/config'; +const HERE = path.dirname(fileURLToPath(import.meta.url)); + export default defineConfig({ + resolve: { + alias: [ + { find: /^@objectstack\/core$/, replacement: path.resolve(HERE, '../core/src/index.ts') }, + ], + }, test: { // A late console.* must not redden a green suite (#10374): vitest's worker // forwards console output over RPC and discards the promise, and a write From 5c9b4e872d8951d5449b76c4ec20176096e97083 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 13:53:43 +0000 Subject: [PATCH 4/4] test(cli): raise the option-B probe anti-vacuity floor to the measured 35 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge with main brings #15261's re-anchored floor (a real bound now that `OPTION_B_LOSSES` is empty) together with this card's four `@objectstack/verify` rows. Merged, the probe measures 35 rows, so the floor moves 30 -> 35. 35 is measured, not remembered: with the assertion temporarily written `toBe(-1)` the run reports `expected 35 to be -1`; a floor of 36 goes RED on the same fixture. The raise also closes an off-by-one it inherited — 30 stood against a probe that measured 31. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --- .../test/option-b-reader-acceptance.pin.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/cli/test/option-b-reader-acceptance.pin.test.ts b/packages/cli/test/option-b-reader-acceptance.pin.test.ts index 649b98e12b..162f6308ba 100644 --- a/packages/cli/test/option-b-reader-acceptance.pin.test.ts +++ b/packages/cli/test/option-b-reader-acceptance.pin.test.ts @@ -265,23 +265,30 @@ describe('#15004 — option-B acceptance pin: every subsystem must see its colle // comment, and the fourth direction this file's header claims ("the probe // itself quietly measuring less ⇒ RED") had silently stopped existing. // - // 30 is MEASURED, not remembered: with this line temporarily written + // 35 is MEASURED, not remembered: with this line temporarily written // `expect(additive.rows.length).toBe(-1)`, the run reports - // `expected 30 to be -1`. Verified live at the boundary in the same - // session — a floor of 31 goes RED on the same fixture, so the assertion + // `expected 35 to be -1`. Verified live at the boundary in the same + // session — a floor of 36 goes RED on the same fixture, so the assertion // is not satisfied by construction. // + // ⚠️ RAISED by #15229, which added four `@objectstack/verify` rows, and the + // raise repaired an off-by-one while it was here: the floor read 30 against + // a probe that measured 31, so one row could stop being measured with + // nothing going red. It is now the measured count EXACTLY — no slack — and + // that is what makes the next card's raise a step it cannot skip without + // this line failing. + // // `>=` rather than `toBe` on purpose, and it is the same shrink-only // direction the ledger uses: a row ADDED to the probe is welcome and stays // green, a row that stops being measured is red. Raise the floor when the // probe grows; ⛔ never lower it to make a red run green. expect( additive.rows.length, - `The probe measured ${additive.rows.length} rows, fewer than the 30 it measured when ` + + `The probe measured ${additive.rows.length} rows, fewer than the 35 it measured when ` + `this floor was set. A row that stops being measured stops being able to fail, which ` + `is the one direction this pin cannot detect anywhere else — fix the probe rather ` + `than the floor.`, - ).toBeGreaterThanOrEqual(30); + ).toBeGreaterThanOrEqual(35); }); // ── The pin ──────────────────────────────────────────────────────────────