Skip to content

Commit 8583bfb

Browse files
committed
test(cli): ledger the four @objectstack/verify option-B losses (#15229)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
1 parent 1c6f7b4 commit 8583bfb

3 files changed

Lines changed: 125 additions & 2 deletions

File tree

packages/cli/test/fixtures/option-b-collection-zoo.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,28 @@ export const ORDERS_PACKAGE_ID = 'com.example.probe.orders';
7979
export const PROBE_DATASOURCE = 'probe_primary';
8080
/** Relative to the project root — `resolve-project-database` anchors it there. */
8181
export const PROBE_DATASOURCE_FILE = '.objectstack/data/probe-primary.db';
82+
/**
83+
* A FEDERATED datasource (ADR-0015), write gate OPEN.
84+
*
85+
* It is here for one reader: `deriveCrudCases` builds a datasource-by-name map
86+
* and consults it for exactly ONE decision — whether a federated object's probe
87+
* insert is allowed through the double write gate — so the only way to watch
88+
* that map is to carry an external object whose gate depends on it. Declared in
89+
* the App package while the object it gates lives in the module package, so the
90+
* row watching it measures a CROSS-PACKAGE resolution and not a within-body
91+
* read.
92+
*
93+
* ⚠️ The lean probe kernel builds no live driver for it, so every boot in this
94+
* fixture's probe logs one `federated (external) object(s) are NOT bound to
95+
* their remote table` ERROR. That is the truthful verdict for a federated
96+
* object with no remote behind it; it touches no row here (nothing in this
97+
* probe reads the object's data) and is expected output, not a fixture defect.
98+
*/
99+
export const PROBE_FEDERATED_DATASOURCE = 'probe_federated';
100+
/** Relative to the project root, same anchoring as the primary. */
101+
export const PROBE_FEDERATED_FILE = '.objectstack/data/probe-federated.db';
102+
/** The write-opted-in federated object the datasource gate above admits. */
103+
export const PROBE_FEDERATED_OBJECT = 'probe_federated_order';
82104
/** The `isDefault` permission set `appSecurityPluginOptions` must resolve. */
83105
export const PROBE_DEFAULT_PERMISSION_SET = 'probe_default_profile';
84106
export const PROBE_POSITION = 'probe_position';
@@ -137,6 +159,19 @@ const coreStack = (): ObjectStackDefinition =>
137159
config: { filename: PROBE_DATASOURCE_FILE },
138160
active: true,
139161
},
162+
// The federated half — see `PROBE_FEDERATED_DATASOURCE`. `datasourceMapping`
163+
// does NOT route to it (the project default stays `probe_primary`), so the
164+
// only thing that reaches it is the object in the OTHER package binding to
165+
// it by name.
166+
{
167+
name: PROBE_FEDERATED_DATASOURCE,
168+
label: 'Probe Federated',
169+
driver: 'sqlite',
170+
config: { filename: PROBE_FEDERATED_FILE },
171+
schemaMode: 'external',
172+
external: { allowWrites: true },
173+
active: true,
174+
},
140175
],
141176
datasourceMapping: [
142177
{ datasource: PROBE_DATASOURCE, default: true },
@@ -199,6 +234,22 @@ const ordersStack = (): ObjectStackDefinition =>
199234
name: { name: 'name', type: 'text', label: 'Number', required: true },
200235
},
201236
},
237+
// The FEDERATED object, bound to the datasource the OTHER package
238+
// declares and write-opted-in on its own half of ADR-0015's double gate.
239+
// A reader that resolves objects but not datasources still gets this one
240+
// wrong — it reports the object `blocked` as read-only — which is what
241+
// makes the two collections separately observable through one function.
242+
{
243+
name: PROBE_FEDERATED_OBJECT,
244+
label: 'Probe Federated Order',
245+
pluralLabel: 'Probe Federated Orders',
246+
sharingModel: 'private',
247+
datasource: PROBE_FEDERATED_DATASOURCE,
248+
external: { remoteName: 'remote_orders', writable: true },
249+
fields: {
250+
name: { name: 'name', type: 'text', label: 'Number', required: true },
251+
},
252+
},
202253
],
203254
actions: [
204255
{

packages/cli/test/fixtures/option-b-reader-probe.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ import {
6262
resolveStandaloneDatabase,
6363
} from '@objectstack/runtime';
6464
import { appSecurityPluginOptions } from '@objectstack/plugin-security';
65+
import {
66+
declaredPositionNames,
67+
deriveCrudCases,
68+
rlsProbePermissionSet,
69+
} from '@objectstack/verify';
6570
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
6671

6772
// 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';
7277

7378
import {
7479
PROBE_DEFAULT_PERMISSION_SET,
80+
PROBE_FEDERATED_OBJECT,
7581
PROBE_FUNCTION,
7682
PROBE_FUNCTION_EFFECT,
7783
} from './option-b-collection-zoo.js';
@@ -369,6 +375,59 @@ export async function measureShape(project: unknown, projectRoot: string): Promi
369375
Object.keys(collectBundleFunctionEntries(project as never)).length,
370376
));
371377

