Skip to content

Commit 96918d6

Browse files
committed
test(cli): pin the walled binding remedy — undeclared, declared-editable, sealed, conflicting, single control — and the served boot's sealed read
The sealed branch names the package that ships the definition when its provenance names one. The served-catalog fixture now reads the metadata door's lock verdict as the command does, and declares two positions in the artifact: one whose declaration names the bound set (clears the gate) and one that lacks it (sealed, fact and no act). Changeset: @objectstack/cli patch, FROM -> TO per remedy sentence. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude <noreply@anthropic.com>
1 parent cb9252d commit 96918d6

4 files changed

Lines changed: 239 additions & 23 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): under a wall, `os migrate organization-ownership --apply` names the act that clears a position binding the rewrite gate refuses, instead of pointing at a step that reports done (#23542)
6+
7+
Clause-②: no
8+
9+
**What changed.** Under the `group` or `isolated` posture, no ceremony step carries `sys_position_permission_set` rows into a position definition. `authored-conversion` reports and finishes before it reads a binding (ADR-0131 D3). The rewrite gate still refused those rows, rightly, but its remedy sent the operator to `authored-conversion`, which had already reported `done` with no blocker. The gate still refuses, and nothing is deleted or guessed. Only the remedy changes, and it now depends on where the position's definition lives.
10+
11+
The blocker's first sentence is unchanged: "Binding rows give position NAME sets its catalog definition does not name (SETS); deleting them would lose a grant." The sentence after it changes, under a wall only:
12+
13+
- **A position no catalog declares.**
14+
- FROM: "The authored-conversion step carries them; resolve its blocker, then re-run."
15+
- TO: "Under the POSTURE posture no ceremony step carries binding rows (ADR-0131 D3). Declare the position NAME in the environment catalog with permissionSets naming SETS, through one door only: the deployment's code (compiled into the artifact this ceremony boots), or the metadata door as the platform administrator (PUT /api/v1/meta/position/NAME). A cold boot refuses a position name a package declares and the environment catalog also holds. Then re-run."
16+
- **A declared position the metadata door reports editable, whose definition lacks some of the bound sets.**
17+
- FROM: the same pointer.
18+
- TO: "Under the POSTURE posture no ceremony step carries binding rows (ADR-0131 D3). Add MISSING to the permissionSets of the position NAME through the metadata door as the platform administrator (PUT /api/v1/meta/position/NAME), then re-run."
19+
- **A declared position the metadata door reports not editable.** A definition a package ships is sealed (ADR-0131 D6), so no door on the database adds the sets. The text states the fact and names no act.
20+
- FROM: the same pointer.
21+
- TO, when the definition's provenance names the package: "Under the POSTURE posture no ceremony step carries binding rows (ADR-0131 D3). Package PACKAGE ships the definition of NAME, which is sealed (ADR-0131 D6: the metadata door reports it not editable, lock LOCK), so no door on this database adds those sets."
22+
- TO, otherwise: "Under the POSTURE posture no ceremony step carries binding rows (ADR-0131 D3). The metadata door reports the definition of NAME not editable (lock LOCK), so no door on this database adds those sets."
23+
- When the door's verdict cannot be read, the text says so and names no act.
24+
25+
Whether a definition is editable is the metadata door's own answer, the `editable` flag of its item read, the same verdict the ceremony already asks before its one in-place write.
26+
27+
**Unchanged.**
28+
29+
- Under `single`, the pointer at `authored-conversion` stays, because that step carries the bindings there.
30+
- A built-in position that does not resolve because the boot did not compose the security plugin keeps that composition's remedy, under every posture.
31+
- The `conflicting` blocker, for a name that organizations bind differently, keeps its text. It states the fact and names no act, because no door makes the rows agree.
32+
- `authored-conversion`'s walled note is unchanged. The rewrite gate's blocker is where the operator reads about binding rows.

‎packages/cli/src/utils/organization-ownership-apply.served-catalog.integration.test.ts‎

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,28 @@
1313
* The catalog: the apply command's boot (`bootSchemaStack` with its four
1414
* composition flags) over a compiled artifact, with an auth secret set so the
1515
* security plugin is composed, read through core's security catalog reader as
16-
* the command reads it. The database: the 17.x catalog rows every environment
17-
* database holds — the seeded `everyone` position, the seeded `member_default`
18-
* set and the seeded binding between them — plus the control: a seeded binding
19-
* that names a position no catalog declares.
16+
* the command reads it. The artifact declares two positions in code:
17+
* `zz_in_code` naming `member_default`, and `zz_shipped` naming no set. The
18+
* database: the 17.x catalog rows every environment database holds — the
19+
* seeded `everyone` position, the seeded `member_default` set and the seeded
20+
* binding between them — plus the control: a seeded binding that names a
21+
* position no catalog declares.
2022
*
2123
* ## What each case measures
2224
*
2325
* The catalog holds the six built-ins and `everyone`'s baseline. Under
2426
* `single`, `authored-conversion` blocks on the control and on nothing else;
2527
* under a wall it reports, and the rewrite gate blocks on the control and on
26-
* nothing else. With the declarations made from `start()` and `kernel:ready`,
27-
* which this boot never runs, both block on `everyone` as well.
28+
* nothing else, naming the act the wall allows: declare the position with its
29+
* sets, through the deployment's code or the metadata door. With the
30+
* declarations made from `start()` and `kernel:ready`, which this boot never
31+
* runs, both block on `everyone` as well.
32+
*
33+
* [#23542] Under a wall, the metadata door's own lock verdict — read as the
34+
* command reads it — tells the gate which definitions no door edits: a binding
35+
* the artifact's declaration already names clears the gate (the code door), and
36+
* one that adds a set to `zz_shipped`, a definition a package ships, gets the
37+
* fact and no act (ADR-0131 D6).
2838
*/
2939

3040
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
@@ -52,6 +62,7 @@ import '@objectstack/platform-objects/plugin';
5262
import '@objectstack/plugin-security';
5363

5464
const CASE_TIMEOUT_MS = 180_000;
65+
const PACKAGE_ID = 'com.example.ownership-catalog';
5566
const ENV_KEYS = ['OS_ARTIFACT_PATH', 'OS_DATABASE_URL', 'DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'NODE_ENV',
5667
'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ENVIRONMENT_ID', 'OS_MULTI_ORG_ENABLED'] as const;
5768
const savedEnv: Record<string, string | undefined> = {};
@@ -70,7 +81,11 @@ beforeAll(async () => {
7081
process.env.OS_HOME = join(dir, 'home');
7182
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');
7283
writeFileSync(process.env.OS_ARTIFACT_PATH, JSON.stringify({
73-
manifest: { id: 'com.example.ownership-catalog', name: 'ownership-catalog', version: '1.0.0', type: 'app' },
84+
manifest: { id: PACKAGE_ID, name: 'ownership-catalog', version: '1.0.0', type: 'app' },
85+
positions: [
86+
{ name: 'zz_in_code', label: 'In code', permissionSets: ['member_default'] },
87+
{ name: 'zz_shipped', label: 'Shipped' },
88+
],
7489
}));
7590
// The apply command's boot, flag for flag.
7691
stack = await bootSchemaStack({
@@ -134,7 +149,16 @@ async function ceremony(posture: string, db: Database.Database): Promise<Organiz
134149
securityPlugin: stack.composition.securityPlugin,
135150
// No view is served here: the form-withdrawal carry has nothing to judge.
136151
views: { list: async () => [] },
137-
lock: { verdict: async () => ({ editable: true }) },
152+
// The save door's own verdict, as the command reads it: the item read's `editable`.
153+
lock: {
154+
verdict: async (type, name, packageId) => {
155+
const protocol = (stack.kernel as { getService(name: string): unknown }).getService('protocol') as {
156+
getMetaItem(request: { type: string; name: string; packageId?: string }): Promise<{ editable?: unknown; lock?: unknown } | null>;
157+
};
158+
const read = await protocol.getMetaItem({ type, name, ...(packageId ? { packageId } : {}) });
159+
return { editable: read?.editable !== false, ...(typeof read?.lock === 'string' ? { lock: read.lock } : {}) };
160+
},
161+
},
138162
door: { save: async (type, name) => { throw new Error(`the door was asked to save ${type} ${name}`); } },
139163
templates: { find: async () => undefined },
140164
columnDrops: { drop: async (columns) => columns.map((c) => ({ table: c.table, column: c.column, outcome: 'not-proposed' as const })) },
@@ -178,13 +202,37 @@ describe('the ownership ceremony resolves bindings against what a started kernel
178202
expect(rewrite).toMatchObject({ status: 'done', summary: { grants: 2, verified: 2 } });
179203
}, CASE_TIMEOUT_MS);
180204

181-
it('walled: the rewrite gate blocks on the undeclared position, and not on everyone', async () => {
205+
it('walled: the rewrite gate blocks on the undeclared position, and not on everyone, naming the act the wall allows', async () => {
182206
const report = await ceremony('isolated', v17Database('walled'));
183207
expect(step(report, 'authored-conversion').status).toBe('done');
184208
const rewrite = step(report, 'rewrite');
185209
expect(rewrite.status).toBe('blocked');
186210
expect(rewrite.blockers).toHaveLength(1);
187211
expect(rewrite.blockers[0]).toContain('position zz_undeclared');
188212
expect(rewrite.blockers.join('\n')).not.toContain('everyone');
213+
// Not the step that reported done: the declaration, through either of its two doors.
214+
expect(rewrite.blockers[0]).not.toMatch(/resolve its blocker/);
215+
expect(rewrite.blockers[0]).toContain('deployment\'s code');
216+
expect(rewrite.blockers[0]).toContain('PUT /api/v1/meta/position/zz_undeclared');
217+
}, CASE_TIMEOUT_MS);
218+
219+
it('walled: a binding the code declaration names clears the gate; one a sealed package definition lacks gets the fact, and no act', async () => {
220+
const db = v17Database('walled-declared');
221+
db.exec(`
222+
INSERT INTO sys_position VALUES
223+
('pos_in_code', NULL, 'zz_in_code', 'In code', 'platform', 0, 1),
224+
('pos_shipped', NULL, 'zz_shipped', 'Shipped', 'platform', 0, 1);
225+
INSERT INTO sys_position_permission_set VALUES
226+
('bind_in_code', NULL, 'pos_in_code', 'ps_member_default'),
227+
('bind_shipped', NULL, 'pos_shipped', 'ps_member_default');
228+
`);
229+
const rewrite = step(await ceremony('isolated', db), 'rewrite');
230+
expect(rewrite.status).toBe('blocked');
231+
expect(rewrite.blockers.join('\n')).not.toContain('zz_in_code');
232+
const sealed = rewrite.blockers.filter((b) => b.includes('position zz_shipped '));
233+
expect(sealed).toHaveLength(1);
234+
expect(sealed[0]).toContain(PACKAGE_ID);
235+
expect(sealed[0]).toContain('ADR-0131 D6');
236+
expect(sealed[0]).not.toMatch(/\b(Declare|Add|remove|resolve)\b|PUT |re-run/);
189237
}, CASE_TIMEOUT_MS);
190238
});

‎packages/cli/src/utils/organization-ownership-apply.test.ts‎

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2099,7 +2099,7 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de
20992099
expect(deletes()).toEqual([]);
21002100
});
21012101

2102-
it('under a wall, the rewrite gate: the everyone binding gets the composition; an undeclared position keeps its remedy', async () => {
2102+
it('under a wall, the rewrite gate: the everyone binding gets the composition; an undeclared position gets the walled remedy', async () => {
21032103
db.exec(v17Bindings);
21042104
const report = await apply(catalog(), ledger(), { posture: 'isolated', securityPlugin: NO_SECRET });
21052105

@@ -2108,7 +2108,8 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de
21082108
expect(rewrite.status).toBe('blocked');
21092109
const undeclared = blockerOf(rewrite, 'position zz_undeclared ');
21102110
expect(undeclared).not.toContain(COMPOSITION);
2111-
expect(undeclared).toMatch(/resolve its blocker/);
2111+
expect(undeclared).toContain('PUT /api/v1/meta/position/zz_undeclared');
2112+
expect(undeclared).not.toMatch(/resolve its blocker/);
21122113
const everyone = blockerOf(rewrite, 'position everyone ');
21132114
expect(everyone).toContain(COMPOSITION);
21142115
expect(everyone).not.toMatch(/\bremove\b/);
@@ -2135,7 +2136,138 @@ describe('ADR-0131 D10 --apply — a name only the uncomposed security plugin de
21352136
`);
21362137
const rewrite = step(await apply(catalog(), ledger(), { posture: 'isolated', securityPlugin }), 'rewrite');
21372138
expect(blockerOf(rewrite, 'the permission set admin_full_access,')).toMatch(/or remove the grants/);
2138-
expect(blockerOf(rewrite, 'position everyone ')).toMatch(/resolve its blocker/);
2139+
expect(blockerOf(rewrite, 'position everyone ')).toContain('PUT /api/v1/meta/position/everyone');
21392140
expect(rewrite.blockers.join('\n')).not.toContain(COMPOSITION);
21402141
});
21412142
});
2143+
2144+
describe('ADR-0131 D10 --apply — under a wall, the rewrite gate names the act the wall allows for a converted binding', () => {
2145+
type Step = OrganizationOwnershipApplyReport['steps'][number];
2146+
const step = (report: OrganizationOwnershipApplyReport, name: Step['step']): Step => report.steps.find((s) => s.step === name)!;
2147+
/** The one blocker of `s` that names `subject`. */
2148+
const blockerOf = (s: Step, subject: string): string => {
2149+
const found = s.blockers.filter((b) => b.includes(subject));
2150+
expect(found, subject).toHaveLength(1);
2151+
return found[0]!;
2152+
};
2153+
/** The remedy half of a binding blocker: what follows the fact. */
2154+
const remedyOf = (blocker: string): string => blocker.slice(blocker.indexOf('deleting them would lose a grant.'));
2155+
/** Every DELETE the run issued, beside the settings rung that moves before the rewrite. */
2156+
const deletes = (): string[] => writes.filter((w) => /^DELETE/.test(w) && !/^DELETE FROM "sys_setting"/.test(w));
2157+
/** An act, a door or a re-run: what a blocker with no sanctioned act never names. */
2158+
const ACT = /\b(Declare|Add|remove|resolve)\b|PUT |re-run/;
2159+
const POINTER = 'The authored-conversion step carries them; resolve its blocker, then re-run.';
2160+
/** The metadata door's lock verdict, answering `answer` and recording what it was asked. */
2161+
const lockAnswering = (answer: Awaited<ReturnType<MetadataLockPort['verdict']>> | Error): MetadataLockPort & { asked: string[] } => {
2162+
const asked: string[] = [];
2163+
return {
2164+
asked,
2165+
async verdict(type, name, packageId) {
2166+
asked.push(`${type}|${name}|${packageId}`);
2167+
if (answer instanceof Error) throw answer;
2168+
return answer;
2169+
},
2170+
};
2171+
};
2172+
/** A seeded position no catalog declares, its row binding member_default. */
2173+
const UNDECLARED = `
2174+
INSERT INTO sys_position VALUES ('pos_gone', NULL, 'zz_undeclared', 'Undeclared', 'platform', 0, 1);
2175+
INSERT INTO sys_position_permission_set VALUES ('bind_gone', NULL, 'pos_gone', 'ps_null');
2176+
`;
2177+
/** A seeded position whose rows bind member_default and zz_extra; the catalog's definition names member_default only. */
2178+
const DECLARED = `
2179+
INSERT INTO sys_permission_set VALUES ('ps_extra', NULL, 'zz_extra', 'Extra', 'platform', 1, '{}', '[]');
2180+
INSERT INTO sys_position VALUES ('pos_env', NULL, 'zz_env', 'Env', 'platform', 0, 1);
2181+
INSERT INTO sys_position_permission_set VALUES ('bind_env_1', NULL, 'pos_env', 'ps_null'), ('bind_env_2', NULL, 'pos_env', 'ps_extra');
2182+
`;
2183+
/** The catalog, declaring zz_env as `definition` from the next boot on. */
2184+
const declaring = async (definition: Record<string, unknown>): Promise<ReturnType<typeof catalog>> => {
2185+
const c = catalog();
2186+
await c.save('position', 'zz_env', { name: 'zz_env', label: 'Env', permissionSets: ['member_default'], ...definition });
2187+
return c;
2188+
};
2189+
2190+
it('⭐ undeclared: declare it with the bound sets through ONE of its two doors — never the step that reports done; nothing is deleted', async () => {
2191+
db.exec(UNDECLARED);
2192+
const lock = lockAnswering({ editable: true });
2193+
const report = await apply(catalog(), ledger(), { posture: 'isolated', lock });
2194+
2195+
expect(step(report, 'authored-conversion')).toMatchObject({ status: 'done', blockers: [] });
2196+
const rewrite = step(report, 'rewrite');
2197+
expect(rewrite.status).toBe('blocked');
2198+
const remedy = remedyOf(blockerOf(rewrite, 'position zz_undeclared '));
2199+
expect(remedy).not.toContain(POINTER);
2200+
expect(remedy).toContain('member_default');
2201+
// Both doors, and one only: the deployment's code, or the metadata door as the platform administrator.
2202+
expect(remedy).toContain('deployment\'s code');
2203+
expect(remedy).toContain('PUT /api/v1/meta/position/zz_undeclared');
2204+
expect(remedy).toMatch(/one door only/);
2205+
// The organization-authored position the base fixture binds is undeclared too, and gets the same act.
2206+
expect(remedyOf(blockerOf(rewrite, 'position sales_lead '))).toContain('PUT /api/v1/meta/position/sales_lead');
2207+
// An undeclared name has no definition for the door to judge.
2208+
expect(lock.asked).toEqual([]);
2209+
// Still refused: nothing is deleted or guessed.
2210+
expect(step(report, 'mirror-deletion').status).toBe('not-reached');
2211+
expect(deletes()).toEqual([]);
2212+
});
2213+
2214+
it('⭐ declared and editable: add the missing sets through the metadata door alone, as the door itself answers', async () => {
2215+
db.exec(DECLARED);
2216+
const lock = lockAnswering({ editable: true });
2217+
const report = await apply(await declaring({}), ledger(), { posture: 'group', lock });
2218+
2219+
const remedy = remedyOf(blockerOf(step(report, 'rewrite'), 'position zz_env '));
2220+
expect(remedy).not.toContain(POINTER);
2221+
expect(remedy).toContain('zz_extra');
2222+
expect(remedy).not.toContain('member_default');
2223+
expect(remedy).toContain('PUT /api/v1/meta/position/zz_env');
2224+
// The environment holds the name: the code door would refuse the cold boot.
2225+
expect(remedy).not.toContain('deployment\'s code');
2226+
expect(lock.asked).toEqual(['position|zz_env|null']);
2227+
expect(deletes()).toEqual([]);
2228+
});
2229+
2230+
it.each([
2231+
['a package ships it (ADR-0131 D6)', { _packageId: 'com.example.vendor', _provenance: 'package' }, { editable: false, lock: 'no-overlay' }, ['com.example.vendor', 'ADR-0131 D6']],
2232+
['an item lock', {}, { editable: false, lock: 'full', lockReason: 'frozen by the operator' }, ['full', 'frozen by the operator']],
2233+
['the door\'s verdict is unreadable', {}, new Error('no metadata protocol is composed on this stack'), ['no metadata protocol is composed on this stack']],
2234+
] as const)('⭐ declared and not editable — %s: the fact, and no act', async (_label, definition, answer, facts) => {
2235+
db.exec(DECLARED);
2236+
const lock = lockAnswering(answer as Awaited<ReturnType<MetadataLockPort['verdict']>> | Error);
2237+
const report = await apply(await declaring(definition), ledger(), { posture: 'isolated', lock });
2238+
2239+
const blocker = blockerOf(step(report, 'rewrite'), 'position zz_env ');
2240+
for (const fact of facts) expect(blocker).toContain(fact);
2241+
expect(blocker).not.toMatch(ACT);
2242+
expect(lock.asked).toEqual(['position|zz_env|null']);
2243+
expect(deletes()).toEqual([]);
2244+
});
2245+
2246+
it('conflicting, under a wall: the fact, and no act', async () => {
2247+
db.exec(`
2248+
INSERT INTO sys_position VALUES ('pos_c1', 'org_default', 'zz_conflict', 'C', 'platform', 0, 1),
2249+
('pos_c2', 'org_b', 'zz_conflict', 'C', 'platform', 0, 1);
2250+
INSERT INTO sys_position_permission_set VALUES ('bind_c1', 'org_default', 'pos_c1', 'ps_null');
2251+
`);
2252+
const report = await apply(catalog(), ledger(), { posture: 'isolated' });
2253+
2254+
const blocker = blockerOf(step(report, 'rewrite'), 'Position zz_conflict:');
2255+
expect(blocker).not.toMatch(ACT);
2256+
expect(deletes()).toEqual([]);
2257+
});
2258+
2259+
it('control — single: the gate keeps the pointer at authored-conversion, and asks the door nothing', async () => {
2260+
const c = catalog();
2261+
const l = ledger();
2262+
await expect(apply(c, l, { executor: executor({ failOnceOn: /^DELETE FROM "sys_capability"/ }) })).rejects.toThrow(/killed mid-step/);
2263+
db.exec(UNDECLARED);
2264+
const lock = lockAnswering({ editable: true });
2265+
2266+
const resumed = await apply(c, l, { lock });
2267+
2268+
const deletion = step(resumed, 'mirror-deletion');
2269+
expect(deletion.status).toBe('blocked');
2270+
expect(remedyOf(blockerOf(deletion, 'position zz_undeclared '))).toBe(`deleting them would lose a grant. ${POINTER}`);
2271+
expect(lock.asked).toEqual([]);
2272+
});
2273+
});

0 commit comments

Comments
 (0)