Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/verify-reads-package-owned-collections.md
Original file line number Diff line number Diff line change
@@ -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".
51 changes: 51 additions & 0 deletions packages/cli/test/fixtures/option-b-collection-zoo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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: [
{
Expand Down
59 changes: 59 additions & 0 deletions packages/cli/test/fixtures/option-b-reader-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ import {
} from '@objectstack/runtime';
import { appSecurityPluginOptions } from '@objectstack/plugin-security';
import { devI18nPluginOptions } from '@objectstack/plugin-dev';
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,
Expand All @@ -88,6 +93,7 @@ import {
import {
PACKAGE_OWNED_COLLECTION_KEYS,
PROBE_DEFAULT_PERMISSION_SET,
PROBE_FEDERATED_OBJECT,
PROBE_FUNCTION,
PROBE_FUNCTION_EFFECT,
} from './option-b-collection-zoo.js';
Expand Down Expand Up @@ -476,6 +482,59 @@ export async function measureShape(project: unknown, projectRoot: string): Promi
unionItems,
));

// ── 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<string, unknown>;
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);
Expand Down
38 changes: 31 additions & 7 deletions packages/cli/test/option-b-reader-acceptance.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,14 @@ import { measureShape, type ProbeRow, type ShapeMeasurement } from './fixtures/o
* measures must now be `present` in BOTH shapes, so a reader that regresses —
* or a new reader that arrives unresolved — is red on arrival with nothing left
* to absorb it. Adding a line is never how that red is fixed.
*
* `@objectstack/verify`'s four rows (#15229) are absent from this list for
* exactly that reason, and the reason is worth stating rather than inferring:
* the by-shape sweep (#15210) found those sites, not this pin, so that card
* LEDGERED them red first and then DELETED the entries again inside one PR, by
* fixing the readers rather than the ledger. Both halves are in its history;
* the probe still measures all four, which is what keeps a regression in them
* red.
*/
const OPTION_B_LOSSES: readonly string[] = [];

Expand Down Expand Up @@ -222,10 +230,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 ────────────────────
Expand All @@ -248,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 ──────────────────────────────────────────────────────────────
Expand Down
14 changes: 8 additions & 6 deletions packages/cli/tsconfig.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
//
Expand All @@ -159,7 +160,8 @@
// package publishes only `"."`.
"@objectstack/plugin-dev": ["../plugins/plugin-dev/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"],
Expand Down
Loading
Loading