378+
// ── B2 · `os verify`'s readers (#15229) ──────────────────────────────────
379+
//
380+
// `os verify` has exactly ONE door — `loadConfig` (`verify.ts:92`) — and it
381+
// never opens a compiled artifact, so these rows are B2 and there is
382+
// deliberately no B1 twin. A B1 row here would measure a path no command
383+
// drives, which reads as coverage this probe does not have.
384+
//
385+
// Every row calls a reader `@objectstack/verify` SHIPS, per this file's rule.
386+
// What makes these the most expensive rows in the table is what the readers
387+
// are FOR: `os verify` derives its whole proof set from the app's metadata,
388+
// so a collection it cannot see is not a missing feature — it is a run that
389+
// asserts nothing and still prints `✓ verify passed`.
390+
391+
const crudCases = deriveCrudCases(project) as Array<{ object: string; blocked?: string }>;
392+
rows.push(countRow(
393+
'B2 · verify deriveCrudCases (CRUD round-trip case derivation) · objects',
394+
crudCases.length,
395+
));
396+
397+
// The datasource-by-name map, watched through the ONE decision it makes: the
398+
// ADR-0015 double write gate. A reader that resolved `objects` but not
399+
// `datasources` still fails this row — the case comes back `blocked` as
400+
// "external read-only", which is a verifier silently skipping an object the
401+
// app explicitly opted into writes for.
402+
const federated = crudCases.find((c) => c.object === PROBE_FEDERATED_OBJECT);
403+
rows.push(row(
404+
'B2 · verify deriveCrudCases federated write gate (ADR-0015 double opt-in) · datasources',
405+
federated ? (federated.blocked ?? 'derived — probe insert allowed') : 'no case derived at all',
406+
!federated || Boolean(federated.blocked),
407+
));
408+
409+
rows.push(countRow(
410+
'B2 · verify declaredPositionNames (one RLS persona per declared position) · positions',
411+
declaredPositionNames(project).length,
412+
));
413+
414+
// The probe permission set is what makes an RLS run a PROBE at all: the
415+
// object grants are what stop `checkObjectPermission` answering 403 before
416+
// record scope is consulted, and the owner-scoped SELECT narrowing is what
417+
// puts the persona outside the record scope. Empty on either half and every
418+
// verdict the run prints is about neither.
419+
const probeSet = rlsProbePermissionSet(project) as unknown as {
420+
objects?: Record<string, unknown>;
421+
rowLevelSecurity?: unknown[];
422+
};
423+
const grants = Object.keys(probeSet?.objects ?? {}).length;
424+
const narrowings = (probeSet?.rowLevelSecurity ?? []).length;
425+
rows.push(row(
426+
'B2 · verify rlsProbePermissionSet (RLS probe grants + owner narrowing) · objects',
427+
`${grants} granted object(s), ${narrowings} owner-scope rule(s)`,
428+
grants === 0 || narrowings === 0,
429+
));
430+
372431
// ── The booted AppPlugin, on BOTH entry paths ────────────────────────────
373432

374433
const bootedFromArtifact = await bootAndRecord(bundle);

packages/cli/test/option-b-reader-acceptance.pin.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ const OPTION_B_LOSSES: readonly string[] = [
147147
'B2 · runtime collectBundleActions over the from-source config · actions + objects[].actions',
148148
'B2 · runtime collectBundleFunctionEntries over the from-source config · functions',
149149
'B2 · runtime collectBundleHooks over the from-source config · hooks',
150+
'B2 · verify declaredPositionNames (one RLS persona per declared position) · positions',
151+
'B2 · verify deriveCrudCases (CRUD round-trip case derivation) · objects',
152+
'B2 · verify deriveCrudCases federated write gate (ADR-0015 double opt-in) · datasources',
153+
'B2 · verify rlsProbePermissionSet (RLS probe grants + owner narrowing) · objects',
150154
'B5 · resolve-project-database readConfigDeclaredDefault (project database tier) · datasourceMapping + datasources',
151155
];
152156

@@ -204,10 +208,19 @@ describe('#15004 — option-B acceptance pin: every subsystem must see its colle
204208
// control for every LOST row below: it proves the option-B fixture really
205209
// carries every definition under `packages[]`, so a zero is a reader losing
206210
// a collection and never a fixture that shipped an empty package.
207-
expect(additive.registryObjectsFromArtifact).toEqual(['probe_account', 'probe_order']);
211+
// `probe_federated_order` is the ADR-0015 federated object #15229 added to
212+
// the zoo: `deriveCrudCases`'s datasource-by-name map decides exactly one
213+
// thing — whether that object's probe insert clears the double write gate —
214+
// so watching the map required carrying one. It registers like any other
215+
// object, which is why it is in this control's list.
216+
expect(additive.registryObjectsFromArtifact).toEqual(
217+
['probe_account', 'probe_federated_order', 'probe_order'],
218+
);
208219
expect(optionB.registryObjectsFromArtifact).toEqual(additive.registryObjectsFromArtifact);
209220
expect(optionB.registryObjectsFromSource).toEqual(additive.registryObjectsFromSource);
210-
expect(optionB.registryObjectsFromSource).toEqual(['probe_account', 'probe_order']);
221+
expect(optionB.registryObjectsFromSource).toEqual(
222+
['probe_account', 'probe_federated_order', 'probe_order'],
223+
);
211224
});
212225

213226
// ── The baseline: green today, and it must stay green ────────────────────

0 commit comments

Comments
 (0)