Compare commits

...
Author SHA1 Message Date
fred e4cb0f99b1 review r1 fix: sole-authoring-path precision; storage transport/adapter disposition recorded in code and inventory doc
ci/woodpecker/pr/ci Pipeline was successful
2026-08-29 16:36:26 -05:00
fred 931542ee19 brain/gateway: prohibit mission_tasks.status as a write source (M4-3a phase 1)
ci/woodpecker/pr/ci Pipeline was canceled
SHARED-CONTRACT §5.1 phase 1 N-1 patch, ahead of the M4-3a expand DDL:
- mission-tasks repo (sole write path) strips status on create/update;
  the column keeps its DB default and stays declared and readable.
- missions controller stops forwarding dto.status on create.
- DTO status fields stay declared (forbidNonWhitelisted would 400 frozen
  legacy consumers) but are documented deprecated/ignored.
- §5.5 write-prohibition spec: 4 tests on what reaches the Drizzle chain.
2026-08-29 16:21:04 -05:00
fred 5399c6b7e7 docs: P0 field-map currency verification at next@abb0c936 (M4-3a-0) (#1478)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-29 21:17:05 +00:00
marcieandorch-01 e09b8783b4 P1b: read-only viewers join the R1/R4 usage contract (#1472)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 21:03:07 +00:00
marcieandorch-01 abb0c93601 framework tools/tmux: agent-send socket default resolution + ambiguity guard (B1) (#1466)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: marcie <[email protected]>
2026-08-29 20:25:50 +00:00
marcieandorch-01 e67cced273 mosaic fleet logins: per-seat credential pass-through (P4 gap closure) (#1473)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 20:05:23 +00:00
fred 635cb1f666 docs: contract 2 Amendment 1 — company-CRUD capability (S2 follow-up) (#1477)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-29 19:34:48 +00:00
marcieandorch-01 09d24b9275 pr-merge --base-line: gated intra-line exception (B5) (#1471)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: marcie <[email protected]>
2026-08-29 18:14:40 +00:00
marcieandorch-01 ed4c543872 mosaic coord: board subcommand + roll alias (P3 gap closure) (#1470)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: marcie <[email protected]>
2026-08-29 17:52:30 +00:00
fred 215faeda0a feat(hierarchy): M4-1b-ii hierarchy command family, grant evaluation, visibility (#1465)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-29 16:54:39 +00:00
orch-01 5125fe21b0 p0 line landing: coord board/roll + wrapper usage contract + dispatch layer (successor to #1467) (#1468)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: orch-01 <[email protected]>
2026-08-29 16:24:08 +00:00
marcieandorch-01 41e8046371 framework tools/git: issue-comment R1/R4 usage-error contract (sync from brain) (#1462)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: marcie <[email protected]>
2026-08-29 01:58:42 +00:00
fred 6e16675ea2 docs: deployment mode and conversion contract (S2 contract 6) (#1439)
ci/woodpecker/push/publish Pipeline was successful
2026-08-28 22:57:11 +00:00
fred 19ebc422aa docs: tool-gateway mapping contract (S2 contract 5) (#1438)
ci/woodpecker/push/publish Pipeline was successful
2026-08-28 22:04:03 +00:00
91 changed files with 13591 additions and 299 deletions
@@ -4,14 +4,14 @@ import { AppModule } from '../app.module.js';
import { HierarchyModule } from '../hierarchy/hierarchy.module.js';
/**
* Hierarchy route-inventory baseline (contract 1 §6.3(a)).
* Hierarchy route inventory (contract 1 §6.3).
*
* M4-1b-i ships the audit event + outbox machinery with NO mutation routes:
* the hierarchy command family (controllers + DTOs) lands in M4-1b-ii once
* contract 2 merges. This witness enumerates every route the AppModule graph
* declares and pins that baseline, so a hierarchy route appearing before its
* command-family witnesses exist fails here first. When M4-1b-ii lands, this
* baseline is replaced by an exact inventory of the command family.
* The hierarchy command family is a CLOSED enumeration asserted here, not a
* prose claim: every hierarchy-flavored route the AppModule graph declares
* must appear in HIERARCHY_COMMAND_FAMILY, and vice versa. Adding or
* removing a hierarchy route without updating this inventory (and its
* witnesses) fails CI first. This replaces the M4-1b-i zero-routes
* baseline.
*/
interface RouteEntry {
@@ -77,7 +77,32 @@ function routesOf(controller: Type<unknown>): RouteEntry[] {
return routes;
}
describe('hierarchy route-inventory baseline (§6.3(a))', () => {
/**
* The closed command family (contract 1 §5, M4-1b-ii). Every entry is a
* mutation audited via the M4-1b-i path or one of the two ratified reads
* (granted companies, the §2.8 directory carve-out).
*/
const HIERARCHY_COMMAND_FAMILY = [
'POST /api/hierarchy/companies',
'GET /api/hierarchy/companies',
'GET /api/hierarchy/companies/directory',
'POST /api/hierarchy/companies/:id/rename',
'POST /api/hierarchy/companies/:id/visibility',
'DELETE /api/hierarchy/companies/:id',
'POST /api/hierarchy/estates',
'POST /api/hierarchy/estates/:id/rename',
'POST /api/hierarchy/estates/:id/transfer',
'DELETE /api/hierarchy/estates/:id',
'POST /api/hierarchy/platform-projects',
'POST /api/hierarchy/platform-projects/:id/rename',
'POST /api/hierarchy/platform-projects/:id/transfer',
'DELETE /api/hierarchy/platform-projects/:id',
'POST /api/hierarchy/grants',
'POST /api/hierarchy/grants/:id/change',
'DELETE /api/hierarchy/grants/:id',
] as const;
describe('hierarchy route inventory (§6.3)', () => {
const inventory = collectControllers(AppModule).flatMap(routesOf);
it('control: the enumeration sees the known route surface', () => {
@@ -88,19 +113,21 @@ describe('hierarchy route-inventory baseline (§6.3(a))', () => {
expect(inventory.length).toBeGreaterThan(20);
});
it('declares zero hierarchy mutation routes before M4-1b-ii', () => {
const hierarchyRoutes = inventory.filter((r) =>
/hierarch|compan|estate|platform[-_]?project/i.test(r.path),
);
expect(
hierarchyRoutes,
'a hierarchy route landed without replacing the §6.3(a) baseline with a command-family inventory',
).toEqual([]);
it('the hierarchy surface is exactly the declared command family', () => {
const hierarchyRoutes = inventory
.filter((r) => /hierarch|compan|estate|platform[-_]?project/i.test(r.path))
.map((r) => `${r.method} ${r.path}`)
.sort();
expect(hierarchyRoutes).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
});
it('HierarchyModule itself declares no controllers', () => {
expect((Reflect.getMetadata('controllers', HierarchyModule) ?? []) as unknown[]).toEqual([]);
const hierarchyControllers = collectControllers(HierarchyModule);
expect(hierarchyControllers).toEqual([]);
it('every command-family route lives on HierarchyController inside HierarchyModule', () => {
const controllers = collectControllers(HierarchyModule);
expect(controllers.map((c) => c.name)).toEqual(['HierarchyController']);
const declared = controllers
.flatMap(routesOf)
.map((r) => `${r.method} ${r.path}`)
.sort();
expect(declared).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
});
});
@@ -61,6 +61,33 @@ describe('CommandAuthorizationService', () => {
).toBe(false);
});
it('denies non-admin scopes to a platform admin (contract 2 §1.1 bypass retirement)', async (): Promise<void> => {
const service = createService('admin');
for (const scope of ['core', 'agent', 'skill', 'plugin'] as const) {
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
expect(
(await service.authorize(command, { ...payload, command: command.name }, 'admin-1'))
.allowed,
).toBe(false);
}
});
it('allows member core/agent scopes and denies skill/plugin (deny-by-default)', async (): Promise<void> => {
const service = createService('member');
for (const [scope, allowed] of [
['core', true],
['agent', true],
['skill', false],
['plugin', false],
] as const) {
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
expect(
(await service.authorize(command, { ...payload, command: command.name }, 'member-1'))
.allowed,
).toBe(allowed);
}
});
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
const entries = new Map<string, string>();
const action = {
@@ -154,8 +154,15 @@ export class CommandAuthorizationService {
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
}
/**
* Contract 2 §1.1: platform admin confers instance administration only —
* the former admin-passes-every-scope short-circuit is retired. Admin
* reaches exactly the admin scope; core/agent scopes belong to the member
* role; skill/plugin scopes stay deny-for-all until a grant mapping names
* them (§3.1 deny-by-default).
*/
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
if (role === 'admin') return true;
if (scope === 'admin') return role === 'admin';
return role === 'member' && (scope === 'core' || scope === 'agent');
}
@@ -28,8 +28,8 @@ import { DB } from '../database/database.module.js';
*
* This is NOT a class-table writer: it touches only the audit/outbox
* tables, so it does not appear on the writer-coverage allowlist. The
* hierarchy command repositories (M4-1b-ii) are the allowlisted writers and
* call into this on their own transactions.
* hierarchy command repository (HierarchyRepository) is the allowlisted
* writer and calls into this on its own transactions.
*/
export type HierarchyAuditVerb = (typeof HIERARCHY_AUDIT_VERBS)[number];
@@ -0,0 +1,965 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import {
companies,
createPgliteDb,
eq,
estates,
hierarchyAuditEvents,
hierarchyGrants,
hierarchyOutbox,
runPgliteMigrations,
teams,
users,
workspaces,
type DbHandle,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { appendHierarchyEvent } from './hierarchy-audit.repository.js';
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
import { HierarchyRepository, type HierarchyResult } from './hierarchy.repository.js';
/**
* Command-level witnesses for the hierarchy command family (M4-1b-ii):
* contract 1 §6.4 (per-mutation-class commit + rollback), §6.5
* (authorization outcomes), §6.7 (no existence oracle), §6.9 (visibility),
* and contract 2 §3 grant-evaluation semantics (deny-by-default,
* ancestor-chain inheritance, max-role, live revocation, suspended team
* subjects). Schema-level constraints are witnessed in
* packages/db/src/hierarchy-schema.witness.test.ts; the audit machinery's
* own atomicity in hierarchy-audit.integration.test.ts.
*
* The rollback legs pre-seed an audit event under the command's idempotency
* key with different content: the command's append then throws inside the
* command transaction, so the whole mutation must roll back — the command
* returns `conflict` and leaves no state change, no second event, and no
* second outbox record.
*/
describe('hierarchy commands integration', (): void => {
let dataDir: string;
let handle: DbHandle;
let moduleRef: TestingModule;
let repo: HierarchyRepository;
let evaluation: HierarchyGrantEvaluationService;
const OWNER = 'hier-cmd-owner';
const ADMIN = 'hier-cmd-admin';
const STRANGER = 'hier-cmd-stranger';
const SUBJECT = 'hier-cmd-subject';
/** Base fixture: OWNER's company (created through the command surface). */
let companyId: string;
const slug = (prefix: string): string => `${prefix}-${randomUUID().slice(0, 8)}`;
function expectOk<T>(result: HierarchyResult<T>): { ok: true } & T {
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
return result;
}
const eventsForKey = (key: string) =>
handle.db
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
const outboxForKey = (key: string) =>
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key));
/** Occupy `key` with unrelated event content so a command reusing it must abort. */
const seedConflictingKey = async (key: string): Promise<void> => {
await handle.db.transaction(async (tx) =>
appendHierarchyEvent(tx, {
actorId: 'seed-actor',
verb: 'create',
targetKind: 'company',
targetId: randomUUID(),
targetSnapshot: { seeded: true },
correlationId: 'seed-correlation',
idempotencyKey: key,
}),
);
};
/**
* §6.4 rollback leg: the command must return `conflict` and leave exactly
* the seeded event/outbox pair under the key — nothing it wrote survives.
*/
const expectRolledBack = async <T>(
key: string,
command: () => Promise<HierarchyResult<T>>,
assertUnchanged: () => Promise<void>,
): Promise<void> => {
await seedConflictingKey(key);
const result = await command();
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBe('conflict');
expect(await eventsForKey(key)).toHaveLength(1);
expect(await outboxForKey(key)).toHaveLength(1);
await assertUnchanged();
};
beforeAll(async (): Promise<void> => {
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-commands-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
moduleRef = await Test.createTestingModule({
providers: [
HierarchyRepository,
HierarchyGrantEvaluationService,
{ provide: DB, useValue: handle.db },
],
}).compile();
repo = moduleRef.get(HierarchyRepository);
evaluation = moduleRef.get(HierarchyGrantEvaluationService);
await handle.db.insert(users).values([
{ id: OWNER, name: 'Owner', email: `${OWNER}@example.com` },
{ id: ADMIN, name: 'Admin', email: `${ADMIN}@example.com`, role: 'admin' },
{ id: STRANGER, name: 'Stranger', email: `${STRANGER}@example.com` },
{ id: SUBJECT, name: 'Subject', email: `${SUBJECT}@example.com` },
]);
const created = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Base Co', slug: slug('base') }),
);
companyId = created.company.id;
});
afterAll(async (): Promise<void> => {
await moduleRef.close();
await handle.close();
await rm(dataDir, { recursive: true, force: true });
});
// ── §6.4 commit legs ───────────────────────────────────────────────────────
it('createCompany commits company, owner grant, causation-linked events, and outbox atomically', async () => {
const key = `key-${randomUUID()}`;
const result = expectOk(
await repo.createCompany({
actorId: OWNER,
name: 'Atomic Co',
slug: slug('atomic'),
idempotencyKey: key,
}),
);
expect(result.company.visibility).toBe('private');
expect(result.grant.role).toBe('hierarchy:owner');
expect(result.grant.userId).toBe(OWNER);
expect(result.grant.grantedBy).toBe(OWNER);
const [createEvents, grantEvents] = await Promise.all([
eventsForKey(key),
eventsForKey(`${key}:grant`),
]);
expect(createEvents).toHaveLength(1);
expect(createEvents[0]).toMatchObject({ verb: 'create', targetId: result.company.id });
expect(grantEvents).toHaveLength(1);
expect(grantEvents[0]).toMatchObject({ verb: 'grant_create', targetId: result.grant.id });
// The grant event is caused by the create event, same correlation (§4.3).
expect(grantEvents[0]!.causationId).toBe(createEvents[0]!.id);
expect(grantEvents[0]!.correlationId).toBe(createEvents[0]!.correlationId);
expect(await outboxForKey(key)).toHaveLength(1);
expect(await outboxForKey(`${key}:grant`)).toHaveLength(1);
const rows = await handle.db
.select()
.from(companies)
.where(eq(companies.id, result.company.id));
expect(rows).toHaveLength(1);
});
it('deleteCompany commits the delete with one audited grant_revoke per cascaded grant', async () => {
const created = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Mortal Co', slug: slug('mortal') }),
);
const extraGrant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: created.company.id,
role: 'viewer',
}),
);
const key = `key-${randomUUID()}`;
expectOk(
await repo.deleteCompany({
actorId: OWNER,
companyId: created.company.id,
idempotencyKey: key,
}),
);
const deleteEvents = await eventsForKey(key);
expect(deleteEvents).toHaveLength(1);
expect(deleteEvents[0]).toMatchObject({ verb: 'delete', targetId: created.company.id });
for (const grantId of [created.grant.id, extraGrant.grant.id]) {
const revokeEvents = await eventsForKey(`${key}:revoke:${grantId}`);
expect(revokeEvents).toHaveLength(1);
expect(revokeEvents[0]).toMatchObject({ verb: 'grant_revoke', targetId: grantId });
expect(revokeEvents[0]!.causationId).toBe(deleteEvents[0]!.id);
}
expect(
await handle.db.select().from(companies).where(eq(companies.id, created.company.id)),
).toHaveLength(0);
});
// ── §6.4 rollback legs (one per mutation class) ────────────────────────────
it('renameCompany commits the rename with an audited event carrying previousName', async () => {
const created = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Old Name Co', slug: slug('rename') }),
);
const key = `key-${randomUUID()}`;
const renamed = expectOk(
await repo.renameCompany({
actorId: OWNER,
companyId: created.company.id,
name: 'New Name Co',
idempotencyKey: key,
}),
);
expect(renamed.company.name).toBe('New Name Co');
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'rename', targetId: created.company.id });
// §6.4: the audited rename carries the old and new names.
expect(events[0]!.targetSnapshot).toMatchObject({
name: 'New Name Co',
previousName: 'Old Name Co',
});
expect(await outboxForKey(key)).toHaveLength(1);
const rows = await handle.db
.select()
.from(companies)
.where(eq(companies.id, created.company.id));
expect(rows[0]!.name).toBe('New Name Co');
});
it('revokeGrant commits the row deletion with one audited grant_revoke event', async () => {
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
);
const key = `key-${randomUUID()}`;
const revoked = expectOk(
await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: key }),
);
expect(revoked.revokedId).toBe(grant.grant.id);
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'grant_revoke', targetId: grant.grant.id });
expect(await outboxForKey(key)).toHaveLength(1);
// §6 revocation = row deletion: the grant row is gone.
const rows = await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, grant.grant.id));
expect(rows).toHaveLength(0);
});
it('rolls back a create: no estate row survives the aborted transaction', async () => {
const estateSlug = slug('rb-create');
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.createEstate({
actorId: OWNER,
companyId,
name: 'Doomed Estate',
slug: estateSlug,
idempotencyKey: key,
}),
async () => {
expect(
await handle.db.select().from(estates).where(eq(estates.slug, estateSlug)),
).toHaveLength(0);
},
);
});
it('rolls back a rename: the company keeps its name', async () => {
const before = (
await handle.db.select().from(companies).where(eq(companies.id, companyId))
)[0]!;
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() => repo.renameCompany({ actorId: OWNER, companyId, name: 'Never', idempotencyKey: key }),
async () => {
const after = (
await handle.db.select().from(companies).where(eq(companies.id, companyId))
)[0]!;
expect(after.name).toBe(before.name);
},
);
});
it('rolls back a visibility change: the company stays private', async () => {
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.changeCompanyVisibility({
actorId: ADMIN,
companyId,
visibility: 'directory',
idempotencyKey: key,
}),
async () => {
const after = (
await handle.db.select().from(companies).where(eq(companies.id, companyId))
)[0]!;
expect(after.visibility).toBe('private');
},
);
});
it('rolls back a transfer: the estate keeps its parent', async () => {
const estate = expectOk(
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-T', slug: slug('rb-t') }),
);
const other = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'RB Dest', slug: slug('rb-dest') }),
);
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: other.company.id,
idempotencyKey: key,
}),
async () => {
const after = (
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
)[0]!;
expect(after.companyId).toBe(companyId);
},
);
});
it('rolls back a delete: the estate row survives', async () => {
const estate = expectOk(
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-D', slug: slug('rb-d') }),
);
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() => repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id, idempotencyKey: key }),
async () => {
expect(
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id)),
).toHaveLength(1);
},
);
});
it('rolls back a grant create: no grant row survives', async () => {
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.createGrant({
actorId: OWNER,
userId: STRANGER,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
idempotencyKey: key,
}),
async () => {
const rows = await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.userId, STRANGER));
expect(rows.filter((r) => r.companyId === companyId)).toHaveLength(0);
},
);
});
it('rolls back a grant change and a grant revoke: the grant keeps its role and its row', async () => {
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
);
const changeKey = `key-${randomUUID()}`;
await expectRolledBack(
changeKey,
() =>
repo.changeGrant({
actorId: OWNER,
grantId: grant.grant.id,
role: 'member',
idempotencyKey: changeKey,
}),
async () => {
const row = (
await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, grant.grant.id))
)[0]!;
expect(row.role).toBe('viewer');
},
);
const revokeKey = `key-${randomUUID()}`;
await expectRolledBack(
revokeKey,
() =>
repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: revokeKey }),
async () => {
expect(
await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, grant.grant.id)),
).toHaveLength(1);
},
);
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
});
it('replays a completed command idempotently through the audit machinery', async () => {
const key = `key-${randomUUID()}`;
const input = { actorId: OWNER, companyId, name: 'Replayed Estate', slug: slug('replay') };
const first = expectOk(await repo.createEstate({ ...input, idempotencyKey: key }));
// The retry's insert no-ops on the slug conflict — the command surfaces
// `conflict`, and crucially appends no second event under the key.
const retry = await repo.createEstate({ ...input, idempotencyKey: key });
expect(retry.ok).toBe(false);
expect(await eventsForKey(key)).toHaveLength(1);
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: first.estate.id }));
});
// ── §6.5 authorization ─────────────────────────────────────────────────────
it('deny-by-default: a user with no grant cannot mutate and sees not_found (§3.1)', async () => {
expect(await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' })).toEqual({
ok: false,
error: 'not_found',
});
expect(
await repo.createEstate({ actorId: STRANGER, companyId, name: 'x', slug: slug('deny') }),
).toEqual({ ok: false, error: 'not_found' });
expect(await repo.deleteCompany({ actorId: STRANGER, companyId })).toEqual({
ok: false,
error: 'not_found',
});
});
it('grant management requires effective owner: member and viewer are refused (§4.1)', async () => {
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'member',
}),
);
expect(
await repo.createGrant({
actorId: SUBJECT,
userId: STRANGER,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
).toEqual({ ok: false, error: 'not_found' });
expect(await repo.revokeGrant({ actorId: SUBJECT, grantId: grant.grant.id })).toEqual({
ok: false,
error: 'not_found',
});
// Member also cannot create children (owner-only, §4.1/§4.3).
expect(
await repo.createEstate({ actorId: SUBJECT, companyId, name: 'x', slug: slug('member') }),
).toEqual({ ok: false, error: 'not_found' });
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
});
it('platform admin confers no tenant content access (§1.1): ungrated admin is a stranger', async () => {
expect(await repo.renameCompany({ actorId: ADMIN, companyId, name: 'x' })).toEqual({
ok: false,
error: 'not_found',
});
expect(
await repo.createGrant({
actorId: ADMIN,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
).toEqual({ ok: false, error: 'not_found' });
expect(await repo.listGrantedCompanies(ADMIN)).toEqual([]);
expect(await evaluation.effectiveRole(ADMIN, 'company', companyId)).toBeNull();
});
it('visibility change is platform-admin-only (§5.5): the owner is forbidden, the admin succeeds', async () => {
const owned = await repo.changeCompanyVisibility({
actorId: OWNER,
companyId,
visibility: 'directory',
});
expect(owned).toEqual({
ok: false,
error: 'forbidden',
message: 'visibility change is platform-admin-only',
});
const changed = expectOk(
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'directory' }),
);
expect(changed.company.visibility).toBe('directory');
// Restore for later witnesses.
expectOk(
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'private' }),
);
});
// ── §6.9 visibility ────────────────────────────────────────────────────────
it('directory lists exactly directory-class companies with closed fields (§2.8)', async () => {
const listed = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Listed Co', slug: slug('listed') }),
);
const unlisted = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Unlisted Co', slug: slug('unlisted') }),
);
const key = `key-${randomUUID()}`;
expectOk(
await repo.changeCompanyVisibility({
actorId: ADMIN,
companyId: listed.company.id,
visibility: 'directory',
idempotencyKey: key,
}),
);
const directory = await repo.listDirectory();
const ids = directory.map((entry) => entry.id);
expect(ids).toContain(listed.company.id);
expect(ids).not.toContain(unlisted.company.id);
expect(ids).not.toContain(companyId);
// Closed-field: existence, name, slug — nothing else (no visibility, no
// timestamps, no grant or membership data).
for (const entry of directory) {
expect(Object.keys(entry).sort()).toEqual(['id', 'name', 'slug']);
}
// §5.5: the audited event carries old and new values.
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'visibility_change', targetId: listed.company.id });
expect(events[0]!.targetSnapshot).toMatchObject({
previousVisibility: 'private',
visibility: 'directory',
});
});
it('directory disclosure confers no authority: a listed company still refuses non-granted callers (§6.9)', async () => {
const listed = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Exposed Co', slug: slug('exposed') }),
);
expectOk(
await repo.changeCompanyVisibility({
actorId: ADMIN,
companyId: listed.company.id,
visibility: 'directory',
}),
);
// The company is directory-listed for the whole probe window...
expect((await repo.listDirectory()).map((entry) => entry.id)).toContain(listed.company.id);
// ...but the non-granted reader's granted-read surface still excludes it:
// directory disclosure adds existence/name/slug only, never content access.
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
// A stranger mutation of the listed company is refused exactly like a
// missing node — the §6.7 carve-out covers the listing, not commands.
const realProbe = await repo.renameCompany({
actorId: STRANGER,
companyId: listed.company.id,
name: 'x',
});
const missingProbe = await repo.renameCompany({
actorId: STRANGER,
companyId: randomUUID(),
name: 'x',
});
expect(realProbe).toEqual(missingProbe);
expect(await repo.deleteCompany({ actorId: STRANGER, companyId: listed.company.id })).toEqual({
ok: false,
error: 'not_found',
});
});
it('granted companies are the reader control: owner sees them, a stranger sees nothing (§2.8)', async () => {
const ownerCompanies = await repo.listGrantedCompanies(OWNER);
expect(ownerCompanies.map((c) => c.id)).toContain(companyId);
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
});
// ── §6.7 no existence oracle ───────────────────────────────────────────────
it('an unauthorized probe of a real node is indistinguishable from a missing node', async () => {
const realCompany = await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' });
const missingCompany = await repo.renameCompany({
actorId: STRANGER,
companyId: randomUUID(),
name: 'x',
});
expect(realCompany).toEqual(missingCompany);
const estate = expectOk(
await repo.createEstate({
actorId: OWNER,
companyId,
name: 'Oracle E',
slug: slug('oracle'),
}),
);
const realEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: estate.estate.id });
const missingEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: randomUUID() });
expect(realEstate).toEqual(missingEstate);
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'estate',
targetId: estate.estate.id,
role: 'viewer',
}),
);
const realGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: grant.grant.id });
const missingGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: randomUUID() });
expect(realGrant).toEqual(missingGrant);
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
});
// ── contract 2 §3 grant evaluation ─────────────────────────────────────────
it('a company grant confers its role down the whole chain, workspace included (§3.2)', async () => {
const estate = expectOk(
await repo.createEstate({ actorId: OWNER, companyId, name: 'Chain E', slug: slug('chain') }),
);
const project = expectOk(
await repo.createPlatformProject({
actorId: OWNER,
estateId: estate.estate.id,
name: 'Chain P',
slug: slug('chain-p'),
}),
);
// Workspaces are evaluable but not hierarchy commands; seed one directly.
const workspaceId = randomUUID();
await handle.db.insert(workspaces).values({
id: workspaceId,
name: 'Chain W',
slug: slug('chain-w'),
platformProjectId: project.platformProject.id,
});
for (const [kind, id] of [
['company', companyId],
['estate', estate.estate.id],
['platform_project', project.platformProject.id],
['workspace', workspaceId],
] as const) {
expect(await evaluation.effectiveRole(OWNER, kind, id)).toBe('owner');
expect(await evaluation.effectiveRole(STRANGER, kind, id)).toBeNull();
}
// Max-role (§3.3): viewer on the company + owner on the estate → owner at
// and below the estate, viewer at the company.
const viewerGrant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
);
const ownerGrant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'estate',
targetId: estate.estate.id,
role: 'owner',
}),
);
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBe('viewer');
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('owner');
expect(await evaluation.effectiveRole(SUBJECT, 'workspace', workspaceId)).toBe('owner');
// Revocation is row deletion and denies the very next evaluation (§6).
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: ownerGrant.grant.id }));
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('viewer');
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: viewerGrant.grant.id }));
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
await handle.db.delete(workspaces).where(eq(workspaces.id, workspaceId));
expectOk(
await repo.deletePlatformProject({
actorId: OWNER,
platformProjectId: project.platformProject.id,
}),
);
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
});
it('team grant subjects are suspended: a team row confers nothing and cannot be changed (§1.4)', async () => {
const teamId = randomUUID();
await handle.db.insert(teams).values({
id: teamId,
name: slug('team'),
slug: slug('team'),
ownerId: SUBJECT,
managerId: SUBJECT,
});
// Out-of-band team row (the command surface cannot create one).
const inserted = await handle.db
.insert(hierarchyGrants)
.values({ teamId, companyId, role: 'owner', grantedBy: OWNER })
.returning();
const teamGrantId = inserted[0]!.id;
// The team's own owner gains no effective role from it.
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
// changeGrant refuses the row.
expect(
await repo.changeGrant({ actorId: OWNER, grantId: teamGrantId, role: 'viewer' }),
).toEqual({
ok: false,
error: 'conflict',
message: 'team grant subjects are suspended',
});
await handle.db.delete(hierarchyGrants).where(eq(hierarchyGrants.id, teamGrantId));
await handle.db.delete(teams).where(eq(teams.id, teamId));
});
// ── command conflict semantics ─────────────────────────────────────────────
it('transfer needs owner on both parents in its own transaction, and refuses no-op and colliding transfers (§5)', async () => {
const source = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Src Co', slug: slug('src') }),
);
const destination = expectOk(
await repo.createCompany({ actorId: SUBJECT, name: 'Dst Co', slug: slug('dst') }),
);
const estateSlug = slug('mv');
const estate = expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: source.company.id,
name: 'Mv E',
slug: estateSlug,
}),
);
// OWNER owns the source but not the destination → not_found (§6.7-safe).
expect(
await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: destination.company.id,
}),
).toEqual({ ok: false, error: 'not_found' });
// Same-parent transfer is refused.
const samePlace = await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: source.company.id,
});
expect(samePlace.ok).toBe(false);
if (!samePlace.ok) expect(samePlace.error).toBe('conflict');
// Grant OWNER the destination; a slug collision there is refused.
expectOk(
await repo.createGrant({
actorId: SUBJECT,
userId: OWNER,
targetKind: 'company',
targetId: destination.company.id,
role: 'owner',
}),
);
expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: destination.company.id,
name: 'Collide',
slug: estateSlug,
}),
);
const collision = await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: destination.company.id,
});
expect(collision.ok).toBe(false);
if (!collision.ok) expect(collision.error).toBe('conflict');
});
it('a successful transfer records transfer_from and transfer_to (§6.4 three-leg witness)', async () => {
const from = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'From Co', slug: slug('from') }),
);
const to = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'To Co', slug: slug('to') }),
);
const estate = expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: from.company.id,
name: 'Moved E',
slug: slug('moved'),
}),
);
const key = `key-${randomUUID()}`;
expectOk(
await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: to.company.id,
idempotencyKey: key,
}),
);
const moved = (
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
)[0]!;
expect(moved.companyId).toBe(to.company.id);
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'transfer', targetId: estate.estate.id });
expect(events[0]!.transferFrom).toMatchObject({ kind: 'company', id: from.company.id });
expect(events[0]!.transferTo).toMatchObject({ kind: 'company', id: to.company.id });
// The post-transfer snapshot's parent chain names the destination.
expect(events[0]!.targetSnapshot).toMatchObject({
parentChain: [{ kind: 'company', id: to.company.id, slug: to.company.slug }],
});
});
it('refuses duplicate slugs, deletes with children, and degenerate grant commands as conflicts', async () => {
const co = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Conflict Co', slug: slug('conf') }),
);
const dupSlug = await repo.createCompany({ actorId: OWNER, name: 'x', slug: co.company.slug });
expect(dupSlug.ok).toBe(false);
if (!dupSlug.ok) expect(dupSlug.error).toBe('conflict');
expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: co.company.id,
name: 'Child',
slug: slug('child'),
}),
);
const withChildren = await repo.deleteCompany({ actorId: OWNER, companyId: co.company.id });
expect(withChildren.ok).toBe(false);
if (!withChildren.ok) expect(withChildren.error).toBe('conflict');
// Grant to a nonexistent subject is refused (the caller already holds
// owner, so the refusal discloses nothing new).
const ghost = await repo.createGrant({
actorId: OWNER,
userId: `missing-${randomUUID()}`,
targetKind: 'company',
targetId: co.company.id,
role: 'viewer',
});
expect(ghost.ok).toBe(false);
if (!ghost.ok) expect(ghost.error).toBe('conflict');
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: co.company.id,
role: 'viewer',
}),
);
const duplicate = await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: co.company.id,
role: 'viewer',
});
expect(duplicate.ok).toBe(false);
if (!duplicate.ok) expect(duplicate.error).toBe('conflict');
const sameRole = await repo.changeGrant({
actorId: OWNER,
grantId: grant.grant.id,
role: 'viewer',
});
expect(sameRole.ok).toBe(false);
if (!sameRole.ok) expect(sameRole.error).toBe('conflict');
// A second grant with another role exists → changing the first onto that
// role would collide with the unique constraint; refused ahead of it.
const second = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: co.company.id,
role: 'member',
}),
);
const collide = await repo.changeGrant({
actorId: OWNER,
grantId: grant.grant.id,
role: 'member',
});
expect(collide.ok).toBe(false);
if (!collide.ok) expect(collide.error).toBe('conflict');
// A clean change succeeds and records the previous role, namespaced (§4.5).
const changeKey = `key-${randomUUID()}`;
const changed = expectOk(
await repo.changeGrant({
actorId: OWNER,
grantId: second.grant.id,
role: 'owner',
idempotencyKey: changeKey,
}),
);
expect(changed.grant.role).toBe('hierarchy:owner');
const events = await eventsForKey(changeKey);
expect(events).toHaveLength(1);
expect(events[0]!.targetSnapshot).toMatchObject({
role: 'hierarchy:owner',
previousRole: 'hierarchy:member',
});
});
});
@@ -0,0 +1,242 @@
import { Inject, Injectable } from '@nestjs/common';
import {
companies,
eq,
estates,
hierarchyGrants,
inArray,
or,
platformProjects,
workspaces,
and,
type Db,
HIERARCHY_GRANT_ROLES,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
/**
* Hierarchy grant evaluation (contract 2 §3).
*
* Deny-by-default (§3.1): a user's effective role on a node is null unless a
* grant row explicitly confers one. Grants apply down the chain only (§3.2):
* the effective role on a node is the maximum role over grants targeting the
* node itself or any of its ancestors, maximum per the total order
* viewer ⊂ member ⊂ owner (§2). Evaluation is live and per-decision — no
* caching — so revocation (row deletion, §6) denies the next decision
* inherently. A missing node evaluates to null, indistinguishable from
* no-grant, which keeps unauthorized probes oracle-safe (contract 1 §6.7).
*
* Team grant subjects are SUSPENDED (§1.4): the command surface refuses to
* create them and this evaluator considers user-subject grants only, so a
* team row could not confer access even if one existed.
*
* Read-only module: it selects from the class tables but never writes them,
* so it does not appear on the writer-coverage allowlist.
*/
export type HierarchyGrantRole = (typeof HIERARCHY_GRANT_ROLES)[number];
/** Node kinds a grant may target (§3.2; workspace is evaluable, not grantable). */
export type GrantTargetKind = 'company' | 'estate' | 'platform_project';
/** Node kinds an authorization decision may be evaluated at (§3.2: down to workspace). */
export type EvaluableNodeKind = GrantTargetKind | 'workspace';
type Tx = Pick<Db, 'select'>;
/** Ancestor chain of a node, self included at its own level; ids only. */
export interface AncestorChain {
readonly companyId: string;
readonly estateId?: string;
readonly platformProjectId?: string;
readonly workspaceId?: string;
}
export function roleStrength(role: HierarchyGrantRole): number {
return HIERARCHY_GRANT_ROLES.indexOf(role);
}
export function roleAtLeast(
role: HierarchyGrantRole | null,
required: HierarchyGrantRole,
): boolean {
return role !== null && roleStrength(role) >= roleStrength(required);
}
/**
* Serialized role strings are namespaced (§4.5): audit events and API
* responses carry `hierarchy:owner`, never a bare `owner`.
*/
export function namespacedHierarchyRole(role: HierarchyGrantRole): string {
return `hierarchy:${role}`;
}
/**
* Resolve a node's ancestor chain (self included). Returns null when the
* node does not exist — callers treat that exactly like no-grant (§3.1,
* oracle-safe).
*/
export async function resolveAncestorChain(
tx: Tx,
kind: EvaluableNodeKind,
id: string,
): Promise<AncestorChain | null> {
if (kind === 'company') {
const rows = await tx
.select({ id: companies.id })
.from(companies)
.where(eq(companies.id, id))
.limit(1);
const row = rows[0];
return row ? { companyId: row.id } : null;
}
if (kind === 'estate') {
const rows = await tx
.select({ id: estates.id, companyId: estates.companyId })
.from(estates)
.where(eq(estates.id, id))
.limit(1);
const row = rows[0];
return row ? { companyId: row.companyId, estateId: row.id } : null;
}
if (kind === 'platform_project') {
const rows = await tx
.select({
id: platformProjects.id,
estateId: platformProjects.estateId,
companyId: estates.companyId,
})
.from(platformProjects)
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
.where(eq(platformProjects.id, id))
.limit(1);
const row = rows[0];
return row
? { companyId: row.companyId, estateId: row.estateId, platformProjectId: row.id }
: null;
}
const rows = await tx
.select({
id: workspaces.id,
platformProjectId: workspaces.platformProjectId,
estateId: platformProjects.estateId,
companyId: estates.companyId,
})
.from(workspaces)
.innerJoin(platformProjects, eq(platformProjects.id, workspaces.platformProjectId))
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
.where(eq(workspaces.id, id))
.limit(1);
const row = rows[0];
return row
? {
companyId: row.companyId,
estateId: row.estateId,
platformProjectId: row.platformProjectId,
workspaceId: row.id,
}
: null;
}
function maxRole(roles: readonly string[]): HierarchyGrantRole | null {
let best: HierarchyGrantRole | null = null;
for (const candidate of roles) {
// Fail-closed: a value outside the vocabulary confers nothing.
if (!(HIERARCHY_GRANT_ROLES as readonly string[]).includes(candidate)) continue;
const role = candidate as HierarchyGrantRole;
if (best === null || roleStrength(role) > roleStrength(best)) best = role;
}
return best;
}
/**
* Effective role of a user on a node: maximum over the user's grants whose
* target is the node or any ancestor (§3.2); null = deny (§3.1). Missing
* node → null.
*/
export async function evaluateEffectiveRole(
tx: Tx,
userId: string,
kind: EvaluableNodeKind,
id: string,
): Promise<HierarchyGrantRole | null> {
const chain = await resolveAncestorChain(tx, kind, id);
if (!chain) return null;
const targetConditions = [eq(hierarchyGrants.companyId, chain.companyId)];
if (chain.estateId) targetConditions.push(eq(hierarchyGrants.estateId, chain.estateId));
if (chain.platformProjectId) {
targetConditions.push(eq(hierarchyGrants.platformProjectId, chain.platformProjectId));
}
const rows = await tx
.select({ role: hierarchyGrants.role })
.from(hierarchyGrants)
.where(and(eq(hierarchyGrants.userId, userId), or(...targetConditions)));
return maxRole(rows.map((r) => r.role));
}
/**
* All companies on which the user holds any effective role, i.e. companies
* with a grant on the company itself or on any descendant (contract 1 §2.8:
* a grant anywhere in the subtree discloses the company's chain upward).
*/
export async function grantedCompanyIds(tx: Tx, userId: string): Promise<string[]> {
const grants = await tx
.select({
companyId: hierarchyGrants.companyId,
estateId: hierarchyGrants.estateId,
platformProjectId: hierarchyGrants.platformProjectId,
})
.from(hierarchyGrants)
.where(eq(hierarchyGrants.userId, userId));
const companyIds = new Set<string>();
const estateIds = new Set<string>();
const platformProjectIds = new Set<string>();
for (const grant of grants) {
if (grant.companyId) companyIds.add(grant.companyId);
else if (grant.estateId) estateIds.add(grant.estateId);
else if (grant.platformProjectId) platformProjectIds.add(grant.platformProjectId);
}
if (platformProjectIds.size > 0) {
const rows = await tx
.select({ estateId: platformProjects.estateId })
.from(platformProjects)
.where(inArray(platformProjects.id, [...platformProjectIds]));
for (const row of rows) estateIds.add(row.estateId);
}
if (estateIds.size > 0) {
const rows = await tx
.select({ companyId: estates.companyId })
.from(estates)
.where(inArray(estates.id, [...estateIds]));
for (const row of rows) companyIds.add(row.companyId);
}
return [...companyIds];
}
@Injectable()
export class HierarchyGrantEvaluationService {
constructor(@Inject(DB) private readonly db: Db) {}
/** Live per-decision evaluation; pass a tx to evaluate inside a command's transaction. */
effectiveRole(
userId: string,
kind: EvaluableNodeKind,
id: string,
tx?: Tx,
): Promise<HierarchyGrantRole | null> {
return evaluateEffectiveRole(tx ?? this.db, userId, kind, id);
}
async hasRole(
userId: string,
kind: EvaluableNodeKind,
id: string,
required: HierarchyGrantRole,
tx?: Tx,
): Promise<boolean> {
return roleAtLeast(await this.effectiveRole(userId, kind, id, tx), required);
}
}
@@ -0,0 +1,297 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import {
ChangeCompanyVisibilityDto,
ChangeGrantDto,
CreateCompanyDto,
CreateEstateDto,
CreateGrantDto,
CreatePlatformProjectDto,
DeleteNodeDto,
RenameNodeDto,
TransferEstateDto,
TransferPlatformProjectDto,
} from './hierarchy.dto.js';
import { HierarchyRepository } from './hierarchy.repository.js';
import { HierarchyService } from './hierarchy.service.js';
/**
* The hierarchy command family (contract 1 §5, §6.3). This controller is the
* closed HTTP surface over the hierarchy class tables: the route-inventory
* witness asserts these routes and no others exist. Delete commands take an
* optional body (idempotency key) via POST-style DTOs; every mutation is
* audited on its own transaction by the repository.
*/
@Controller('api/hierarchy')
@UseGuards(AuthGuard)
export class HierarchyController {
constructor(
private readonly repository: HierarchyRepository,
private readonly service: HierarchyService,
) {}
// ── companies ────────────────────────────────────────────────────────────
@Post('companies')
async createCompany(@CurrentUser() user: { id: string }, @Body() dto: CreateCompanyDto) {
return this.service.unwrap(
await this.repository.createCompany({
actorId: user.id,
name: dto.name,
slug: dto.slug,
idempotencyKey: dto.idempotencyKey,
}),
);
}
/** Companies the caller holds a grant on (directly or via a descendant). */
@Get('companies')
listGrantedCompanies(@CurrentUser() user: { id: string }) {
return this.repository.listGrantedCompanies(user.id);
}
/** Directory-class companies, closed-field (§2.8). */
@Get('companies/directory')
listDirectory() {
return this.repository.listDirectory();
}
@Post('companies/:id/rename')
@HttpCode(200)
async renameCompany(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenameNodeDto,
) {
return this.service.unwrap(
await this.repository.renameCompany({
actorId: user.id,
companyId: id,
name: dto.name,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('companies/:id/visibility')
@HttpCode(200)
async changeCompanyVisibility(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ChangeCompanyVisibilityDto,
) {
return this.service.unwrap(
await this.repository.changeCompanyVisibility({
actorId: user.id,
companyId: id,
visibility: dto.visibility,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('companies/:id')
async deleteCompany(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.deleteCompany({
actorId: user.id,
companyId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
// ── estates ──────────────────────────────────────────────────────────────
@Post('estates')
async createEstate(@CurrentUser() user: { id: string }, @Body() dto: CreateEstateDto) {
return this.service.unwrap(
await this.repository.createEstate({
actorId: user.id,
companyId: dto.companyId,
name: dto.name,
slug: dto.slug,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('estates/:id/rename')
@HttpCode(200)
async renameEstate(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenameNodeDto,
) {
return this.service.unwrap(
await this.repository.renameEstate({
actorId: user.id,
estateId: id,
name: dto.name,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('estates/:id/transfer')
@HttpCode(200)
async transferEstate(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: TransferEstateDto,
) {
return this.service.unwrap(
await this.repository.transferEstate({
actorId: user.id,
estateId: id,
destinationCompanyId: dto.destinationCompanyId,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('estates/:id')
async deleteEstate(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.deleteEstate({
actorId: user.id,
estateId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
// ── platform projects ────────────────────────────────────────────────────
@Post('platform-projects')
async createPlatformProject(
@CurrentUser() user: { id: string },
@Body() dto: CreatePlatformProjectDto,
) {
return this.service.unwrap(
await this.repository.createPlatformProject({
actorId: user.id,
estateId: dto.estateId,
name: dto.name,
slug: dto.slug,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('platform-projects/:id/rename')
@HttpCode(200)
async renamePlatformProject(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenameNodeDto,
) {
return this.service.unwrap(
await this.repository.renamePlatformProject({
actorId: user.id,
platformProjectId: id,
name: dto.name,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('platform-projects/:id/transfer')
@HttpCode(200)
async transferPlatformProject(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: TransferPlatformProjectDto,
) {
return this.service.unwrap(
await this.repository.transferPlatformProject({
actorId: user.id,
platformProjectId: id,
destinationEstateId: dto.destinationEstateId,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('platform-projects/:id')
async deletePlatformProject(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.deletePlatformProject({
actorId: user.id,
platformProjectId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
// ── grants ───────────────────────────────────────────────────────────────
@Post('grants')
async createGrant(@CurrentUser() user: { id: string }, @Body() dto: CreateGrantDto) {
return this.service.unwrap(
await this.repository.createGrant({
actorId: user.id,
userId: dto.userId,
targetKind: dto.targetKind,
targetId: dto.targetId,
role: dto.role,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('grants/:id/change')
@HttpCode(200)
async changeGrant(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ChangeGrantDto,
) {
return this.service.unwrap(
await this.repository.changeGrant({
actorId: user.id,
grantId: id,
role: dto.role,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('grants/:id')
async revokeGrant(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.revokeGrant({
actorId: user.id,
grantId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
}
+169
View File
@@ -0,0 +1,169 @@
import { COMPANY_VISIBILITY, HIERARCHY_GRANT_ROLES } from '@mosaicstack/db';
import { IsIn, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
/**
* Hierarchy command DTOs (contract 1 §5, contract 2 §4/§7).
*
* The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so a
* payload field absent from these classes is a 400. That closure is itself
* contract surface:
* - CreateCompanyDto declares NO visibility field — creation is always
* private (contract 1 §5.5); a visibility argument is refused by the pipe.
* - CreateGrantDto declares NO teamId field — team grant subjects are
* suspended (contract 2 §1.4/§7.5); a team subject is refused by the pipe.
* Every class here must be registered in PIPE_GUARDED_DTOS so the boot-time
* assertion proves the pipe sees the decorators.
*/
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
const SLUG_MESSAGE = 'slug must be lowercase alphanumeric with interior hyphens';
export class CreateCompanyDto {
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(100)
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
slug!: string;
/** Client-supplied idempotency key (REQ-AUD-001 replay); server-generated when absent. */
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class RenameNodeDto {
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class ChangeCompanyVisibilityDto {
@IsIn(COMPANY_VISIBILITY)
visibility!: (typeof COMPANY_VISIBILITY)[number];
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class DeleteNodeDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class CreateEstateDto {
@IsUUID()
companyId!: string;
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(100)
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
slug!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class CreatePlatformProjectDto {
@IsUUID()
estateId!: string;
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(100)
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
slug!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class TransferEstateDto {
@IsUUID()
destinationCompanyId!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class TransferPlatformProjectDto {
@IsUUID()
destinationEstateId!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class CreateGrantDto {
/** Subject user (better-auth text id). No teamId field — see module doc. */
@IsString()
@MinLength(1)
@MaxLength(255)
userId!: string;
@IsIn(['company', 'estate', 'platform_project'])
targetKind!: 'company' | 'estate' | 'platform_project';
@IsUUID()
targetId!: string;
/** Bare vocabulary on requests; responses and audit events are namespaced (§4.5). */
@IsIn(HIERARCHY_GRANT_ROLES)
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class ChangeGrantDto {
@IsIn(HIERARCHY_GRANT_ROLES)
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
+18 -7
View File
@@ -1,17 +1,28 @@
import { Module } from '@nestjs/common';
import { HierarchyAuditRepository } from './hierarchy-audit.repository.js';
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
import { HierarchyController } from './hierarchy.controller.js';
import { HierarchyRepository } from './hierarchy.repository.js';
import { HierarchyService } from './hierarchy.service.js';
/**
* Hierarchy (tenancy/authorization structure) feature module.
*
* M4-1b-i ships the audit event + outbox machinery only (contract 1 §5.2).
* The hierarchy command family — controllers, DTOs, and the allowlisted
* class-table repositories — lands in M4-1b-ii once contract 2 (RBAC grant
* model) merges; until then this module exposes no routes, which the
* route-inventory witness asserts.
* M4-1b-i shipped the audit event + outbox machinery (contract 1 §5.2);
* M4-1b-ii adds the command family — the closed route surface asserted by
* the route-inventory witness — plus grant evaluation (contract 2 §3).
* HierarchyRepository is the sole class-table writer (writer-coverage
* allowlist); every mutation runs authorize → mutate → audit in one
* transaction.
*/
@Module({
providers: [HierarchyAuditRepository],
exports: [HierarchyAuditRepository],
controllers: [HierarchyController],
providers: [
HierarchyAuditRepository,
HierarchyGrantEvaluationService,
HierarchyRepository,
HierarchyService,
],
exports: [HierarchyAuditRepository, HierarchyGrantEvaluationService],
})
export class HierarchyModule {}
@@ -0,0 +1,935 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import {
and,
asc,
companies,
eq,
estates,
hierarchyGrants,
inArray,
platformProjects,
users,
workspaces,
type Db,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import {
appendHierarchyEvent,
buildNodeSnapshot,
HierarchyAuditIdempotencyConflictError,
} from './hierarchy-audit.repository.js';
import {
evaluateEffectiveRole,
grantedCompanyIds,
namespacedHierarchyRole,
roleAtLeast,
type GrantTargetKind,
type HierarchyGrantRole,
} from './hierarchy-grant-evaluation.js';
/**
* Hierarchy command repository (contract 1 §5, contract 2 §4).
*
* The ONLY writer of the hierarchy class tables (companies, estates,
* platform_projects, hierarchy_grants) — it is the writer-coverage
* allowlist's sole entry. Every command runs one transaction that
* authorizes (live grant evaluation inside the same transaction), mutates,
* and appends the semantic audit event + outbox record via the M4-1b-i
* machinery, so state, event, and outbox commit or roll back together
* (REQ-AUD-001).
*
* Authorization failure and target-not-found both return `not_found`
* (contract 1 §6.7: no existence oracle — an unauthorized caller learns
* nothing a stranger would not). `forbidden` appears only where the caller
* already knows the surface exists independent of any node: the admin-only
* visibility change (§5.5). Serialized role strings are namespaced (§4.5).
*/
export type HierarchyCommandFailure =
| { readonly ok: false; readonly error: 'not_found' }
| { readonly ok: false; readonly error: 'forbidden'; readonly message: string }
| { readonly ok: false; readonly error: 'conflict'; readonly message: string };
export type HierarchyResult<T> = ({ readonly ok: true } & T) | HierarchyCommandFailure;
export interface CompanyView {
readonly id: string;
readonly name: string;
readonly slug: string;
readonly visibility: string;
}
export interface NodeView {
readonly id: string;
readonly name: string;
readonly slug: string;
}
export interface GrantView {
readonly id: string;
readonly userId: string;
readonly targetKind: GrantTargetKind;
readonly targetId: string;
/** Namespaced (§4.5), e.g. `hierarchy:owner`. */
readonly role: string;
readonly grantedBy: string;
}
/** Directory rows are closed-field: existence, name, slug — nothing else (§2.8). */
export interface DirectoryEntry {
readonly id: string;
readonly name: string;
readonly slug: string;
}
type Tx = Pick<Db, 'insert' | 'select' | 'update' | 'delete'>;
type GrantRow = typeof hierarchyGrants.$inferSelect;
const NOT_FOUND: HierarchyCommandFailure = { ok: false, error: 'not_found' };
function conflict(message: string): HierarchyCommandFailure {
return { ok: false, error: 'conflict', message };
}
function grantTarget(row: GrantRow): { kind: GrantTargetKind; id: string } {
if (row.companyId) return { kind: 'company', id: row.companyId };
if (row.estateId) return { kind: 'estate', id: row.estateId };
return { kind: 'platform_project', id: row.platformProjectId as string };
}
/** Grant event snapshot (contract 2 §4.4): subject, target, namespaced role, grantor. */
function grantSnapshot(row: GrantRow): Record<string, unknown> {
const target = grantTarget(row);
return {
id: row.id,
subject: { userId: row.userId },
target: { kind: target.kind, id: target.id },
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
grantedBy: row.grantedBy,
};
}
function grantView(row: GrantRow): GrantView {
const target = grantTarget(row);
return {
id: row.id,
userId: row.userId as string,
targetKind: target.kind,
targetId: target.id,
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
grantedBy: row.grantedBy,
};
}
function companyView(row: typeof companies.$inferSelect): CompanyView {
return { id: row.id, name: row.name, slug: row.slug, visibility: row.visibility };
}
interface CommandContext {
readonly actorId: string;
readonly idempotencyKey: string;
readonly correlationId: string;
}
@Injectable()
export class HierarchyRepository {
constructor(@Inject(DB) private readonly db: Db) {}
private async run<T>(
idempotencyKey: string | undefined,
actorId: string,
body: (tx: Tx, ctx: CommandContext) => Promise<HierarchyResult<T>>,
): Promise<HierarchyResult<T>> {
const ctx: CommandContext = {
actorId,
idempotencyKey: idempotencyKey ?? randomUUID(),
correlationId: randomUUID(),
};
try {
return await this.db.transaction(async (tx) => body(tx, ctx));
} catch (error) {
// A key replayed with different content aborts the whole command —
// the transaction (state change included) has rolled back (§6.4).
if (error instanceof HierarchyAuditIdempotencyConflictError) {
return conflict(error.message);
}
throw error;
}
}
private async requireOwner(
tx: Tx,
actorId: string,
kind: GrantTargetKind,
id: string,
): Promise<boolean> {
return roleAtLeast(await evaluateEffectiveRole(tx, actorId, kind, id), 'owner');
}
private async isPlatformAdmin(tx: Tx, actorId: string): Promise<boolean> {
const rows = await tx
.select({ role: users.role })
.from(users)
.where(eq(users.id, actorId))
.limit(1);
return rows[0]?.role === 'admin';
}
// ── companies ────────────────────────────────────────────────────────────
/**
* Any authenticated user may create a company; the same audited operation
* writes the creator's initial owner grant (§4.3), causation-linked to the
* create event. Visibility is always 'private' — the command takes no
* visibility input (§5.5).
*/
createCompany(input: {
actorId: string;
name: string;
slug: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ company: CompanyView; grant: GrantView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const inserted = await tx
.insert(companies)
.values({ name: input.name, slug: input.slug })
.onConflictDoNothing()
.returning();
const company = inserted[0];
if (!company) return conflict('company slug already exists');
const grantRows = await tx
.insert(hierarchyGrants)
.values({
userId: ctx.actorId,
companyId: company.id,
role: 'owner',
grantedBy: ctx.actorId,
})
.returning();
const grant = grantRows[0] as GrantRow;
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
const created = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'create',
targetKind: 'company',
targetId: company.id,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_create',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: created.event.id,
idempotencyKey: `${ctx.idempotencyKey}:grant`,
});
return { ok: true, company: companyView(company), grant: grantView(grant) };
});
}
renameCompany(input: {
actorId: string;
companyId: string;
name: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ company: CompanyView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
return NOT_FOUND;
}
const rows = await tx
.select()
.from(companies)
.where(eq(companies.id, input.companyId))
.limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(companies)
.set({ name: input.name, updatedAt: new Date() })
.where(eq(companies.id, input.companyId))
.returning();
const company = updated[0] as typeof companies.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'rename',
targetKind: 'company',
targetId: company.id,
targetSnapshot: { ...snapshot, previousName: previous.name },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, company: companyView(company) };
});
}
/**
* Admin-only until the company-CRUD capability ratifies (§5.5) — the one
* hierarchy mutation a platform admin performs without a grant. A
* non-admin caller (owner included) gets `forbidden` before any company
* read: the refusal reveals nothing about the target's existence.
*/
changeCompanyVisibility(input: {
actorId: string;
companyId: string;
visibility: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ company: CompanyView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.isPlatformAdmin(tx, ctx.actorId))) {
return {
ok: false,
error: 'forbidden',
message: 'visibility change is platform-admin-only',
};
}
const rows = await tx
.select()
.from(companies)
.where(eq(companies.id, input.companyId))
.limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(companies)
.set({ visibility: input.visibility, updatedAt: new Date() })
.where(eq(companies.id, input.companyId))
.returning();
const company = updated[0] as typeof companies.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'visibility_change',
targetKind: 'company',
targetId: company.id,
// Old and new values are event content (§5.5).
targetSnapshot: {
...snapshot,
previousVisibility: previous.visibility,
visibility: company.visibility,
},
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, company: companyView(company) };
});
}
deleteCompany(input: {
actorId: string;
companyId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ deletedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
return NOT_FOUND;
}
const children = await tx
.select({ id: estates.id })
.from(estates)
.where(eq(estates.companyId, input.companyId))
.limit(1);
if (children.length > 0) return conflict('company still has estates');
// Snapshot and grants are read before the delete; target FKs cascade
// the grant rows, and each cascaded deletion is audited (§5.2).
const snapshot = await buildNodeSnapshot(tx, 'company', input.companyId);
const grants = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.companyId, input.companyId));
await tx.delete(companies).where(eq(companies.id, input.companyId));
const deleted = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'delete',
targetKind: 'company',
targetId: input.companyId,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
for (const grant of grants) {
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: deleted.event.id,
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
});
}
return { ok: true, deletedId: input.companyId };
});
}
// ── estates ──────────────────────────────────────────────────────────────
/** Child creation requires owner on the parent and confers no grant (§4.3). */
createEstate(input: {
actorId: string;
companyId: string;
name: string;
slug: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ estate: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
return NOT_FOUND;
}
const inserted = await tx
.insert(estates)
.values({ companyId: input.companyId, name: input.name, slug: input.slug })
.onConflictDoNothing()
.returning();
const estate = inserted[0];
if (!estate) return conflict('estate slug already exists in company');
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'create',
targetKind: 'estate',
targetId: estate.id,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
});
}
renameEstate(input: {
actorId: string;
estateId: string;
name: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ estate: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
return NOT_FOUND;
}
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(estates)
.set({ name: input.name })
.where(eq(estates.id, input.estateId))
.returning();
const estate = updated[0] as typeof estates.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'rename',
targetKind: 'estate',
targetId: estate.id,
targetSnapshot: { ...snapshot, previousName: previous.name },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
});
}
/** Transfer requires effective owner on BOTH parents, evaluated in the transfer's own transaction (§5). */
transferEstate(input: {
actorId: string;
estateId: string;
destinationCompanyId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ estate: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
const estate = rows[0];
if (!estate) return NOT_FOUND;
if (!(await this.requireOwner(tx, ctx.actorId, 'company', estate.companyId))) {
return NOT_FOUND;
}
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.destinationCompanyId))) {
return NOT_FOUND;
}
if (estate.companyId === input.destinationCompanyId) {
return conflict('estate already belongs to the destination company');
}
const collision = await tx
.select({ id: estates.id })
.from(estates)
.where(
and(eq(estates.companyId, input.destinationCompanyId), eq(estates.slug, estate.slug)),
)
.limit(1);
if (collision.length > 0) return conflict('destination company already has that estate slug');
const parents = await tx
.select({ id: companies.id, slug: companies.slug })
.from(companies)
.where(inArray(companies.id, [estate.companyId, input.destinationCompanyId]));
const source = parents.find((p) => p.id === estate.companyId);
const destination = parents.find((p) => p.id === input.destinationCompanyId);
if (!source || !destination) return NOT_FOUND;
await tx
.update(estates)
.set({ companyId: input.destinationCompanyId })
.where(eq(estates.id, input.estateId));
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'transfer',
targetKind: 'estate',
targetId: input.estateId,
targetSnapshot: { ...snapshot },
transferFrom: { kind: 'company', id: source.id, slug: source.slug },
transferTo: { kind: 'company', id: destination.id, slug: destination.slug },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
});
}
deleteEstate(input: {
actorId: string;
estateId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ deletedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
return NOT_FOUND;
}
const children = await tx
.select({ id: platformProjects.id })
.from(platformProjects)
.where(eq(platformProjects.estateId, input.estateId))
.limit(1);
if (children.length > 0) return conflict('estate still has platform projects');
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
const grants = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.estateId, input.estateId));
await tx.delete(estates).where(eq(estates.id, input.estateId));
const deleted = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'delete',
targetKind: 'estate',
targetId: input.estateId,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
for (const grant of grants) {
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: deleted.event.id,
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
});
}
return { ok: true, deletedId: input.estateId };
});
}
// ── platform projects ────────────────────────────────────────────────────
createPlatformProject(input: {
actorId: string;
estateId: string;
name: string;
slug: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
return NOT_FOUND;
}
const inserted = await tx
.insert(platformProjects)
.values({ estateId: input.estateId, name: input.name, slug: input.slug })
.onConflictDoNothing()
.returning();
const project = inserted[0];
if (!project) return conflict('platform project slug already exists in estate');
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'create',
targetKind: 'platform_project',
targetId: project.id,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return {
ok: true,
platformProject: { id: project.id, name: project.name, slug: project.slug },
};
});
}
renamePlatformProject(input: {
actorId: string;
platformProjectId: string;
name: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
) {
return NOT_FOUND;
}
const rows = await tx
.select()
.from(platformProjects)
.where(eq(platformProjects.id, input.platformProjectId))
.limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(platformProjects)
.set({ name: input.name })
.where(eq(platformProjects.id, input.platformProjectId))
.returning();
const project = updated[0] as typeof platformProjects.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'rename',
targetKind: 'platform_project',
targetId: project.id,
targetSnapshot: { ...snapshot, previousName: previous.name },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return {
ok: true,
platformProject: { id: project.id, name: project.name, slug: project.slug },
};
});
}
transferPlatformProject(input: {
actorId: string;
platformProjectId: string;
destinationEstateId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx
.select()
.from(platformProjects)
.where(eq(platformProjects.id, input.platformProjectId))
.limit(1);
const project = rows[0];
if (!project) return NOT_FOUND;
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', project.estateId))) {
return NOT_FOUND;
}
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.destinationEstateId))) {
return NOT_FOUND;
}
if (project.estateId === input.destinationEstateId) {
return conflict('platform project already belongs to the destination estate');
}
const collision = await tx
.select({ id: platformProjects.id })
.from(platformProjects)
.where(
and(
eq(platformProjects.estateId, input.destinationEstateId),
eq(platformProjects.slug, project.slug),
),
)
.limit(1);
if (collision.length > 0) {
return conflict('destination estate already has that platform project slug');
}
const parents = await tx
.select({ id: estates.id, slug: estates.slug })
.from(estates)
.where(inArray(estates.id, [project.estateId, input.destinationEstateId]));
const source = parents.find((p) => p.id === project.estateId);
const destination = parents.find((p) => p.id === input.destinationEstateId);
if (!source || !destination) return NOT_FOUND;
await tx
.update(platformProjects)
.set({ estateId: input.destinationEstateId })
.where(eq(platformProjects.id, input.platformProjectId));
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'transfer',
targetKind: 'platform_project',
targetId: input.platformProjectId,
targetSnapshot: { ...snapshot },
transferFrom: { kind: 'estate', id: source.id, slug: source.slug },
transferTo: { kind: 'estate', id: destination.id, slug: destination.slug },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return {
ok: true,
platformProject: { id: project.id, name: project.name, slug: project.slug },
};
});
}
deletePlatformProject(input: {
actorId: string;
platformProjectId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ deletedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
) {
return NOT_FOUND;
}
const children = await tx
.select({ id: workspaces.id })
.from(workspaces)
.where(eq(workspaces.platformProjectId, input.platformProjectId))
.limit(1);
if (children.length > 0) return conflict('platform project still has workspaces');
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
const grants = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.platformProjectId, input.platformProjectId));
await tx.delete(platformProjects).where(eq(platformProjects.id, input.platformProjectId));
const deleted = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'delete',
targetKind: 'platform_project',
targetId: input.platformProjectId,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
for (const grant of grants) {
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: deleted.event.id,
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
});
}
return { ok: true, deletedId: input.platformProjectId };
});
}
// ── grants ───────────────────────────────────────────────────────────────
/** Grant management requires effective owner on the target (§4.1). */
createGrant(input: {
actorId: string;
userId: string;
targetKind: GrantTargetKind;
targetId: string;
role: HierarchyGrantRole;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ grant: GrantView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, input.targetKind, input.targetId))) {
return NOT_FOUND;
}
const subject = await tx
.select({ id: users.id })
.from(users)
.where(eq(users.id, input.userId))
.limit(1);
if (subject.length === 0) return conflict('subject user does not exist');
const inserted = await tx
.insert(hierarchyGrants)
.values({
userId: input.userId,
companyId: input.targetKind === 'company' ? input.targetId : null,
estateId: input.targetKind === 'estate' ? input.targetId : null,
platformProjectId: input.targetKind === 'platform_project' ? input.targetId : null,
role: input.role,
grantedBy: ctx.actorId,
})
.onConflictDoNothing()
.returning();
const grant = inserted[0];
if (!grant) return conflict('grant already exists');
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_create',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, grant: grantView(grant) };
});
}
changeGrant(input: {
actorId: string;
grantId: string;
role: HierarchyGrantRole;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ grant: GrantView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, input.grantId))
.limit(1);
const existing = rows[0];
if (!existing) return NOT_FOUND;
const target = grantTarget(existing);
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
return NOT_FOUND;
}
// Team subjects are suspended (§1.4); the command surface never
// creates them, so this only fires on out-of-band rows.
if (!existing.userId) return conflict('team grant subjects are suspended');
if (existing.role === input.role) return conflict('grant already holds that role');
const duplicate = await tx
.select({ id: hierarchyGrants.id })
.from(hierarchyGrants)
.where(
and(
eq(hierarchyGrants.userId, existing.userId),
target.kind === 'company'
? eq(hierarchyGrants.companyId, target.id)
: target.kind === 'estate'
? eq(hierarchyGrants.estateId, target.id)
: eq(hierarchyGrants.platformProjectId, target.id),
eq(hierarchyGrants.role, input.role),
),
)
.limit(1);
if (duplicate.length > 0) {
return conflict('subject already holds that role on the target');
}
const updated = await tx
.update(hierarchyGrants)
.set({ role: input.role })
.where(eq(hierarchyGrants.id, input.grantId))
.returning();
const grant = updated[0] as GrantRow;
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_change',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: {
...grantSnapshot(grant),
previousRole: namespacedHierarchyRole(existing.role as HierarchyGrantRole),
},
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, grant: grantView(grant) };
});
}
/** Revocation is row deletion (§6): the next evaluation denies, nothing lingers. */
revokeGrant(input: {
actorId: string;
grantId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ revokedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, input.grantId))
.limit(1);
const existing = rows[0];
if (!existing) return NOT_FOUND;
const target = grantTarget(existing);
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
return NOT_FOUND;
}
await tx.delete(hierarchyGrants).where(eq(hierarchyGrants.id, input.grantId));
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: existing.id,
targetSnapshot: grantSnapshot(existing),
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, revokedId: existing.id };
});
}
// ── reads ────────────────────────────────────────────────────────────────
/**
* The directory: every directory-class company, closed-field (§2.8). The
* sole ratified existence-disclosure carve-out (§6.7 / A2 §9.1.2).
*/
async listDirectory(): Promise<DirectoryEntry[]> {
return this.db
.select({ id: companies.id, name: companies.name, slug: companies.slug })
.from(companies)
.where(eq(companies.visibility, 'directory'))
.orderBy(asc(companies.name));
}
/** Companies the user holds any grant on (company or descendant, §2.8). */
async listGrantedCompanies(userId: string): Promise<CompanyView[]> {
const ids = await grantedCompanyIds(this.db, userId);
if (ids.length === 0) return [];
const rows = await this.db
.select()
.from(companies)
.where(inArray(companies.id, ids))
.orderBy(asc(companies.name));
return rows.map(companyView);
}
}
@@ -0,0 +1,31 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { HierarchyCommandFailure, HierarchyResult } from './hierarchy.repository.js';
/**
* Maps repository result unions onto HTTP exceptions. `not_found` carries
* one fixed message for every cause — missing node and unauthorized caller
* are indistinguishable on the wire (contract 1 §6.7).
*/
@Injectable()
export class HierarchyService {
unwrap<T>(result: HierarchyResult<T>): T {
if (result.ok) return result;
throw this.toException(result);
}
private toException(failure: HierarchyCommandFailure): Error {
switch (failure.error) {
case 'not_found':
return new NotFoundException('hierarchy node not found');
case 'forbidden':
return new ForbiddenException(failure.message);
case 'conflict':
return new ConflictException(failure.message);
}
}
}
+89 -24
View File
@@ -176,7 +176,18 @@ describe('MCP actor identity and tool scope enforcement', () => {
).toBe(false);
expect(
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
).toBe(true);
).toBe(false);
});
it('derives no scope elevation from any platform role (contract 2 §1.1 bypass retirement)', () => {
const memberScopes = deriveMcpToolScopesForUser({ role: 'member' });
for (const role of ['admin', 'platform-admin', 'super-admin', null, undefined]) {
const scopes = deriveMcpToolScopesForUser({ role });
expect([...scopes].sort()).toEqual([...memberScopes].sort());
expect(scopes.has(MCP_TOOL_SCOPES.brain_create_task)).toBe(false);
expect(scopes.has(MCP_TOOL_SCOPES.brain_update_task)).toBe(false);
expect(scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
}
});
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
@@ -311,14 +322,20 @@ describe('MCP actor identity and tool scope enforcement', () => {
]);
});
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
const { service } = makeService({
it('gives admin-role and platform-admin-role actors only owned content on brain reads (§1.1 retirement)', async () => {
// Contract 2 §1.1: users.role confers no content visibility. An actor whose
// role is 'admin', 'platform-admin', or 'super-admin' but who holds no
// ownership sees exactly what an unprivileged member with the same
// ownership would see — here, only the one project they own, and nothing
// tenant-wide or platform-wide.
const fixtures = {
projects: [
{ id: 'project-owned', ownerId: 'role-bearing-user', teamId: 'tenant-a', name: 'owned' },
{
id: 'project-tenant-a',
ownerId: 'other-user-a',
teamId: 'tenant-a',
name: 'same tenant',
name: 'same tenant, unowned',
},
{
id: 'project-tenant-b',
@@ -328,39 +345,50 @@ describe('MCP actor identity and tool scope enforcement', () => {
},
],
missions: [
{ id: 'mission-owned', projectId: 'project-owned' },
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
],
tasks: [
{ id: 'task-owned', projectId: 'project-owned', status: 'not-started' },
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
],
});
const { server, tools } = makeCapturingServer();
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
};
service.registerTools(server, actor);
const actors = [
makeAdminActor('role-bearing-user', 'tenant-a'),
makePlatformAdminActor('role-bearing-user'),
];
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
for (const actor of actors) {
const { service } = makeService(fixtures);
const { server, tools } = makeCapturingServer();
service.registerTools(server, actor);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-owned']);
}
});
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
const { service, brain } = makeService({
projects: [
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
// §1.1 retirement: content visibility comes from ownership, not the
// tenant-admin role — the acting user owns the tenant-a project.
{ id: 'project-tenant-a', ownerId: 'tenant-admin-user', teamId: 'tenant-a' },
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
],
missions: [
@@ -373,7 +401,29 @@ describe('MCP actor identity and tool scope enforcement', () => {
],
});
const { server, tools } = makeCapturingServer();
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
// Platform role no longer derives task-write scopes (§1.1 retirement):
// a role-derived admin actor is scope-denied before any tenant logic.
const roleDerivedAdmin = makeAdminActor('tenant-admin-user', 'tenant-a');
service.registerTools(server, roleDerivedAdmin);
await expect(
getTool(tools, 'brain_create_task').handler({ title: 'role-derived write' }),
).rejects.toThrow('MCP tool scope denied');
expect(brain.tasks.create).not.toHaveBeenCalled();
// The tenant-scoping checks below sit behind the scope gate; exercise
// them with explicitly granted task-write scopes (how grant-mapped
// scopes will arrive), not with a platform role.
tools.clear();
const actor = createMcpActorContext({
userId: 'tenant-admin-user',
tenantId: 'tenant-a',
role: 'member',
scopes: [
...deriveMcpToolScopesForUser({ role: 'member' }),
MCP_TOOL_SCOPES.brain_create_task,
MCP_TOOL_SCOPES.brain_update_task,
],
});
service.registerTools(server, actor);
@@ -416,7 +466,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
);
});
it('keeps admin-only coordination tools on server-derived paths', async () => {
it('denies coordination tools to every role-derived actor and keeps the granted path server-derived', async () => {
const { service, coord } = makeService();
const { server, tools } = makeCapturingServer();
const member = makeMemberActor('authenticated-user');
@@ -433,10 +483,25 @@ describe('MCP actor identity and tool scope enforcement', () => {
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
// §1.1 retirement: platform-admin no longer derives coord scopes either.
tools.clear();
service.registerTools(server, platformAdmin);
const platformAdminTool = getTool(tools, 'coord_list_tasks');
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
await expect(platformAdminTool.handler({})).rejects.toThrow(
'MCP tool scope denied: coord:read',
);
// An explicitly granted coord:read scope reaches the server-derived
// path (caller-supplied projectPath is stripped by the schema).
tools.clear();
const grantedActor = createMcpActorContext({
userId: 'granted-user',
role: 'member',
scopes: [MCP_TOOL_SCOPES.coord_list_tasks],
});
service.registerTools(server, grantedActor);
const grantedTool = getTool(tools, 'coord_list_tasks');
await grantedTool.handler({ projectPath: '/tmp/victim' });
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
});
+19 -57
View File
@@ -63,20 +63,6 @@ interface SessionEntry {
actor: McpActorContext;
}
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
MCP_TOOL_SCOPES.brain_list_tasks,
MCP_TOOL_SCOPES.brain_create_task,
MCP_TOOL_SCOPES.brain_update_task,
MCP_TOOL_SCOPES.brain_list_missions,
MCP_TOOL_SCOPES.brain_list_conversations,
MCP_TOOL_SCOPES.memory_search,
MCP_TOOL_SCOPES.memory_get_preferences,
MCP_TOOL_SCOPES.memory_save_preference,
MCP_TOOL_SCOPES.memory_save_insight,
]);
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
@@ -89,15 +75,17 @@ const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.memory_save_insight,
]);
export function deriveMcpToolScopesForUser(input: {
/**
* Contract 2 §1.1: platform role confers NO MCP scope elevation — the
* former tenant-admin/global-admin scope sets keyed on users.role are
* retired. Every authenticated user receives the base member set; task
* writes and coordination scopes attach to explicit hierarchy grants when
* the MCP grant mapping lands, never to a platform role. The role
* parameter is kept for caller compatibility and deliberately ignored.
*/
export function deriveMcpToolScopesForUser(_input: {
role?: string | null;
}): ReadonlySet<McpToolScope> {
if (input.role === 'platform-admin' || input.role === 'super-admin') {
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
}
if (input.role === 'admin') {
return new Set(TENANT_ADMIN_MCP_SCOPES);
}
return new Set(MEMBER_MCP_SCOPES);
}
@@ -168,41 +156,22 @@ type TaskLike = TenantScopedLike & {
userId?: string | null;
};
function isGlobalAdminActor(actor: McpActorContext): boolean {
return actor.role === 'platform-admin' || actor.role === 'super-admin';
}
function isTenantAdminActor(actor: McpActorContext): boolean {
return actor.role === 'admin';
}
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
return (
record.tenantId === actor.tenantId ||
record.organizationId === actor.tenantId ||
record.teamId === actor.tenantId
);
}
/**
* Contract 2 §1.1: `users.role` confers NO content visibility — the former
* global-admin/tenant-admin filter short-circuits keyed on the platform role
* are retired along with the role-derived scope sets. Content reaches an MCP
* actor through ownership only; widened access arrives as explicit hierarchy
* grants when the MCP grant mapping lands.
*/
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
if (isGlobalAdminActor(actor)) return projects;
return projects.filter(
(project) =>
project.ownerId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
);
return projects.filter((project) => project.ownerId === actor.userId);
}
function filterMissionsByDirectActorScope<T extends MissionLike>(
actor: McpActorContext,
missions: T[],
): T[] {
if (isGlobalAdminActor(actor)) return missions;
return missions.filter(
(mission) =>
mission.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
);
return missions.filter((mission) => mission.userId === actor.userId);
}
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
@@ -293,7 +262,6 @@ export class McpService implements OnModuleDestroy {
}
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
}
@@ -302,8 +270,6 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
missions: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return missions;
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
const projectIds = new Set(
filterProjectsForActor(actor, projects).map((project) => project.id),
@@ -317,7 +283,6 @@ export class McpService implements OnModuleDestroy {
}
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
if (!mission) return false;
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
@@ -339,7 +304,7 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
refs: { projectId?: string | null; missionId?: string | null },
): Promise<void> {
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
if (!refs.projectId && !refs.missionId) {
throw new Error('MCP task scope denied');
}
await this.assertTaskReferencesAuthorized(actor, refs);
@@ -349,8 +314,6 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
tasks: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return tasks;
const [projects, missions] = await Promise.all([
this.brain.projects.findAll(),
this.brain.missions.findAll(),
@@ -367,7 +330,6 @@ export class McpService implements OnModuleDestroy {
return tasks.filter(
(task) =>
task.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
);
@@ -108,11 +108,13 @@ export class MissionsController {
) {
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
if (!mission) throw new NotFoundException('Mission not found');
// dto.status is deliberately not forwarded: mission_tasks.status is
// write-prohibited through the N-1 window (SHARED-CONTRACT §5.1 phase 1);
// the repo strips it as well.
return this.brain.missionTasks.create({
missionId,
taskId: dto.taskId,
userId: user.id,
status: dto.status,
description: dto.description,
notes: dto.notes,
pr: dto.pr,
+12
View File
@@ -77,6 +77,12 @@ export class CreateMissionTaskDto {
@IsUUID()
taskId?: string;
/**
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
* The field stays declared because the global ValidationPipe runs with
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
*/
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
@@ -102,6 +108,12 @@ export class UpdateMissionTaskDto {
@IsUUID()
taskId?: string;
/**
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
* The field stays declared because the global ValidationPipe runs with
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
*/
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
+62
View File
@@ -1,6 +1,18 @@
import 'reflect-metadata';
import { getMetadataStorage } from 'class-validator';
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
import {
ChangeCompanyVisibilityDto,
ChangeGrantDto,
CreateCompanyDto,
CreateEstateDto,
CreateGrantDto,
CreatePlatformProjectDto,
DeleteNodeDto,
RenameNodeDto,
TransferEstateDto,
TransferPlatformProjectDto,
} from './hierarchy/hierarchy.dto.js';
/**
* Boot-time self-check: the global ValidationPipe must be able to SEE the
@@ -43,6 +55,56 @@ export const PIPE_GUARDED_DTOS: Array<{
target: BootstrapSetupDto,
properties: ['name', 'email', 'password'],
},
{
name: 'CreateCompanyDto',
target: CreateCompanyDto,
properties: ['name', 'slug', 'idempotencyKey'],
},
{
name: 'RenameNodeDto',
target: RenameNodeDto,
properties: ['name', 'idempotencyKey'],
},
{
name: 'ChangeCompanyVisibilityDto',
target: ChangeCompanyVisibilityDto,
properties: ['visibility', 'idempotencyKey'],
},
{
name: 'DeleteNodeDto',
target: DeleteNodeDto,
properties: ['idempotencyKey'],
},
{
name: 'CreateEstateDto',
target: CreateEstateDto,
properties: ['companyId', 'name', 'slug', 'idempotencyKey'],
},
{
name: 'CreatePlatformProjectDto',
target: CreatePlatformProjectDto,
properties: ['estateId', 'name', 'slug', 'idempotencyKey'],
},
{
name: 'TransferEstateDto',
target: TransferEstateDto,
properties: ['destinationCompanyId', 'idempotencyKey'],
},
{
name: 'TransferPlatformProjectDto',
target: TransferPlatformProjectDto,
properties: ['destinationEstateId', 'idempotencyKey'],
},
{
name: 'CreateGrantDto',
target: CreateGrantDto,
properties: ['userId', 'targetKind', 'targetId', 'role', 'idempotencyKey'],
},
{
name: 'ChangeGrantDto',
target: ChangeGrantDto,
properties: ['role', 'idempotencyKey'],
},
];
export class PipeMetatypeCheckError extends Error {
+1
View File
@@ -14,6 +14,7 @@
| [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) | rc.16 direct-Drizzle current storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README commented/user-guide executable forms fail before masking and source-consistency rejects runner-delegation copy; held future bootstrap → TLS/roles → run → verify → readiness; plus prior production boundary, pgvector owner, attestation, inventory, manifests, DDL classifier, TLS/bootstrap, activation, and certification contract; foundation prerequisite of KBN-100 and real-role gate before KBN-105 |
| [`KBN-101-ENVELOPE-A.md`](./KBN-101-ENVELOPE-A.md) | KBN-101 Envelope A (v6) — RATIFIED, part of the frozen SSOT: rc.20 declarative sink-RBAC + per-role connection-selection + RLS `WITH CHECK`/`USING` write-source + `FORCE ROW LEVEL SECURITY` + sink-resident `task_status_write_override`; adds owner card KBN-101-10 + responsibility-widenings; authority Jason B1 + Mos OPTION A/Q1/Q2 |
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
| [`P0-MAP-CURRENCY-2026-08-29.md`](./P0-MAP-CURRENCY-2026-08-29.md) | REQ-MIG-001 lane-opening verification: SHARED-CONTRACT §5 field map re-verified byte-identical at `next` @ `abb0c936`; workspaces/audit-pattern refinements; measured `mission_tasks.status` writer inventory and the pre-expand stop-write work item |
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
@@ -0,0 +1,117 @@
---
kind: verification
status: active
---
# P0 Field-Map Currency Verification — 2026-08-29
**Purpose:** REQ-MIG-001 (native-kanban-sot.md §5) accepts only when "P0 publishes
the current `origin/main` field-by-field expand/backfill/compatibility/switch/contract
map before any schema lane starts." That map exists: [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md)
§5, inspected at `packages/db/src/schema.ts` @ `e72388b2cbfe400842fe940fa6cabf984ed43711`
(2026-07-13). The M4-3 schema lane (expand migration 0021+) now opens against the
integration trunk `next`. This document re-verifies the map's currency at the
lane-opening head and records the measured pre-expand writer inventory. It amends
nothing normative in SHARED-CONTRACT.md; where the two disagree, SHARED-CONTRACT.md
wins.
## 1. Currency verification (measured)
- Map pin: `e72388b2cbfe400842fe940fa6cabf984ed43711` (2026-07-13, `main`).
- Lane-opening head: `abb0c936011c7f6b8c0bcc90a20a865d5e8a40e9` (`origin/next`,
2026-08-29).
- Measurement: `git diff e72388b2 abb0c936 -- packages/db/src/schema.ts` reports
**300 insertions, 0 deletions** — no existing declaration changed.
- The additions: the new declarations `logicalAgentConnectorLeases`,
`connectorLeaseAuditLog`, and the hierarchy layer (`companies`, `estates`,
`platformProjects`, `workspaces`, `hierarchyGrants`, `hierarchyAuditEvents`,
`hierarchyOutbox`, plus their enums and constant arrays); a nullable `issuer`
column on the unmapped BetterAuth `accounts` table (shipped as
`drizzle/0017_accounts_issuer.sql`); and expanded `drizzle-orm` imports
(`sql`, `AnyPgColumn`, `unique`, `check`, `bigint`). None touch a mapped
source.
- Stronger literal fact: REQ-MIG-001's acceptance names `origin/main`. Measured
pin → `origin/main` (`7102ccb9`, 2026-08-13): **63 insertions, 0 deletions**
for `schema.ts`, and `origin/main` is an ancestor of `abb0c936`. The map is
therefore current at `origin/main` itself, and at the trunk head beyond it.
**Consequence:** every source column mapped in SHARED-CONTRACT.md §5.4 —
`teams`/`team_members`, `projects`, `missions`, `tasks`, `mission_tasks`,
`agents`, fleet `backlog` — is byte-identical to the declaration the map
inspected. The field map is current as written. No row changes.
## 2. Refinements available since the pin (context, not map changes)
1. **The `workspaces` table exists.** The map predates contract 1's hierarchy
layer; its "bootstrap workspace" backfill step now has a shipped target:
`workspaces` (uuid PK, chained under platform projects per
`docs/requirements/hierarchy-schema.md`; hierarchy core in
`drizzle/0018_clean_cobalt_man.sql`, audit/outbox in
`0019_volatile_killraven.sql`, visibility in
`0020_special_betty_brant.sql`). New `workspace_id` columns FK there.
2. **The audit/outbox envelope pattern is shipped.** `hierarchyAuditEvents` +
`hierarchyOutbox` implement same-transaction semantic event + outbox. The
task lane's `task_events`/`task_outbox` mirror the pattern but are
workspace-scoped with the composite `(workspace_id, id)` key required by
§5.3 and REQ-SOT-004. The hierarchy tables are a pattern reference, never a
shared store for task events.
3. **Trunk designation.** The integration trunk is `next` (`.mosaic/repo.json`).
§1 measures currency at both the literal `origin/main` REQ-MIG-001 names and
the trunk head pinned above, so no reinterpretation of the acceptance text
is needed.
4. **Migration ownership.** SHARED-CONTRACT.md §6 assigns schema/migration
ownership to the mission seat `coder2`. Seat identity is operational fleet
state, not resolvable from this repository, and is outside this document's
scope. The invariant §6 protects binds regardless of seat and is restated
here as binding on the M4-3 schema lane: exactly one lane generates
migrations at a time; expand is additive; no drop/rename/narrow; constraints
validate before NOT NULL.
## 3. Pre-expand writer inventory (measured 2026-08-29 at `abb0c936`)
SHARED-CONTRACT.md §5.1 phase 1 requires an N-1 patch that stops
`mission_tasks.status` as a write source, plus a writer inventory, before any
expand DDL.
- **Sole authoring write path:** `packages/brain/src/mission-tasks.ts`
`create`/`update` (Drizzle insert/update on `mission_tasks`), invoked by
`apps/gateway/src/missions/missions.controller.ts`. `update` accepts
`Partial<NewMissionTask>`, so `status` is writable through both DTOs today.
The same module also exposes `remove`/`removeByMission` DELETE paths —
immaterial to `status` writes, listed for inventory completeness.
- **Storage-layer surfaces that touch the column without authoring it**
(added 2026-08-29 after independent review of the phase-1 patch):
`packages/storage/src/migrate-tier.ts` copies whole `mission_tasks` rows
between storage tiers and must preserve the stored `status` verbatim — row
transport, exempt from the write prohibition (stripping there would corrupt
data inside the N-1 window). The generic table-keyed storage adapters
(`adapters/postgres.ts`, `adapters/pglite.ts`) register `mission_tasks` in
their table maps but have no caller that targets it: measured at this head,
every runtime adapter caller passes a fixed collection constant
(preferences/insights). Neither surface authors a new `status` value.
- **Read-only consumers of `mission_tasks`:** federation verb services
(`get-query.service.ts`, `list-query.service.ts`) select only. The MCP
`brain_*` tools do not touch `mission_tasks` at all; `brain_create_task` /
`brain_update_task` write the separately mapped `tasks` table, a legitimate
N-1 writer through the compatibility window.
- The ratified contract 5 decision
(`docs/requirements/tool-gateway-mapping.md` §3.2, ruled 2026-08-27) freezes
the legacy endpoints — including MCP `brain_*` task mutations — for new
consumers, while existing consumers keep working until each surface's owning
contract retires it. It does not stop existing writes.
**Standing work item:** the phase-1 stop-write patch (reject or ignore `status`
on `mission_tasks` create/update) MUST land before the expand DDL of migration
lane M4-3a. It is N-1-safe per the §5.4 row for `mission_tasks.status` (linked
status is ignored; the column stays declared and readable through the whole
N-1 window; retirement only after no readers).
## 4. Lane opening
With this verification merged, REQ-MIG-001's P0-map precondition is satisfied
for the M4-3 schema lane at pinned head `abb0c936`. The ordered phases (§5.1),
mission candidate-key DDL order (§5.2), audit/proposal DDL order (§5.3), field
map (§5.4), and required migration tests (§5.5) bind as written. External
import machinery (jarvis-brain/Vikunja shadow import, REQ-MIG-001) and client
cutover (REQ-MIG-002) remain out of scope for M4-3; the legacy surface stays
frozen for new consumers meanwhile (`tool-gateway-mapping.md` §3.2 decision).
+279
View File
@@ -0,0 +1,279 @@
# Deployment Mode and Conversion Contract (D3)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 6 of 9).
Authority: PRD D3 (Part I §3) — two modes chosen at install time,
Standalone and Enterprise, with the mode table (brains, user-data
isolation, secrets, conversion); Standalone → Enterprise conversion is
**one-way** and Enterprise is a **terminal state**. PRD D14 (Part I §7)
— the per-user brain split is optional in Standalone and keeping it is
the recommended default because it preserves forward-compatibility with
the one-way conversion. PRD D11 (Part I §9) — v1 ships the Standalone
flow only; Enterprise conversion is explicitly deferred. PRD D3
federation clause — federation is intentionally not fully designed,
deferred, and nothing in v1 may foreclose it.
Revision 2 (luna review F1F7): the identity precondition restated in
identity-contract terms with a conversion-local acknowledgment record
this contract owns (F1); a durable, keyed preparation state with a
Standalone-safe representation rule, an in-transaction re-check fence,
and an exact flip boundary (F2); the §5.4 unknown-value rule stated
directly without the contradictory non-exhaustiveness clause (F3); the
D14 boundary bound here with a stable column-allowlist witness instead
of delegated to an unratified layout (F4); the conversion witness
matrix extended to every §4.2/§4.4 condition (F5); the mode-record
writer coverage imported concretely from contract 1 §6.3 with a named
schema, closed writer set, crafted-write probe, and mode-resolution
assertion (F6); the mode read command flagged as a §12.1 drafting
addition rather than a D8 mandate (F7). Ownership language aligned
with contract 3 revision 2: mode is recorded at bootstrap and read by
the wizard as input.
This contract binds the mode as a canonical platform property (§2), the
per-mode obligations and which contract owns each (§3), the conversion
transition (§4), the v1 non-foreclosure obligations (§5), and their
witnesses (§6). Domain semantics stay with their owning contracts:
wizard branching (contract 3 §2), identity/SSO
(`identity-lifecycle.md`), custody and per-user brain mechanics
(contract 7, `custody-schema.md`), tool mapping
(`tool-gateway-mapping.md`).
## 1. Definitions
1. **Mode**: the platform-wide deployment mode, exactly one of
`standalone` or `enterprise`. The vocabulary is closed in v1;
extension (e.g. a federation mode) is by amendment to this contract,
never ad hoc.
2. **Conversion**: the one-way transition `standalone → enterprise`.
No other mode transition exists.
3. **Conversion preconditions**: the verifiable conditions of §4.2 that
must all hold before the mode record may change.
4. **Preparation unit**: one re-runnable piece of pre-conversion work —
the migration of one secret to the Vault backend, or the partition
of one user's brain content (§4.3).
## 2. Mode is a canonical recorded property
1. Mode is recorded canonically in the platform database at bootstrap
as the operator's install-time choice (D3: modes are "chosen at
install time"). The record is a single-row keyed record
(`platform_mode`: mode value, recorded-at timestamp, bootstrap epoch
reference); this contract owns it, the bootstrap writer performs the
one v1 write (§6.2), and the wizard reads it as input (contract 3
§2.3). Mode is never derived from feature state (presence of Vault,
count of brains, count of users), and no component may infer a
different mode than the record states.
2. The record is readable by any authenticated user through a Gateway
command with CLI exposure. This read command is a **drafting
addition** ratified with this contract (PRD §12.1), not a D8
mandate: D8 binds only that any surface exposing the value goes
through official tooling. When a webUI surface consumes the read, a
mapping row is added to `tool-gateway-mapping.md` by amendment —
the same route §4.4 already binds for the conversion command.
Components branch on the read value only.
3. The record is immutable except by the §4 conversion transition.
Editing it by direct database access, config file, environment
variable, or wizard re-run is non-conformant (contract 3 §2.3:
changing mode later is conversion, not a wizard re-run).
## 3. Per-mode obligations (owner map)
The PRD mode table binds four rows; this contract assigns each an
owning contract so no obligation is unowned and none is bound twice:
| Obligation | Standalone | Enterprise | Owner |
| ------------------- | -------------------------------------- | -------------------------------------------------- | --------------------------------------------- |
| Brains | one mosaic-brain (system + user files) | system brain for config + one brain per user | contract 7 (custody/brain mechanics) |
| User-data isolation | single user | no user-data leakage between users; sharing opt-in | contract 7 (enforced by architecture, D14) |
| Secrets | OpenBao/Vault or flat files | OpenBao/Vault REQUIRED | this contract (§4.2 gate; steady-state check) |
| Conversion | may convert to Enterprise, one-way | terminal state | this contract (§4) |
The Standalone brains row states the default layout, not the only
valid one: the D14 per-user split is a MAY in Standalone with keeping
it the recommended default (PRD §7, contract 7 §6), and Vault-backed
secrets are equally valid Standalone configuration. Both prepared
states are therefore themselves valid Standalone states — the fact
§4.3 relies on.
In Enterprise steady state, a flat-file secrets backend is
non-conformant; the platform refuses to start Enterprise-mode
components against a flat-file secrets configuration (fail-closed, not
warn-and-run).
## 4. Conversion transition
1. **Direction and terminality.** The only transition is
`standalone → enterprise`. `enterprise → standalone` does not exist:
there is no command, no admin override, and no support path. An
attempt is refused with the precondition/state error class of the
command envelope (`tool-gateway-mapping.md` §4.2).
2. **Preconditions (all verified before the record changes):**
- Secrets: OpenBao/Vault is configured and reachable, and every
required secret is served from the Vault backend — none from a
flat-file backend. Secret migration completes before conversion;
this contract does not define the migration tooling, only the
gate.
- Brains: the per-user brain split required by the Enterprise row of
§3 is established for **every** existing user (or the deployment
already kept the split, the D14 recommended default). Brain
partitioning mechanics are contract 7; this contract binds only
that the split is complete before the mode flips.
- Identity: at least one platform administrator account exists that
is active in identity-contract terms — authenticated capability,
not banned, not deactivated (identity §2, §5). And the conversion
request carries a **configuration acknowledgment**: the current
canonical values of registration mode and per-provider JIT
enablement (identity §2.2, §4.1), echoed back in the request. A
mismatch between the echoed values and the canonical values at
verification refuses the conversion. This acknowledgment record
is conversion-local, owned by this contract, and stored with the
§4.4 audit event as the precondition evidence; it adds no
identity-contract obligation and no mode-specific identity
default — identity's own defaults remain valid states.
3. **Preparation state and the flip boundary.** Preparatory work is
tracked durably: each preparation unit (§1.4) records its
completion in a preparation table keyed by (bootstrap epoch, unit
identity — the secret's path, the user's id), written in the same
transaction as the unit's own effect where the unit's backend
allows it, and reconciled from the backend's actual state where it
does not (a secret already served by Vault, a brain already split,
is complete regardless of the table). Units are at-most-once per
key and re-runnable across attempts. **Standalone-safe
representation:** every preparation unit moves the deployment into
a state that is itself valid Standalone configuration (§3 note), so
an interrupted preparation leaves a fully operational Standalone
deployment reading its state through the ordinary contracts — no
rollback, fencing, or special Standalone read path is needed, and
no component behavior may key on "preparation in progress".
**The flip:** one transaction that (a) locks the mode record, (b)
re-verifies every §4.2 precondition after acquiring the lock, and
(c) writes the mode record and the §4.4 audit event. Any re-check
failure aborts with no write. External state that changes after the
re-check but before commit is bounded by the transaction window;
an external backend (Vault) failing after conversion is an
Enterprise runtime fault handled by §3's fail-closed steady-state
rule, not a conversion defect. An interrupted or failed conversion
leaves the record `standalone` and the platform fully operational;
there is no intermediate mode and no half-converted state
observable through the record.
4. **Authority and audit.** Conversion is a platform-administrator
command carrying an explicit irreversibility acknowledgment in its
request (distinct from the §4.2 configuration acknowledgment). It
is an official Gateway/CLI command (D8): when built, it is added to
the tool↔Gateway mapping by amendment (`tool-gateway-mapping.md`
§3.3). The transition emits an audit event (actor, prior mode, new
mode, precondition evidence reference including the configuration
acknowledgment) in the same transaction as the record change; the
event survives indefinitely. A refused attempt emits a refusal
event naming the failed precondition class and actor, with no
mode-change event.
## 5. v1 obligations (non-foreclosure)
v1 ships Standalone only (D11); the conversion command is deferred
work. v1 still MUST:
1. Record the mode per §2 at bootstrap, with `enterprise` a reserved,
refused value for bootstrap — v1 bootstrap accepts `standalone`
only. The wizard reads the record (contract 3 §2.3); nothing in v1
writes it after bootstrap.
2. Keep the §2.3 immutability rule: no v1 surface mutates the mode
record.
3. Not foreclose conversion: the v1 platform database holds no
sensitive user content — sensitive categories live in the owning
user's brain, and postgres holds structure, consent records, and
pointers only (the D14 boundary, PRD §7). Custody mechanics are
contract 7's; this contract binds the boundary itself here so v1
cannot ship a layout that makes the §4.2 brain precondition
unsatisfiable, and §6.3 gives it a stable witness that does not
depend on contract 7's internals. Conversion implementation
additionally requires contract 7 ratified.
4. Not foreclose federation: v1 components accept exactly the two §1.1
values wherever a mode value is parsed and refuse any other value
**before side effects** — a refused configuration, not undefined
behavior and not a crash mid-operation. Forward compatibility lives
in storage and architecture, not in parser speculation: the mode
record's storage is not structurally locked to two values (no
database-level two-value enum), and any future value (e.g. a
federation mode) is defined by a versioned amendment to this
contract before any component accepts it. The PRD defers
federation's shape entirely; this contract does not presume it
arrives as a third mode value.
## 6. Verification requirements
Binding on the implementing PRs:
1. **Mode-record witness (v1):** after bootstrap the mode is readable
via the Gateway command and CLI and equals the bootstrap-recorded
choice; bootstrap with mode `enterprise` is refused; bootstrap with
any unknown mode value is refused before side effects (§5.4).
2. **Writer-coverage witness (v1):** the mode record's writer set is
closed by the same three-prong static assertion contract 1 §6.3(b)
defines — symbol, class-table literal, and raw-execution prongs
with its allowlist composition rules — scoped to the
`platform_mode` table, with a writer allowlist containing exactly
the bootstrap writer in v1 (and exactly plus the conversion command
at the conversion milestone). Companions: a crafted direct write
attempted in a test fails and leaves the record unchanged; a
mode-resolution assertion that no shipped component derives mode
from feature state (mode reads occur only through the §2.2 read
surface — static assertion over Gateway, CLI, bootstrap, and
repository sources).
3. **D14-boundary witness (v1):** a column-allowlist assertion in the
style of contract 1 §6.2 that the platform database schema contains
no sensitive-content column — the §5.3 boundary — stable regardless
of contract 7's internals (contract 7 §7 carries the full custody
witnesses).
4. **No-downgrade witness (conversion milestone):** with mode
`enterprise`, a conversion request to `standalone` (and any crafted
mode-write) is refused with the precondition/state error class and
no record change.
5. **Precondition witnesses (conversion milestone),** each refused
with no record change and no partial mode effect, parameterized
over both OpenBao and Vault where secrets are involved:
(a) secrets backend unreachable; (b) one required secret still
flat-file backed (migration incomplete); (c) one unpartitioned user
brain in a **multi-user** deployment where every other user is
partitioned; (d) no active platform administrator (the only admin
banned or deactivated); (e) configuration acknowledgment missing or
mismatching the canonical registration/JIT values; (f) actor not a
platform administrator (authorization refusal); (g) irreversibility
acknowledgment absent. And the steady-state rule: an
Enterprise-mode component started against a flat-file secrets
configuration refuses to start (§3).
6. **Interruption and fence witnesses (conversion milestone):** fault
injection aborting conversion after each preparation unit and
between preparation and flip leaves the record `standalone` and the
platform operational in Standalone semantics (§4.3
Standalone-safety), and a re-attempt completes without duplicating
prepared state (at-most-once keys); a precondition invalidated
after preparation but before the flip (a secret reverted to
flat-file) is caught by the in-transaction re-check and refused.
7. **Audit witnesses (conversion milestone):** a completed conversion
has exactly one mode-change audit event, same-transaction with the
record change (transaction linkage asserted), carrying actor, prior
mode, new mode, and the precondition evidence reference including
the configuration acknowledgment; a failed attempt has a refusal
event naming the failed precondition class and no mode-change
event; the mode-change event remains queryable after subsequent
unrelated audit activity (retention probe).
8. **Mapping witness (conversion milestone):** the conversion command
and the mode read command each have their
`tool-gateway-mapping.md` row (added by amendment per §2.2/§4.4)
before the commands ship.
## Ruling request
Ratify sections 16 as written, with one decision embedded:
- Decision (§5): v1 implements the **mode record and its immutability
only** — bootstrap records `standalone`, the `enterprise` value is
reserved and refused, and the conversion command itself is deferred
to the Enterprise milestone, consistent with D11's deferred list.
v1 carries three obligations beyond the record: the closed writer
assertion, the D14 column boundary, and the unknown-value refusal
(§6.1–§6.3) — these are the non-foreclosure floor, not hidden
conversion work. Alternative if rejected: build the conversion
command inside v1 — rejected because D11 scopes v1 to the Standalone
slice and conversion depends on contract 7 custody mechanics that
are themselves not in the v1 slice.
+44
View File
@@ -514,3 +514,47 @@ itself permits, so that contract does not stretch A1 by interpretation.
default-open disclosure.
5. Every other constraint of A1 — §8.1.2's remaining bullets, §8.2 in full,
and §8.3's other acceptance criteria — is untouched.
## 10. Amendment A3 — capability-holder existence disclosure
**Status:** amendment to Amendment A2, added by reviewed PR together with
contract 2 Amendment 1 (`rbac-grant-model.md` §8, this PR), under that
amendment's ruling request (decision owner Jason). It binds if and only if
contract 2 Amendment 1 ratifies; until then §9.1.2's sole-disclosure rule
stands unmodified — which is consistent, because until ratification the
company-CRUD capability class is empty and the carve-out below has no
holders. Everything in §§19 remains binding verbatim, with exactly the one
express modification below. The detailed contract text lives in
`rbac-grant-model.md` §8.1; this amendment changes only what A2 itself
permits, so that contract does not stretch A2 by interpretation.
### 10.1 What A3 modifies in A2
1. **Capability-holder disclosure (narrows §9.1.2's sole-disclosure rule
by one carve-out).** §9.1.2 makes the directory the sole permitted
existence disclosure and keeps private companies undisclosed to
non-granted subjects everywhere. A3 admits exactly one further
disclosure channel: a subject holding the company-CRUD capability
(contract 2 §8), when exercising the hierarchy schema §5.5 visibility
command, learns the target company's existence and its old/new
visibility values through the command's redacted actor receipt —
success for an existing target (private or directory alike) versus
`not_found` for a nonexistent id — bounded exactly as contract 2 §8.1
states: no name, slug, structure, content, grant, or membership
information, and no read command of any kind. To every other
non-granted subject, private companies remain undisclosed everywhere,
including the directory; the directory remains the sole
existence-disclosure _listing_.
### 10.2 What A3 explicitly does not change
1. The directory itself is unchanged: read-only, directory-class companies
only, existence/name/slug only (§9.1.2's enumeration is narrowed for
capability holders' receipts, widened for nothing).
2. No join-request surface, no curation listing, no read command of any
family is authorized (§9.2.2 unchanged; a curation listing is a further
amendment per contract 2 §8.1).
3. The canonical audit event for visibility mutations is untouched — it
keeps hierarchy schema §5.2's full immutable target snapshot; the
capability confers no audit read (contract 2 §8.5).
4. Every other constraint of A1 and A2 is untouched.
+219
View File
@@ -24,6 +24,26 @@ fail-closed-fault, and existence-oracle observables added (§7);
role-string namespacing rule added (§4.5); ruling request now names the
interpretive resolution of PRD "admins".
Amendment 1 (company-CRUD capability): defines the capability class that
contract 1 Amendment 1 (Ruling 4b, 2026-08-28) and hierarchy schema §5.5
anticipate. §8 defines the capability as a platform-scoped, admin-assigned,
audited delegation of exactly the hierarchy schema §5.5 company visibility
command — no read command, no other company operation; the mutation's
inherent existence disclosure is ratified as a bounded carve-out to
hierarchy schema §6.7/§2.8 and to kanban SOT Amendment A2's
sole-disclosure rule — SOT Amendment A3 (native-kanban-sot.md §10, this
PR) expressly extends A2 by exactly this carve-out (§8.1). The holder
sees only a redacted actor receipt; the canonical audit event keeps
hierarchy schema §5.2's full immutable snapshot. The hierarchy role
vocabulary (§2), every evaluation rule (§3), and grant management (§4) are
untouched: the capability is not a `hierarchy_grants.role` value and
evaluates outside the chain; capability-row deletion joins §6.1's
revocation enumeration (§8.4). Until this amendment ratifies, the capability
class is empty and
the visibility command remains admin-only (hierarchy schema §5.5 states
this fallback; the shipped gate at
`apps/gateway/src/hierarchy/hierarchy.repository.ts` implements it).
Scope: the roles that can appear in `hierarchy_grants.role`, what a grant at
each hierarchy level confers, how grants evaluate down the chain, how
revocation propagates, and who may manage grants. Out of scope: the hierarchy
@@ -242,6 +262,191 @@ contract 1 §6):
8. Transfer: both-sides `owner` accepted, each single-side case refused
(completing contract 1 §6.5).
## 8. Company-CRUD capability (Amendment 1)
Hierarchy schema §5.5 authorizes the company visibility mutation for
exactly two actor classes: platform admins and "subjects holding the
company-CRUD capability that a follow-up amendment to contract 2 will
define". This section is that definition. The name is historical — coined
in contract 1 Amendment 1 before the capability's content was fixed — and
confers nothing by connotation: the ratified content is exactly §8.1.
Company _creation_ is already ruled open to active users and always
private (contract 3 §5.2, Ruling 4); rename, delete, and transfer of
companies remain hierarchy `owner` operations (§2.3, §5); none of those is
part of this capability, and widening it to any other operation is a
further amendment, not an implementation decision.
1. **Content: exactly one command, no read command, disclosure stated.**
Holding the capability authorizes executing the hierarchy schema §5.5
visibility command (`companies.visibility`, both directions:
`private → directory` and `directory → private`) on any company in the
deployment, and no other command of any family. It confers **no read
command**: no company enumeration, no curation listing, no structure
read. The practical flow this implies is deliberate: to publish a
private company, the holder is given the target identifier by the
requesting company `owner` out of band; to unpublish, the target is
already directory-listed. A curation listing for capability holders,
if ever wanted, is a further amendment with its own disclosure
analysis under hierarchy schema §6.7.
**Existence disclosure carve-out, stated rather than pretended away:**
exercising a mutation inherently discloses its target's existence.
The command's result distinguishes an existing company (success, for
private and directory targets alike) from a nonexistent id
(`not_found`), so a holder presenting candidate ids learns existence —
exactly as a platform admin already does through the same command.
This amendment ratifies that disclosure as part of the §5.5 curation
authority, bounded as follows. The holder-visible surface is the
command's **actor receipt** — the mutation result payload, carrying
exactly the target id, old visibility, and new visibility, and
**nothing else**: no name, slug, structure, content, grant, or
membership information. The actor receipt is a redacted projection
distinct from the **canonical audit event**, which is unchanged by
this amendment: it keeps hierarchy schema §5.2's deletion-safe
immutable target snapshot (id, slug, and parent chain at event time)
in full. The two never converge on the holder: the capability confers
no audit read (§8.5), so the canonical event — and with it the slug
and parent chain — is reachable only by subjects independently
authorized to read audit data, never through this capability. A
successful publish additionally makes the target directory-listed to
every authenticated user; that is the command's ratified purpose
(hierarchy schema §5.5), not a leak. Hierarchy schema §6.7's
existence-oracle rule and §2.8's directory-only disclosure are amended
by exactly this carve-out for capability holders, kanban SOT Amendment
A3 (native-kanban-sot.md §10, this PR) expressly extends A2's
sole-disclosure enumeration by the same carve-out, and all three are
otherwise untouched. Witnessed in §8.6.3.
2. **Holding: platform-scoped assignment, user subjects only.** The
capability is not a hierarchy grant: it attaches to no node, has no
role, and never enters §3 chain evaluation. It is held via a
`platform_capabilities` table whose column set is exactly (nothing
else, per the contract 1 §2.7 exhaustiveness discipline):
- `id` — uuid, primary key;
- `user_id` — text, NOT NULL, FK `users` **ON DELETE RESTRICT**;
- `capability` — text, NOT NULL, constraint-checked against exactly
`company_crud`;
- `granted_by` — text, NOT NULL, FK `users` **ON DELETE RESTRICT**;
- `created_at` — timestamptz, NOT NULL;
- UNIQUE (`user_id`, `capability`).
The user FKs are **text**, not uuid, because `users.id` is a BetterAuth
text key (`packages/db/src/schema.ts`; custody schema records the same)
— PostgreSQL cannot reference a text primary key with a uuid column.
This matches the shipped `hierarchy_grants` shape exactly: uuid
surrogate `id`, text FKs to `users`.
Both user FKs are RESTRICT for the same reason contract 1 §3.3 pins
RESTRICT on principal FKs: the identity contract (§7.3) gates user
deletion, and a cascade here could silently destroy a capability
without its §8.3 revocation audit event. Revocation is row deletion
through the §8.3 command — there is no other removal path, no expiry
column, and no tombstone. A deactivated holder confers nothing while
deactivated: identity contract §7.1 denies all authorization to
deactivated accounts, and the §8.4 predicate evaluates on the
authenticated live user. No team subjects (§1.4's suspension reasoning
applies with more force here — a workspace-bound team holding
deployment-wide curation authority has no ratified meaning).
3. **Assignment is instance administration on the normal admin surface.**
Only platform admins (`users.role = 'admin'`) may assign or revoke the
capability, through an ordinary admin command (the same command class
`AdminGuard` governs, §1.1) — not through direct table writes.
Assignment delegates a slice of instance administration and is itself
an instance-administration act under §1.1. A capability holder as such
may NOT assign or revoke it (no self-propagation). Every assignment
and revocation is a semantic audit event carrying actor, verb, subject
user, and capability; serialized capability strings are namespaced per
§4.5 (`platform-capability:company-crud` — a bare `company_crud` in
any serialized artifact is non-conformant).
4. **Evaluation and revocation follow this contract's existing rules.**
The hierarchy schema §5.5 command's authorization predicate is:
`users.role = 'admin'` OR a live `platform_capabilities` row
(`user_id`, `company_crud`). Both disjuncts are evaluated live and
fail closed per §3.5 — **independently**: with capability state
unreadable (fault), the capability disjunct denies, but a platform
admin whose `users.role` is readable remains authorized through the
admin disjunct; with role state unreadable, the admin disjunct denies
likewise. A decision that can read neither denies. Capability-row
deletion is hereby added to §6.1's enumerated revocation paths:
it propagates identically, under §6.2's bound, on every transport —
no new HTTP/MCP command authorized by the deleted row after the
revoking transaction commits, and any cached authorization is
invalidated in the revoking transaction (§3.5).
5. **What it does not confer**, stated so implementing PRs cannot drift:
no hierarchy grant or effective role at any node; no workspace
authorization or membership; no content, structure, or roll-up read;
no grant management (§4.1 unchanged); no MCP scope; no other instance
administration (user management, system settings, provider
configuration remain platform-admin-only); no company create, rename,
delete, or transfer. Hierarchy schema §5.5's rule that a company
`owner` as such may NOT change visibility is unchanged — `owner` and
this capability are disjoint authorities that combine only by a
subject holding both.
6. **Verification requirements** (extends §7, binding on implementing
PRs):
1. Schema witnesses (real PostgreSQL, `ci-postgres` service in the
`test` CI step): the `capability` CHECK constraint rejects any
value outside `company_crud`; NOT NULL enforced on every declared
NOT NULL column; UNIQUE (`user_id`, `capability`) rejects a
duplicate; both user FKs reject a dangling reference AND deleting a
referenced user is refused (RESTRICT witnessed in both directions);
the table's column set is exactly the §8.2 declared set (contract 1
§6.2 discipline).
2. Capability-only command matrix — the witness that proves "exactly
one command", not merely "at least one": a non-admin holder with no
other grants succeeds on the visibility command in **both**
directions with contract 1 §5.2's audit event (old and new values
as semantic content), and the **same** actor is refused, case by
enumerated case: every hierarchy mutation family (company/child
create under another's node, rename, delete, transfer); grant
create/change/revoke; the workspace read and write command
families; roll-up reads; structure reads — including the
not-found-indistinguishable refusal on a structure read of the very
company they just mutated (hierarchy schema §6.7); every
instance-administration surface other than the visibility command
(user management, system settings, provider configuration, and
capability assign/revoke itself); and MCP scope derivation yields
nothing — the §7.4 deny-by-default matrix gains this row. Company
creation compares against an eligible-user baseline: the holder's
create behaves exactly as any active user's — always `private`,
and a creation request carrying a visibility argument is refused
for holder and baseline alike (contract 3 §5.2).
3. Disclosure bound (§8.1 carve-out witnessed, receipt and canonical
event separately): the mutation result for a private-valid target,
a directory-valid target, and a nonexistent id is exactly {success,
success, `not_found`}; the actor receipt for a success carries
exactly {target id, old visibility, new visibility} and no result
or error payload carries name, slug, structure, content, grant, or
membership data; the canonical audit event for the same mutation —
asserted directly against the hierarchy outbox, not through any
holder-facing surface — carries hierarchy schema §5.2's full
immutable snapshot (id, slug, parent chain); and the holder's
attempt to read audit data is refused (no audit read conferred,
§8.5), proving the receipt/event separation reaches the holder as
a redaction, not a weakened event.
4. Assignment path, both polarities: a platform admin assigns and
revokes through the normal admin command (positive witnesses —
assign then observe the §8.6.2 allow, revoke then observe deny); a
non-admin — including a current capability holder — is refused
assign and revoke; every assign/revoke produces its audit event
with the namespaced string (§8.3); a direct-write path that skips
the command surface is non-conformant (the §8.3 command is the only
writer of `platform_capabilities`).
5. Revocation joins the §7.6 matrix: assignment is decision-time-live
(capability assigned → the holder's next visibility command allows,
no re-login); after row deletion, the ex-holder's next visibility
command is refused **on every exposed transport**, measured with
the revocation and the decision on distinct physical connections; a
cached-authorization implementation proves transactional
invalidation (§3.5). Fail-closed fault witnesses, both disjuncts
(§8.4): with `platform_capabilities` unreadable, a non-admin holder
is denied while a platform admin remains authorized; with role
state unreadable, the admin disjunct denies.
6. Owner-as-such refusal re-witnessed: hierarchy schema §6.9's
owner-cannot-publish witness re-asserted with the
`platform_capabilities` table present and empty for that owner.
## Ruling request
Ratify sections 17 as written, with one decision embedded and one
@@ -257,3 +462,17 @@ interpretive resolution named:
platform admins. A1 §8.1.3 does not attribute grant declaration to
platform admins, and the §1.1 decision above is what makes this reading
binding.
## Ruling request (Amendment 1)
Ratify §8, the Amendment 1 header note, and kanban SOT Amendment A3
(native-kanban-sot.md §10 — the express A2 carve-out extension, which
binds only with this ratification) as written, with one decision
embedded:
- Decision: the company-CRUD capability is a platform-scoped,
admin-assigned, audited delegation of exactly the hierarchy schema §5.5
visibility command — no read command, no other company operation, with
the mutation's inherent existence disclosure ratified as a bounded
carve-out (§8.1). Say "agreed" or name the additional operations (or
the curation listing) you want it to carry.
+197
View File
@@ -0,0 +1,197 @@
# Tool↔Gateway Mapping Contract (D8)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 5 of 9).
Authority: PRD D8/D12 (Part I §8) — the webUI sits OVER official tooling:
every webUI operation goes through the Gateway API backed by the same
official framework tooling the CLI uses, and a webUI operation with no
backing tool is scored **blocked on tooling** and the tool is built
first. Measured input: the webui-audit A5 tooling baseline
(operation-by-operation inventory of the current Gateway surface and the
P1 gaps, cross-reviewed; `fleet/lanes/webui-audit/findings/
A5-tooling-baseline.md` in the estate brain). The T10 ruling adopted the
targeted-update plan including building the D8 tools in A5's rank order.
Revision 2 (GLM review F1F5): the §2 table completed against an
independent re-measurement of the live `apps/web` surface (mission
reads, coordination status, capability-gated `turn:send` added); rank-6
composition corrected to ranks 1 and 4; SOT citations corrected to §3
invariant 11 / REQ-TASK-001 / §5+A1; the §3.2 retirement clause
softened to match what the owning contracts actually schedule; §6.1
scoped to outbound calls with an extractability lint, and §6.3 given
static companions for §4.1 and §4.3.
This contract binds three things: the operation→tool mapping itself
(§2–§3), the command envelope every mapped operation satisfies
(§4), and the process rule that keeps the mapping closed (§5). Domain
semantics stay with their owning contracts — hierarchy (contract 1,
`hierarchy-schema.md`), grants (contract 2, `rbac-grant-model.md`),
wizard (contract 3, `onboarding-wizard.md`), identity
(`identity-lifecycle.md`), kanban lifecycle (`native-kanban-sot.md`
§5 and Amendment A1), roll-up (contract 8), API artifact format
(contract 9).
## 1. Definitions
1. **Official tool**: a command implemented in the framework packages and
exposed through the Gateway API; the CLI remains the primary execution
method for the same command (D8). The webUI is a Gateway client only.
2. **Mapped operation**: a webUI operation with a named official path in
§2 or §3. Anything else the webUI wants to do is unmapped and follows
§5.
3. **Legacy non-substitute**: an existing endpoint that resembles a P1
need but is contractually barred from backing it (§3.2).
## 2. P0 mapping (current operations, ratified as-is)
This table is the complete measured P0 surface: every Gateway call the
web app's production sources make at this revision's head appears as a
row (independently re-measured at review; the three calls the first
measurement missed — mission reads, coordination status, and the
capability-gated `turn:send` emit — are rows below). The surface stays
bound to these paths:
| WebUI operation | Official path |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Register / log in / log out / OIDC callback | better-auth mount `/api/auth/*`; `GET /api/sso/providers` |
| List/show projects (legacy read) | `GET /api/projects`, `GET /api/projects/:id` |
| List tasks / task detail (legacy read) | `GET /api/tasks`, `GET /api/tasks/:id` — with the filtered legacy project/mission reads the same surfaces use |
| Mission list (legacy read) | `GET /api/missions` |
| Coordination status (legacy read) | `GET /api/coord/status` |
| Conversation CRUD/search/messages | `/api/conversations*` |
| Chat turn / stop / thinking / command execute+approve / streaming | `/chat` socket events `message`, `abort`, `set:thinking`, `command:execute`, `command:approve`; `turn:send` (capability-gated — emitted only when the server advertises the pi turn-runtime capability, which the current Gateway does not) |
| Harness/model selection | `GET /api/harnesses*`, `GET/PUT /api/chat/preferences/selection` |
| Preferences; provider inspect/test | `/api/memory/preferences`, `GET /api/providers`, `POST /api/providers/test` |
| Admin users / roles / ban / health | `/api/admin/users*`, `/api/admin/health` |
P0 rows inherit §4 obligations as their backing controllers are next
touched; they are not required to be retrofitted in one sweep.
## 3. P1 mapping (bound to the build-first tools)
1. Every P1 operation maps to exactly one build-first command family, in
the T10-ruled rank order:
| Rank | Command family (owning contract) | P1 webUI operations it backs |
| ---- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Hierarchy command family (contract 1 §5; grants attach per contract 2) | Company/estate/platform-project/workspace CRUD, parentage and reparenting, hierarchy reads; the wizard's initial-hierarchy step (contract 3 §3.4) |
| 2 | Hierarchy RBAC command/evaluator (contract 2) | Grant create/change/revoke at company/estate/platform-project; inherited evaluation down to workspace; authorization-safe hierarchy queries |
| 3 | Typed kanban command/query surface (SOT §5, Amendment A1) | Workspace task lifecycle (create/edit/cancel/archive/move), board rank, typed queries |
| 4 | Agent enrollment command | Enroll one agent: harness, credential reference/API-key intake (values never echoed), name/persona, assignment scope (contract 3 §3.5) |
| 5 | Authorized roll-up query (contract 8) | Read-only aggregated task counts/statuses at every hierarchy level over readable workspaces only |
| 6 | Onboarding orchestration (contract 3) | The re-runnable wizard flow, composing ranks 1 and 4 (its only grant write rides inside the rank-1 company-create command, contract 2 §4.3) |
2. **Legacy non-substitutes.** The following MUST NOT back any P1
operation, matching the audit findings: legacy `/api/projects` and
`/api/tasks` CRUD (planning-data records, not hierarchy nodes and not
the typed kanban boundary); `POST /api/workspaces` (filesystem
bootstrap, not audited hierarchy parentage); `/api/teams` reads (no
grants, no inheritance); `POST /api/bootstrap/setup` (one-shot
epoch transition, identity §3 — not the re-runnable wizard); the MCP
`brain_*` task mutations (legacy Brain writes, not the typed kanban
commands). These stay serving their existing P0/host consumers until
the owning contract (or a successor amendment) schedules each
retirement — no such migration is scheduled at this revision; the
freeze stands on its own.
3. New P1 mapping rows (operations this table does not list) are added by
amending this contract, not ad hoc (§5).
## 4. Command envelope (request / result / error / audit)
Binding on every mapped operation the build-first families expose:
1. **Typed request and result.** Each command and query has an explicit
request DTO and result DTO in the shared types package, validated at
the Gateway boundary; unvalidated pass-through and `any`-typed
payloads are non-conformant. Mutations on records with an
expected-version rule in their owning contract carry the expected
version in the request and fail on mismatch with the conflict error
class (SOT §3 invariant 11 and REQ-TASK-001's concurrent-update
conflict acceptance; hierarchy per contract 1).
2. **Error taxonomy.** Every error result carries a stable
machine-readable code from a closed per-family enum plus an HTTP
status mapping, distinguishing at minimum: validation failure,
authentication failure, authorization refusal, not-found, conflict
(version/uniqueness), precondition/state refusal (e.g. bootstrap
epoch, suspended team subjects), and internal fault. Where contract
2's no-existence-oracle rule applies, authorization refusal and
not-found are indistinguishable on the wire for unauthorized readers
— same code, same status, same shape.
3. **Audit linkage.** A mutating mapped operation emits exactly the
audit events its owning contract defines (contract 1 §5.2, contract 2
§4.4, identity §§24, SOT audit rules); the envelope contributes the
correlation: every request accepts/generates a correlation id,
carried into the audit events and returned in the result, so a UI
action is traceable end to end. The mapping layer itself adds no
second audit stream.
4. **Fail-closed.** A mapped operation that cannot evaluate its
authorization or reach its owning tool refuses (contract 2 §3.5); the
envelope never degrades to an unauthorized fallback read or a direct
data access.
5. **CLI parity.** Each build-first family is invocable through the
official CLI against the same Gateway commands with the same
request/result/error contracts. No webUI-only command exists; a
Gateway command without CLI exposure is a conformance gap tracked at
the family's implementing issue.
## 5. Closure rule (blocked on tooling)
1. A webUI change that needs an operation with no mapping row is
**blocked on tooling**: the backing tool is built and mapped first
(D8). Scoring a gap "blocked on tooling" is mandatory, not
discretionary; working around it in the UI (direct DB or filesystem
access, calling a legacy non-substitute, embedding domain logic in
the web app) is non-conformant.
2. The mapping is enforced closed by §6.1's inventory witness: the web
app's network surface must be a subset of the mapped paths.
## 6. Verification requirements
Binding on the implementing PRs:
1. **Network-surface inventory witness:** a CI assertion extracting the
web app's outbound Gateway calls — route literals at request call
sites and outbound socket emits in `apps/web` sources (inbound
handler registrations are not calls and are out of scope) — and
failing on any call outside the §2/§3 mapped paths. The inventory is
closed like contract 1 §6.3's allowlist: a new call fails until a
mapping row exists in the same PR. Dynamic route construction that
evades extraction is resolved toward the witness, enforced by an
extractability lint: every request call site takes a literal or
template-literal path, and a call site that does not fails the
assertion itself (the web-side analogue of contract 1's
raw-execution prong), never an exemption for the caller.
2. **Non-substitute witness:** the P1 surfaces (hierarchy, RBAC, kanban,
enrollment, roll-up, wizard UI) make zero calls to the §3.2 legacy
endpoints — asserted by the same inventory, scoped per surface.
3. **Envelope witnesses per family:** for each build-first family — a
request with an invalid DTO is refused with the validation code; a
version-mismatch mutation returns the conflict code; an unauthorized
read of an existing node and a read of a nonexistent node return
indistinguishable results where the no-existence-oracle rule applies;
a correlation id submitted on a mutation appears in its audit
event(s) and result. Two static companions: a type-level assertion
that the family's boundary accepts no `any`-typed or unvalidated
pass-through payload (§4.1), and a single-emitter assertion that the
mapped operation's audit events originate only from the owning
contract's audit emitter (§4.3's no-second-audit-stream, made
checkable).
4. **CLI-parity witness:** for each family, a CLI smoke invocation of at
least one command and one query against the Gateway succeeds with the
same typed result the web client receives.
5. **Fail-closed witness:** with the owning tool or grant state
unreachable (fault injection), the mapped operation returns the
internal-fault or authorization-refusal class and performs no
fallback read/write (extends contract 2 §7.6 to the mapping layer).
## Ruling request
Ratify sections 16 as written, with one decision embedded:
- Decision (§3.2): the legacy endpoints named there are **frozen for new
consumers** as of ratification — existing P0/host consumers keep
working, new UI or tool code may not call them, and each is retired by
the migration its owning contract schedules. Alternative if rejected:
allow P1 surfaces to reuse legacy endpoints as interim backends —
rejected by the audit's finding that they cannot satisfy the
hierarchy/kanban/RBAC contracts, so the interim would ship
non-conformant semantics.
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from 'vitest';
import { createMissionTasksRepo } from './mission-tasks.js';
/**
* SHARED-CONTRACT §5.5 "mission_tasks.status write prohibition": this repo is
* the sole path that authors mission_tasks.status from caller input (storage
* tier migration is row transport and preserves stored values; the generic
* storage adapters have no mission_tasks caller), and it must never forward a
* caller-supplied status to the database on create or update. Callers keep
* working (the field is accepted and ignored), so these tests assert on what
* reaches the Drizzle chain, not on rejection.
*/
function makeInsertDb(returned: unknown[]) {
const values = vi.fn((_v: unknown) => ({ returning: vi.fn().mockResolvedValue(returned) }));
return { db: { insert: vi.fn(() => ({ values })) }, values };
}
function makeUpdateDb(returned: unknown[]) {
const set = vi.fn((_v: unknown) => ({
where: vi.fn(() => ({ returning: vi.fn().mockResolvedValue(returned) })),
}));
return { db: { update: vi.fn(() => ({ set })) }, set };
}
describe('createMissionTasksRepo — status write prohibition', () => {
it('create strips a caller-supplied status before insert', async () => {
const { db, values } = makeInsertDb([{ id: 'mt1', status: 'not-started' }]);
const repo = createMissionTasksRepo(db as never);
const result = await repo.create({
missionId: 'm1',
userId: 'u1',
status: 'done',
description: 'd',
} as never);
expect(values).toHaveBeenCalledTimes(1);
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
expect('status' in inserted).toBe(false);
expect(inserted.missionId).toBe('m1');
expect(inserted.description).toBe('d');
expect(result.id).toBe('mt1');
});
it('create without status still inserts (DB default applies)', async () => {
const { db, values } = makeInsertDb([{ id: 'mt2' }]);
const repo = createMissionTasksRepo(db as never);
await repo.create({ missionId: 'm1', userId: 'u1' } as never);
const inserted = values.mock.calls[0]![0] as Record<string, unknown>;
expect('status' in inserted).toBe(false);
});
it('update strips a caller-supplied status but keeps the other fields', async () => {
const { db, set } = makeUpdateDb([{ id: 'mt1', notes: 'n' }]);
const repo = createMissionTasksRepo(db as never);
const result = await repo.update('mt1', { status: 'done', notes: 'n' } as never);
expect(set).toHaveBeenCalledTimes(1);
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
expect('status' in updated).toBe(false);
expect(updated.notes).toBe('n');
expect(updated.updatedAt).toBeInstanceOf(Date);
expect(result?.id).toBe('mt1');
});
it('update with only status degenerates to a timestamp-only update', async () => {
const { db, set } = makeUpdateDb([{ id: 'mt1' }]);
const repo = createMissionTasksRepo(db as never);
await repo.update('mt1', { status: 'blocked' } as never);
const updated = set.mock.calls[0]![0] as Record<string, unknown>;
expect(Object.keys(updated)).toEqual(['updatedAt']);
});
});
+20 -2
View File
@@ -3,6 +3,24 @@ import { eq, and, type Db, missionTasks } from '@mosaicstack/db';
export type MissionTask = typeof missionTasks.$inferSelect;
export type NewMissionTask = typeof missionTasks.$inferInsert;
// SHARED-CONTRACT §5.1 phase 1 / §5.4: mission_tasks.status is prohibited as a
// write source through the N-1 window. This repo is the sole path that authors
// status from caller input, so the field is stripped here — accepted and
// ignored rather than rejected, because the legacy surface is frozen with
// existing consumers kept working (tool-gateway-mapping.md §3.2). Two other
// surfaces touch the column and are deliberately NOT stripped:
// packages/storage/migrate-tier.ts copies whole rows between storage tiers and
// must preserve the stored value verbatim, and the generic table-keyed storage
// adapters register mission_tasks but have no caller that targets it (runtime
// callers use fixed collection constants). Neither authors a new status. The
// column keeps its DB default, stays declared and readable, and is retired
// only after no readers remain.
function stripStatus<T extends { status?: unknown }>(data: T): Omit<T, 'status'> {
const rest = { ...data };
delete rest.status;
return rest;
}
export function createMissionTasksRepo(db: Db) {
return {
async findByMission(missionId: string): Promise<MissionTask[]> {
@@ -30,14 +48,14 @@ export function createMissionTasksRepo(db: Db) {
},
async create(data: NewMissionTask): Promise<MissionTask> {
const rows = await db.insert(missionTasks).values(data).returning();
const rows = await db.insert(missionTasks).values(stripStatus(data)).returning();
return rows[0]!;
},
async update(id: string, data: Partial<NewMissionTask>): Promise<MissionTask | undefined> {
const rows = await db
.update(missionTasks)
.set({ ...data, updatedAt: new Date() })
.set({ ...stripStatus(data), updatedAt: new Date() })
.where(eq(missionTasks.id, id))
.returning();
return rows[0];
@@ -0,0 +1,5 @@
ALTER TABLE "hierarchy_audit_events" DROP CONSTRAINT "hierarchy_audit_events_verb_check";--> statement-breakpoint
ALTER TABLE "companies" ADD COLUMN "visibility" text DEFAULT 'private' NOT NULL;--> statement-breakpoint
ALTER TABLE "companies" ADD CONSTRAINT "companies_visibility_check" CHECK (visibility IN ('private', 'directory'));--> statement-breakpoint
ALTER TABLE "hierarchy_audit_events" ADD CONSTRAINT "hierarchy_audit_events_verb_check" CHECK (verb IN ('create', 'rename', 'transfer', 'visibility_change', 'delete', 'grant_create', 'grant_change', 'grant_revoke'));--> statement-breakpoint
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_role_check" CHECK (role IN ('viewer', 'member', 'owner'));
File diff suppressed because it is too large Load Diff
+7
View File
@@ -141,6 +141,13 @@
"when": 1787880918208,
"tag": "0019_volatile_killraven",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1787963521142,
"tag": "0020_special_betty_brant",
"breakpoints": true
}
]
}
@@ -44,7 +44,7 @@ type AnyDb = {
/** Column allowlist — the exact declared sets of §2/§3. Nothing else. */
const COLUMN_ALLOWLIST: Record<string, string[]> = {
companies: ['id', 'name', 'slug', 'created_at', 'updated_at'],
companies: ['id', 'name', 'slug', 'visibility', 'created_at', 'updated_at'],
estates: ['id', 'name', 'slug', 'company_id'],
platform_projects: ['id', 'name', 'slug', 'estate_id'],
workspaces: ['id', 'name', 'slug', 'platform_project_id'],
@@ -377,7 +377,61 @@ function witnessSuite(getHandle: () => AnyDb): void {
// Control: same subject and target with a different role is a new grant.
await db()
.insert(hierarchyGrants)
.values({ userId: userA, companyId, role: `${T}-other-role`, grantedBy: userA });
.values({ userId: userA, companyId, role: 'member', grantedBy: userA });
});
// ── §2.6 role vocabulary CHECK ─────────────────────────────────────────────
it('refuses a grant role outside the ratified vocabulary, accepts each ratified role', async () => {
await expectViolation(
db()
.insert(hierarchyGrants)
.values({ userId: userB, companyId, role: 'superuser', grantedBy: userA }),
/check constraint/i,
);
// Serialized namespaced forms are storage-invalid too: rows hold bare roles.
await expectViolation(
db()
.insert(hierarchyGrants)
.values({ userId: userB, companyId, role: 'hierarchy:owner', grantedBy: userA }),
/check constraint/i,
);
for (const role of ['viewer', 'member', 'owner'] as const) {
await db()
.insert(hierarchyGrants)
.values({ userId: userB, estateId, role, grantedBy: userA });
}
await db().execute(
sql`DELETE FROM hierarchy_grants WHERE user_id = ${userB} AND estate_id = ${estateId}`,
);
});
// ── §2.8 visibility column ─────────────────────────────────────────────────
it('defaults companies.visibility to private and refuses values outside the class', async () => {
const visId = randomUUID();
await db().execute(
sql`INSERT INTO companies (id, name, slug) VALUES (${visId}, 'Vis', ${T + '-vis'})`,
);
const res = rows(await db().execute(sql`SELECT visibility FROM companies WHERE id = ${visId}`));
expect(res[0]!['visibility']).toBe('private');
await db().execute(
sql`INSERT INTO companies (id, name, slug, visibility) VALUES (${randomUUID()}, 'Vis D', ${T + '-vis-d'}, 'directory')`,
);
await expectViolation(
db().execute(
sql`INSERT INTO companies (id, name, slug, visibility) VALUES (${randomUUID()}, 'Vis X', ${T + '-vis-x'}, 'public')`,
),
/check constraint/i,
);
await expectViolation(
db().execute(sql`UPDATE companies SET visibility = 'hidden' WHERE id = ${visId}`),
/check constraint/i,
);
await expectViolation(
db().execute(sql`UPDATE companies SET visibility = NULL WHERE id = ${visId}`),
/null value|not-null/i,
);
});
// ── §6.1 NOT NULLs ─────────────────────────────────────────────────────────
@@ -391,7 +445,7 @@ function witnessSuite(getHandle: () => AnyDb): void {
);
await expectViolation(
db().execute(
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, 'x', NULL)`,
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, 'viewer', NULL)`,
),
/null value|not-null/i,
);
@@ -135,9 +135,9 @@
* production code is anomalous and review-visible; that blind spot is
* accepted as a residual, not closed.
*
* The writer allowlist names hierarchy command/repository modules ONLY. It is
* empty today: the hierarchy command family (M4-1b-ii) has not landed, so no
* production module may write the class tables. The infrastructure register
* The writer allowlist names hierarchy command/repository modules ONLY. Its
* single entry is the M4-1b-ii hierarchy command repository — the sole
* production module permitted to write the class tables. The infrastructure register
* holds legitimate non-hierarchy raw execution; registered modules are exempt
* from prong (iii) only — prongs (i) and (ii) apply to them with no
* exemption, and no registered module may appear on the writer allowlist.
@@ -181,13 +181,15 @@ const CLASS_TABLES = [
/**
* Writer allowlist (§6.3b): hierarchy command/repository modules only.
* EMPTY until the hierarchy command family lands (M4-1b-ii; M4-1b-i ships
* only the audit/outbox machinery, which writes no class table). Adding a module
* here is a contract-conformance decision reviewed under §5.1 — the module
* must be part of the Gateway hierarchy command path, and it must not export
* a function that executes caller-supplied SQL.
* Adding a module here is a contract-conformance decision reviewed under
* §5.1 — the module must be part of the Gateway hierarchy command path, and
* it must not export a function that executes caller-supplied SQL.
*/
const WRITER_ALLOWLIST: string[] = [];
const WRITER_ALLOWLIST: string[] = [
// The hierarchy command repository (M4-1b-ii): the sole class-table
// writer; every mutation is audited on its own transaction (§5.2).
'apps/gateway/src/hierarchy/hierarchy.repository.ts',
];
/**
* Infrastructure register: closed enumeration of legitimate non-hierarchy raw
+27 -9
View File
@@ -1063,13 +1063,24 @@ export const federationEnrollmentTokens = pgTable('federation_enrollment_tokens'
// command family only (§5.1), enforced by the writer-coverage assertion
// (§6.3b) — do not add writers outside that allowlist.
export const companies = pgTable('companies', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
/** Company visibility classes (contract 1 §2.8, Ruling 4b): 'private' is the
* only creatable class (§5.5 creation carries no visibility argument);
* 'directory' discloses existence/name/slug to all users and is entered only
* through the admin-gated visibility-change command. */
export const COMPANY_VISIBILITY = ['private', 'directory'] as const;
export const companies = pgTable(
'companies',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
visibility: text('visibility').notNull().default('private'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
() => [check('companies_visibility_check', sql`visibility IN ('private', 'directory')`)],
);
export const estates = pgTable(
'estates',
@@ -1110,6 +1121,10 @@ export const workspaces = pgTable(
(t) => [unique('workspaces_platform_project_slug_uniq').on(t.platformProjectId, t.slug)],
);
/** Grant role vocabulary (contract 2 §2): totally ordered, viewer member
* owner. Order in this tuple IS the ordering index = strength. */
export const HIERARCHY_GRANT_ROLES = ['viewer', 'member', 'owner'] as const;
export const hierarchyGrants = pgTable(
'hierarchy_grants',
{
@@ -1126,7 +1141,8 @@ export const hierarchyGrants = pgTable(
platformProjectId: uuid('platform_project_id').references(() => platformProjects.id, {
onDelete: 'cascade',
}),
// Role vocabulary and its CHECK constraint are contract 2 §2 (M4-2).
// Role vocabulary per contract 2 §2: exactly viewer ⊂ member ⊂ owner,
// totally ordered; CHECK below closes the column to that vocabulary.
role: text('role').notNull(),
grantedBy: text('granted_by')
.notNull()
@@ -1134,6 +1150,7 @@ export const hierarchyGrants = pgTable(
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
check('hierarchy_grants_role_check', sql`role IN ('viewer', 'member', 'owner')`),
check('hierarchy_grants_subject_check', sql`num_nonnulls(user_id, team_id) = 1`),
check(
'hierarchy_grants_target_check',
@@ -1170,6 +1187,7 @@ export const HIERARCHY_AUDIT_VERBS = [
'create',
'rename',
'transfer',
'visibility_change',
'delete',
'grant_create',
'grant_change',
@@ -1220,7 +1238,7 @@ export const hierarchyAuditEvents = pgTable(
index('hierarchy_audit_events_correlation_idx').on(t.correlationId),
check(
'hierarchy_audit_events_verb_check',
sql`verb IN ('create', 'rename', 'transfer', 'delete', 'grant_create', 'grant_change', 'grant_revoke')`,
sql`verb IN ('create', 'rename', 'transfer', 'visibility_change', 'delete', 'grant_create', 'grant_change', 'grant_revoke')`,
),
check(
'hierarchy_audit_events_target_kind_check',
@@ -3,6 +3,7 @@
# Usage: issue-assign.sh -i ISSUE_NUMBER [-a assignee] [-l labels] [-m milestone]
set -e
set -o pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
@@ -33,25 +34,36 @@ Examples:
$(basename "$0") -i 42 -l "in-progress" -m "0.2.0"
$(basename "$0") -i 42 -a @me
EOF
exit "${1:-1}"
exit "${1:-2}"
}
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
usage >&2
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE="$2"
shift 2
;;
-a|--assignee)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ASSIGNEE="$2"
shift 2
;;
-l|--labels)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LABELS="$2"
shift 2
;;
-m|--milestone)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
MILESTONE="$2"
shift 2
;;
@@ -79,20 +91,35 @@ PLATFORM=$(detect_platform)
case "$PLATFORM" in
github)
if [[ -n "$ASSIGNEE" ]]; then
gh issue edit "$ISSUE" --add-assignee "$ASSIGNEE"
prov_rc=0
gh issue edit "$ISSUE" --add-assignee "$ASSIGNEE" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
fi
if [[ "$REMOVE_ASSIGNEE" == true ]]; then
# Get current assignees and remove them
CURRENT=$(gh issue view "$ISSUE" --json assignees -q '.assignees[].login' 2>/dev/null | tr '\n' ',')
# pipefail preserves the provider status through the pipeline;
# a FAILED lookup exits here instead of reading as a silent
# no-assignees skip (codex PR #1464). A successful lookup with
# zero assignees still skips the edit below.
CURRENT=$(gh issue view "$ISSUE" --json assignees -q '.assignees[].login' 2>/dev/null | tr '\n' ',') || {
echo "Error: could not read current assignees (provider lookup failed)" >&2
exit 1
}
if [[ -n "$CURRENT" ]]; then
gh issue edit "$ISSUE" --remove-assignee "${CURRENT%,}"
prov_rc=0
gh issue edit "$ISSUE" --remove-assignee "${CURRENT%,}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
fi
fi
if [[ -n "$LABELS" ]]; then
gh issue edit "$ISSUE" --add-label "$LABELS"
prov_rc=0
gh issue edit "$ISSUE" --add-label "$LABELS" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
fi
if [[ -n "$MILESTONE" ]]; then
gh issue edit "$ISSUE" --milestone "$MILESTONE"
prov_rc=0
gh issue edit "$ISSUE" --milestone "$MILESTONE" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
fi
echo "Issue #$ISSUE updated successfully"
;;
@@ -131,7 +158,9 @@ case "$PLATFORM" in
fi
if [[ "$NEEDS_EDIT" == true ]]; then
"${CMD[@]}"
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
echo "Issue #$ISSUE updated successfully"
else
echo "No changes specified"
@@ -1,6 +1,7 @@
#!/bin/bash
# issue-close.sh - Close an issue on GitHub or Gitea
# Usage: issue-close.sh -i <issue_number> [-c <comment>]
# Usage: issue-close.sh -i <issue_number> [-b <comment>]
# (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28)
set -e
@@ -12,35 +13,49 @@ source "$SCRIPT_DIR/detect-platform.sh"
ISSUE_NUMBER=""
COMMENT=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
# caller or stop gate can tell an invocation defect from a delivery blocker.
usage_error() {
echo "Error: $*" >&2
echo "Usage: issue-close.sh -i <issue_number> [-b <comment>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE_NUMBER="$2"
shift 2
;;
-c|--comment)
-b|--body|-c|--comment)
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
# a backward-compatible alias.
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
COMMENT="$2"
shift 2
;;
-h|--help)
echo "Usage: issue-close.sh -i <issue_number> [-c <comment>]"
echo "Usage: issue-close.sh -i <issue_number> [-b <comment>]"
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo " -c, --comment Comment to add before closing (optional)"
echo " -b, --body Comment to add before closing (optional; canonical)"
echo " -c, --comment Alias for --body"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
usage_error "unknown option: $1"
;;
esac
done
if [[ -z "$ISSUE_NUMBER" ]]; then
echo "Error: Issue number is required (-i)"
exit 1
usage_error "issue number is required (-i/--issue)"
fi
# Detect platform and close issue
@@ -82,10 +97,22 @@ gitea_issue_close_api() {
}
if [[ "$PLATFORM" == "github" ]]; then
# R4: normalize provider failures to exit 1 (gh's own usage errors exit 2
# and would collide with the reserved usage-error status).
if [[ -n "$COMMENT" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
gh_rc=0
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
if [[ "$gh_rc" -ne 0 ]]; then
echo "Error: GitHub comment before close failed (gh exit $gh_rc)" >&2
exit 1
fi
fi
gh_rc=0
gh issue close "$ISSUE_NUMBER" || gh_rc=$?
if [[ "$gh_rc" -ne 0 ]]; then
echo "Error: GitHub issue close failed (gh exit $gh_rc)" >&2
exit 1
fi
gh issue close "$ISSUE_NUMBER"
echo "Closed GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
GITEA_LOGIN_NAME=$(get_gitea_login || true)
@@ -107,7 +134,9 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
exit 1
}
fi
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
prov_rc=0
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
else
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
if [[ -n "$COMMENT" ]]; then
@@ -1,6 +1,7 @@
#!/bin/bash
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
# Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]
# Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]
# (-c/--comment is a backward-compatible alias for -b/--body; R1, 2026-08-28)
#
# tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`);
# the non-existent `tea issue comment ...` form does not error — tea silently
@@ -32,45 +33,61 @@ ISSUE_NUMBER=""
COMMENT=""
LOGIN_OVERRIDE=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
# caller or stop gate can tell an invocation defect from a delivery blocker
# (CONSTITUTION gate 8 as amended; E2E-DELIVERY).
usage_error() {
echo "Error: $*" >&2
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE_NUMBER="$2"
shift 2
;;
-c|--comment)
-b|--body|-c|--comment)
# R1 (2026-08-28): --body is the canonical flag, matching
# issue-create/issue-edit/pr-create/pr-edit; -c/--comment stays a
# backward-compatible alias.
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
COMMENT="$2"
shift 2
;;
-l|--login)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LOGIN_OVERRIDE="$2"
shift 2
;;
-h|--help)
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]"
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo " -c, --comment Comment text (required)"
echo " -b, --body Comment text (required; canonical)"
echo " -c, --comment Alias for --body"
echo " -l, --login Override the detected Gitea tea login for this call"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
usage_error "unknown option: $1"
;;
esac
done
if [[ -z "$ISSUE_NUMBER" ]]; then
echo "Error: Issue number is required (-i)"
exit 1
usage_error "issue number is required (-i/--issue)"
fi
if [[ -z "$COMMENT" ]]; then
echo "Error: Comment is required (-c)"
exit 1
usage_error "comment is required (-b/--body, or the -c/--comment alias)"
fi
detect_platform >/dev/null
@@ -340,7 +357,15 @@ PY
}
if [[ "$PLATFORM" == "github" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
# R4 exit-code contract: normalize provider failures to exit 1. gh's own
# usage errors exit 2, which would collide with this wrapper's reserved
# usage-error status if propagated raw (codex review of 08a00149).
gh_rc=0
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
if [[ "$gh_rc" -ne 0 ]]; then
echo "Error: GitHub comment write failed (gh exit $gh_rc; provider/credential failure — usage errors are exit 2)" >&2
exit 1
fi
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
# A --login override selects a NAMED tea credential and is the only way to
@@ -74,26 +74,39 @@ Examples:
$(basename "$0") -t "Fix login bug" -l "bug,priority-high"
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
$(basename "$0") -i
Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure.
EOF
exit "${1:-1}"
exit "${1:-2}"
}
# Parse arguments
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
usage >&2
}
while [[ $# -gt 0 ]]; do
case $1 in
-t|--title)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
TITLE="$2"
shift 2
;;
-b|--body)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
BODY="$2"
shift 2
;;
-l|--labels)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LABELS="$2"
shift 2
;;
-m|--milestone)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
MILESTONE="$2"
shift 2
;;
@@ -131,7 +144,9 @@ case "$PLATFORM" in
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
"${CMD[@]}"
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
;;
gitea)
if command -v tea >/dev/null 2>&1; then
@@ -14,25 +14,39 @@ BODY=""
LABELS=""
MILESTONE=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
# caller or stop gate can tell an invocation defect from a delivery blocker.
usage_error() {
echo "Error: $*" >&2
echo "Usage: issue-edit.sh -i <issue_number> [-t <title>] [-b <body>] [-l <labels>] [-m <milestone>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE_NUMBER="$2"
shift 2
;;
-t|--title)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
TITLE="$2"
shift 2
;;
-b|--body)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
BODY="$2"
shift 2
;;
-l|--labels)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LABELS="$2"
shift 2
;;
-m|--milestone)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
MILESTONE="$2"
shift 2
;;
@@ -46,18 +60,18 @@ while [[ $# -gt 0 ]]; do
echo " -l, --labels Labels (comma-separated, replaces existing)"
echo " -m, --milestone Milestone name"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
usage_error "unknown option: $1"
;;
esac
done
if [[ -z "$ISSUE_NUMBER" ]]; then
echo "Error: Issue number is required (-i)"
exit 1
usage_error "issue number is required (-i/--issue)"
fi
detect_platform >/dev/null
@@ -68,7 +82,9 @@ if [[ "$PLATFORM" == "github" ]]; then
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
[[ -n "$LABELS" ]] && CMD+=(--add-label "$LABELS")
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
"${CMD[@]}"
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
echo "Updated GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
REPO_SLUG=$(get_repo_slug) || {
@@ -84,7 +100,9 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
[[ -n "$LABELS" ]] && CMD+=(--add-labels "$LABELS")
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
"${CMD[@]}"
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
echo "Updated Gitea issue #$ISSUE_NUMBER"
else
echo "Error: Unknown platform"
@@ -36,33 +36,46 @@ Examples:
$(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0
$(basename "$0") --repo ddk/ai-bma # List issues from anywhere
EOF
exit "${1:-1}"
exit "${1:-2}"
}
# Parse arguments
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
usage >&2
}
while [[ $# -gt 0 ]]; do
case $1 in
-s|--state)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
STATE="$2"
shift 2
;;
-l|--label)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LABEL="$2"
shift 2
;;
-m|--milestone)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
MILESTONE="$2"
shift 2
;;
-a|--assignee)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ASSIGNEE="$2"
shift 2
;;
-n|--limit)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LIMIT="$2"
shift 2
;;
-r|--repo)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
REPO_OVERRIDE="$2"
shift 2
;;
@@ -95,7 +108,9 @@ case "$PLATFORM" in
[[ -n "$LABEL" ]] && CMD+=(--label "$LABEL")
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
[[ -n "$ASSIGNEE" ]] && CMD+=(--assignee "$ASSIGNEE")
"${CMD[@]}"
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
;;
gitea)
if [[ -n "$REPO_OVERRIDE" ]]; then
@@ -114,7 +129,9 @@ case "$PLATFORM" in
[[ -n "$MILESTONE" ]] && CMD+=(--milestones "$MILESTONE")
# Note: tea may not support assignee filter directly in all versions.
[[ -n "$ASSIGNEE" ]] && echo "Note: Assignee filtering may require manual review for Gitea" >&2
"${CMD[@]}"
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
;;
*)
echo "Error: Could not detect git platform" >&2
@@ -1,6 +1,7 @@
#!/bin/bash
# issue-reopen.sh - Reopen a closed issue on GitHub or Gitea
# Usage: issue-reopen.sh -i <issue_number> [-c <comment>]
# Usage: issue-reopen.sh -i <issue_number> [-b <comment>]
# (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28)
set -e
@@ -11,35 +12,49 @@ source "$SCRIPT_DIR/detect-platform.sh"
ISSUE_NUMBER=""
COMMENT=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
# caller or stop gate can tell an invocation defect from a delivery blocker.
usage_error() {
echo "Error: $*" >&2
echo "Usage: issue-reopen.sh -i <issue_number> [-b <comment>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE_NUMBER="$2"
shift 2
;;
-c|--comment)
-b|--body|-c|--comment)
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
# a backward-compatible alias.
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
COMMENT="$2"
shift 2
;;
-h|--help)
echo "Usage: issue-reopen.sh -i <issue_number> [-c <comment>]"
echo "Usage: issue-reopen.sh -i <issue_number> [-b <comment>]"
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo " -c, --comment Comment to add when reopening (optional)"
echo " -b, --body Comment to add when reopening (optional; canonical)"
echo " -c, --comment Alias for --body"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
usage_error "unknown option: $1"
;;
esac
done
if [[ -z "$ISSUE_NUMBER" ]]; then
echo "Error: Issue number is required (-i)"
exit 1
usage_error "issue number is required (-i/--issue)"
fi
detect_platform >/dev/null
@@ -80,18 +95,34 @@ gitea_issue_reopen_api() {
}
if [[ "$PLATFORM" == "github" ]]; then
# R4: normalize provider failures to exit 1 (gh's own usage errors exit 2
# and would collide with the reserved usage-error status).
if [[ -n "$COMMENT" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
gh_rc=0
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
if [[ "$gh_rc" -ne 0 ]]; then
echo "Error: GitHub comment before reopen failed (gh exit $gh_rc)" >&2
exit 1
fi
fi
gh_rc=0
gh issue reopen "$ISSUE_NUMBER" || gh_rc=$?
if [[ "$gh_rc" -ne 0 ]]; then
echo "Error: GitHub issue reopen failed (gh exit $gh_rc)" >&2
exit 1
fi
gh issue reopen "$ISSUE_NUMBER"
echo "Reopened GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
REPO_ARGS=$(get_gitea_repo_args || true)
if [[ -n "$REPO_ARGS" ]]; then
if [[ -n "$COMMENT" ]]; then
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $REPO_ARGS
prov_rc=0
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $REPO_ARGS || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
fi
tea issue reopen "$ISSUE_NUMBER" $REPO_ARGS
prov_rc=0
tea issue reopen "$ISSUE_NUMBER" $REPO_ARGS || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
else
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
if [[ -n "$COMMENT" ]]; then
@@ -8,6 +8,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
echo "Usage: issue-view.sh -i <issue_number> (see --help)" >&2
exit 2
}
ISSUE_NUMBER=""
# get_remote_host and get_gitea_token are provided by detect-platform.sh
@@ -74,6 +82,7 @@ if comments:
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE_NUMBER="$2"
shift 2
;;
@@ -88,21 +97,21 @@ while [[ $# -gt 0 ]]; do
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
usage_error "unknown option: $1"
;;
esac
done
if [[ -z "$ISSUE_NUMBER" ]]; then
echo "Error: Issue number is required (-i)"
exit 1
usage_error "Issue number is required"
fi
detect_platform >/dev/null
if [[ "$PLATFORM" == "github" ]]; then
gh issue view "$ISSUE_NUMBER"
prov_rc=0
gh issue view "$ISSUE_NUMBER" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
elif [[ "$PLATFORM" == "gitea" ]]; then
if command -v tea >/dev/null 2>&1; then
# --comments is what makes tea print the comment bodies (#1357 F3).
@@ -28,18 +28,25 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
REPO="" MILESTONE="" LABEL="" LOGIN="" LIMIT=100
while getopts "r:m:l:L:n:h" opt; do
case "$opt" in
r) REPO="$OPTARG" ;;
m) MILESTONE="$OPTARG" ;;
l) LABEL="$OPTARG" ;;
L) LOGIN="$OPTARG" ;;
n) LIMIT="$OPTARG" ;;
h) grep '^#' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "see -h" >&2; exit 2 ;;
# R2 (2026-08-28): long-flag aliases with the same usage-error contract the
# wrapper family shares (rc 2, stderr). getopts could not take long flags.
usage_error() {
echo "Error: $*" >&2
echo "Usage: lane-brief.sh -r <owner/repo> [-m milestone] [-l label] [-L login] [-n limit]" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case "$1" in
-r|--repo) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; REPO="$2"; shift 2 ;;
-m|--milestone) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; MILESTONE="$2"; shift 2 ;;
-l|--label|--labels) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LABEL="$2"; shift 2 ;;
-L|--login) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LOGIN="$2"; shift 2 ;;
-n|--limit) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LIMIT="$2"; shift 2 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \?//'; exit 0 ;;
*) usage_error "unknown option: $1" ;;
esac
done
[[ -n "$REPO" ]] || { echo "FATAL: -r <owner/repo> required" >&2; exit 2; }
[[ -n "$REPO" ]] || usage_error "-r/--repo <owner/repo> required"
# Resolve login: explicit -L, then $GITEA_LOGIN, then owner inference, then the
# shared default-login resolver. Owner inference comes before the shared fallback
@@ -72,7 +79,7 @@ if [[ -z "$LOGIN" ]]; then
fi
fi
fi
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 2; }
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 1; }
command -v tea >/dev/null || { echo "FATAL: tea not found" >&2; exit 1; }
command -v jq >/dev/null || { echo "FATAL: jq not found" >&2; exit 1; }
@@ -8,11 +8,20 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
echo "Usage: milestone-close.sh -t <title> (see --help)" >&2
exit 2
}
TITLE=""
while [[ $# -gt 0 ]]; do
case $1 in
-t|--title)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
TITLE="$2"
shift 2
;;
@@ -25,28 +34,30 @@ while [[ $# -gt 0 ]]; do
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
usage_error "unknown option: $1"
;;
esac
done
if [[ -z "$TITLE" ]]; then
echo "Error: Milestone title is required (-t)"
exit 1
usage_error "Milestone title is required"
fi
detect_platform >/dev/null
if [[ "$PLATFORM" == "github" ]]; then
gh api -X PATCH "/repos/{owner}/{repo}/milestones/$(gh api "/repos/{owner}/{repo}/milestones" --jq ".[] | select(.title==\"$TITLE\") | .number")" -f state=closed
prov_rc=0
gh api -X PATCH "/repos/{owner}/{repo}/milestones/$(gh api "/repos/{owner}/{repo}/milestones" --jq ".[] | select(.title==\"$TITLE\") | .number")" -f state=closed || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
echo "Closed GitHub milestone: $TITLE"
elif [[ "$PLATFORM" == "gitea" ]]; then
REPO_ARGS=$(get_gitea_repo_args) || {
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
exit 1
}
tea milestone close "$TITLE" $REPO_ARGS
prov_rc=0
tea milestone close "$TITLE" $REPO_ARGS || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
echo "Closed Gitea milestone: $TITLE"
else
echo "Error: Unknown platform"
@@ -3,6 +3,7 @@
# Usage: milestone-create.sh -t "Title" [-d "Description"] [--due "YYYY-MM-DD"]
set -e
set -o pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
@@ -37,21 +38,31 @@ Examples:
$(basename "$0") -t "0.0.1" -d "Pre-MVP Foundation Sprint"
$(basename "$0") -t "0.1.0" -d "MVP Release" --due "2025-03-01"
EOF
exit "${1:-1}"
exit "${1:-2}"
}
# Parse arguments
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
usage >&2
}
while [[ $# -gt 0 ]]; do
case $1 in
-t|--title)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
TITLE="$2"
shift 2
;;
-d|--desc)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
DESCRIPTION="$2"
shift 2
;;
--due)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
DUE_DATE="$2"
shift 2
;;
@@ -74,14 +85,18 @@ PLATFORM=$(detect_platform)
if [[ "$LIST_ONLY" == true ]]; then
case "$PLATFORM" in
github)
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)\t\(.title)\t\(.state)\t\(.open_issues)/\(.closed_issues) issues"'
prov_rc=0
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)\t\(.title)\t\(.state)\t\(.open_issues)/\(.closed_issues) issues"' || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
;;
gitea)
REPO_ARGS=$(get_gitea_repo_args) || {
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
exit 1
}
tea milestones list $REPO_ARGS
prov_rc=0
tea milestones list $REPO_ARGS || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
;;
*)
echo "Error: Could not detect git platform" >&2
@@ -92,8 +107,7 @@ if [[ "$LIST_ONLY" == true ]]; then
fi
if [[ -z "$TITLE" ]]; then
echo "Error: Title is required (-t) for creating milestones" >&2
usage
usage_error "Title is required (-t) for creating milestones"
fi
case "$PLATFORM" in
@@ -109,7 +123,9 @@ case "$PLATFORM" in
+ (if $d != "" then {"description": $d} else {} end)
+ (if $due != "" then {"due_on": ($due + "T00:00:00Z")} else {} end)')
gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD"
prov_rc=0
gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
echo "Milestone '$TITLE' created successfully"
;;
gitea)
@@ -120,7 +136,9 @@ case "$PLATFORM" in
CMD=(tea milestones create --title "$TITLE")
[[ -n "$DESCRIPTION" ]] && CMD+=(--description "$DESCRIPTION")
[[ -n "$DUE_DATE" ]] && CMD+=(--deadline "$DUE_DATE")
"${CMD[@]}" $REPO_ARGS
prov_rc=0
"${CMD[@]}" $REPO_ARGS || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
echo "Milestone '$TITLE' created successfully"
;;
*)
@@ -8,11 +8,20 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
echo "Usage: milestone-list.sh [-s <state>] (see --help)" >&2
exit 2
}
STATE="open"
while [[ $# -gt 0 ]]; do
case $1 in
-s|--state)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
STATE="$2"
shift 2
;;
@@ -25,8 +34,7 @@ while [[ $# -gt 0 ]]; do
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
usage_error "unknown option: $1"
;;
esac
done
@@ -34,13 +42,17 @@ done
detect_platform >/dev/null
if [[ "$PLATFORM" == "github" ]]; then
gh api "/repos/{owner}/{repo}/milestones?state=$STATE" --jq '.[] | "\(.title) (\(.state)) - \(.open_issues) open, \(.closed_issues) closed"'
prov_rc=0
gh api "/repos/{owner}/{repo}/milestones?state=$STATE" --jq '.[] | "\(.title) (\(.state)) - \(.open_issues) open, \(.closed_issues) closed"' || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
elif [[ "$PLATFORM" == "gitea" ]]; then
REPO_ARGS=$(get_gitea_repo_args) || {
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
exit 1
}
tea milestone list $REPO_ARGS
prov_rc=0
tea milestone list $REPO_ARGS || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
else
echo "Error: Unknown platform"
exit 1
+43 -12
View File
@@ -1,6 +1,7 @@
#!/bin/bash
# pr-close.sh - Close a pull request without merging on GitHub or Gitea
# Usage: pr-close.sh -n <pr_number> [-c <comment>]
# Usage: pr-close.sh -n <pr_number> [-b <comment>]
# (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28)
set -e
@@ -11,50 +12,80 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
COMMENT=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
# caller or stop gate can tell an invocation defect from a delivery blocker.
usage_error() {
echo "Error: $*" >&2
echo "Usage: pr-close.sh -n <pr_number> [-b <comment>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-n|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
PR_NUMBER="$2"
shift 2
;;
-c|--comment)
-b|--body|-c|--comment)
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
# a backward-compatible alias.
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
COMMENT="$2"
shift 2
;;
-h|--help)
echo "Usage: pr-close.sh -n <pr_number> [-c <comment>]"
echo "Usage: pr-close.sh -n <pr_number> [-b <comment>]"
echo ""
echo "Options:"
echo " -n, --number PR number (required)"
echo " -c, --comment Comment before closing (optional)"
echo " -b, --body Comment before closing (optional; canonical)"
echo " -c, --comment Alias for --body"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
usage_error "unknown option: $1"
;;
esac
done
if [[ -z "$PR_NUMBER" ]]; then
echo "Error: PR number is required (-n)"
exit 1
usage_error "PR number is required (-n/--number)"
fi
detect_platform >/dev/null
if [[ "$PLATFORM" == "github" ]]; then
# R4: normalize provider failures to exit 1 (gh's own usage errors exit 2
# and would collide with the reserved usage-error status).
if [[ -n "$COMMENT" ]]; then
gh pr comment "$PR_NUMBER" --body "$COMMENT"
gh_rc=0
gh pr comment "$PR_NUMBER" --body "$COMMENT" || gh_rc=$?
if [[ "$gh_rc" -ne 0 ]]; then
echo "Error: GitHub PR comment before close failed (gh exit $gh_rc)" >&2
exit 1
fi
fi
gh_rc=0
gh pr close "$PR_NUMBER" || gh_rc=$?
if [[ "$gh_rc" -ne 0 ]]; then
echo "Error: GitHub PR close failed (gh exit $gh_rc)" >&2
exit 1
fi
gh pr close "$PR_NUMBER"
echo "Closed GitHub PR #$PR_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
if [[ -n "$COMMENT" ]]; then
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
prov_rc=0
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args) || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
fi
tea pr close "$PR_NUMBER" $(get_gitea_repo_args)
prov_rc=0
tea pr close "$PR_NUMBER" $(get_gitea_repo_args) || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
echo "Closed Gitea PR #$PR_NUMBER"
else
echo "Error: Unknown platform"
@@ -135,37 +135,51 @@ Examples:
$(basename "$0") -i 42 -b "Implements the feature described in #42"
$(basename "$0") -t "WIP: New feature" --draft
EOF
exit "${1:-1}"
exit "${1:-2}"
}
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
usage >&2
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-t|--title)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
TITLE="$2"
shift 2
;;
-b|--body)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
BODY="$2"
shift 2
;;
-B|--base)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
BASE_BRANCH="$2"
shift 2
;;
-H|--head)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
HEAD_BRANCH="$2"
shift 2
;;
-l|--labels)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LABELS="$2"
shift 2
;;
-m|--milestone)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
MILESTONE="$2"
shift 2
;;
-i|--issue)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE="$2"
shift 2
;;
@@ -266,7 +280,9 @@ case "$PLATFORM" in
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
[[ "$DRAFT" == true ]] && CMD+=(--draft)
"${CMD[@]}"
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
;;
gitea)
# tea pull create syntax. Always pass --repo because tea repo inference
+16 -4
View File
@@ -13,21 +13,33 @@ OUTPUT_FILE=""
REPO_OVERRIDE=""
HOST_OVERRIDE=""
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
echo "Usage: pr-diff.sh -n <pr_number> [-r owner/repo] [--host host] [-o <output_file>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-n|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
PR_NUMBER="$2"
shift 2
;;
-o|--output)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
OUTPUT_FILE="$2"
shift 2
;;
-r|--repo)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
REPO_OVERRIDE="$2"
shift 2
;;
--host)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
HOST_OVERRIDE="$2"
shift 2
;;
@@ -40,18 +52,18 @@ while [[ $# -gt 0 ]]; do
echo " --host Gitea host for --repo API calls (or set GITEA_HOST/GITEA_URL)"
echo " -o, --output Output file (optional, prints to stdout if omitted)"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure."
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
usage_error "unknown option: $1"
;;
esac
done
if [[ -z "$PR_NUMBER" ]]; then
echo "Error: PR number is required (-n)" >&2
exit 1
usage_error "PR number is required (-n/--number)"
fi
if [[ -n "$REPO_OVERRIDE" ]]; then
+31 -18
View File
@@ -50,38 +50,45 @@ Options:
-H, --host HOST Explicit Gitea host (required with --repo off-host)
-h, --help Show this help message
EOF
exit "${1:-1}"
exit "${1:-2}"
}
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
usage >&2
}
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--number) PR_NUMBER="${2:-}"; shift 2 ;;
-t|--title) TITLE="${2:-}"; shift 2 ;;
-b|--body) BODY="${2:-}"; shift 2 ;;
-B|--base) BASE_BRANCH="${2:-}"; shift 2 ;;
-n|--number) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; PR_NUMBER="${2:-}"; shift 2 ;;
-t|--title) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; TITLE="${2:-}"; shift 2 ;;
-b|--body) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BODY="${2:-}"; shift 2 ;;
-B|--base) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BASE_BRANCH="${2:-}"; shift 2 ;;
--draft)
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
DRAFT_MODE="draft"; shift ;;
--ready)
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
DRAFT_MODE="ready"; shift ;;
-l|--login) LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
-r|--repo) REPO_OVERRIDE="${2:-}"; shift 2 ;;
-H|--host) HOST_OVERRIDE="${2:-}"; shift 2 ;;
-l|--login) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
-r|--repo) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; REPO_OVERRIDE="${2:-}"; shift 2 ;;
-H|--host) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; HOST_OVERRIDE="${2:-}"; shift 2 ;;
-h|--help) usage 0 ;;
*) echo "Unknown option: $1" >&2; usage ;;
esac
done
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 1; }
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 1; }
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 2; }
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 2; }
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
echo "Error: At least one edit option is required" >&2
exit 1
exit 2
fi
[[ -z "$REPO_OVERRIDE" || "$REPO_OVERRIDE" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || {
echo "Error: --repo must be OWNER/REPO" >&2
exit 1
exit 2
}
if [[ -n "$HOST_OVERRIDE" || -n "$REPO_OVERRIDE" ]]; then
@@ -92,18 +99,24 @@ fi
case "$PLATFORM" in
github)
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 1; }
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 2; }
if [[ -n "$TITLE" || -n "$BODY" || -n "$BASE_BRANCH" ]]; then
CMD=(gh pr edit "$PR_NUMBER")
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
"${CMD[@]}"
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
fi
if [[ "$DRAFT_MODE" == "draft" ]]; then
gh pr ready "$PR_NUMBER" --undo
prov_rc=0
gh pr ready "$PR_NUMBER" --undo || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
elif [[ "$DRAFT_MODE" == "ready" ]]; then
gh pr ready "$PR_NUMBER"
prov_rc=0
gh pr ready "$PR_NUMBER" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
fi
;;
gitea)
+14 -3
View File
@@ -34,29 +34,41 @@ Examples:
$(basename "$0") -s merged -a username # Merged PRs by user
$(basename "$0") --repo ddk/ai-bma # List PRs from anywhere
EOF
exit "${1:-1}"
exit "${1:-2}"
}
# Parse arguments
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
usage >&2
}
while [[ $# -gt 0 ]]; do
case $1 in
-s|--state)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
STATE="$2"
shift 2
;;
-l|--label)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LABEL="$2"
shift 2
;;
-a|--author)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
AUTHOR="$2"
shift 2
;;
-n|--limit)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LIMIT="$2"
shift 2
;;
-r|--repo)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
REPO_OVERRIDE="$2"
shift 2
;;
@@ -64,8 +76,7 @@ while [[ $# -gt 0 ]]; do
usage 0
;;
*)
echo "Unknown option: $1" >&2
usage
usage_error "unknown option: $1"
;;
esac
done
@@ -1,6 +1,6 @@
#!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--no-ci-expected] [--co-author-trailers --escalate-to PRINCIPAL]
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--no-ci-expected] [--base-line BRANCH] [--co-author-trailers --escalate-to PRINCIPAL]
set -euo pipefail
@@ -30,6 +30,12 @@ Options:
-d, --delete-branch Delete the head branch after merge
--dry-run Run metadata/login preflight without merging
--expect-head SHA Refuse unless the PR head matches this full commit SHA
--base-line BRANCH Documented intra-line exception (B5, ruled
2026-08-29): authorize a merge whose base is
neither main nor next (stacked PR lines). The
value must MATCH the PR base; all other gates
(queue guard, head pin, CI) still run and the
exception is recorded in the merge audit.
--no-ci-expected Assert the target repository has no CI: forward --no-ci-expected to the queue guard (requires repository admin)
--co-author-trailers Build verified trailers from linked PR commit authors
--escalate-to NAME Named principal for an unresolved-author BLOCK
@@ -46,6 +52,7 @@ EOF
}
# Parse arguments
BASE_LINE_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case $1 in
-n|--number)
@@ -64,6 +71,11 @@ while [[ $# -gt 0 ]]; do
DRY_RUN=true
shift
;;
--base-line)
[[ $# -ge 2 ]] || { echo "Error: --base-line requires a branch name." >&2; exit 1; }
BASE_LINE_OVERRIDE="$2"
shift 2
;;
--expect-head)
if [[ $# -lt 2 ]]; then
echo "Error: --expect-head requires one full commit SHA." >&2
@@ -172,8 +184,17 @@ if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
else
repo_decl_warn_absent_irreversible "pr-merge"
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
exit 1
if [[ -n "$BASE_LINE_OVERRIDE" && "$BASE_LINE_OVERRIDE" != "$BASE_BRANCH" ]]; then
echo "Error: --base-line '$BASE_LINE_OVERRIDE' does not match the PR base '$BASE_BRANCH' (refusing; the exception must name the real base)." >&2
exit 1
fi
if [[ "$BASE_LINE_OVERRIDE" == "$BASE_BRANCH" ]]; then
echo "audit: base-line exception — merge into '$BASE_BRANCH' authorized by explicit --base-line (B5 ruling 2026-08-29); queue guard, head pin, and CI gates unchanged." >&2
else
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
echo " A ruled intra-line merge may pass --base-line '$BASE_BRANCH' (same gates; the exception is recorded)." >&2
exit 1
fi
fi
fi
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
@@ -12,13 +12,23 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
OUTPUT_FILE=""
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
echo "Usage: pr-metadata.sh -n <pr_number> [-o <output_file>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-n|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
PR_NUMBER="$2"
shift 2
;;
-o|--output)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
OUTPUT_FILE="$2"
shift 2
;;
@@ -29,18 +39,18 @@ while [[ $# -gt 0 ]]; do
echo " -n, --number PR number (required)"
echo " -o, --output Output file (optional, prints to stdout if omitted)"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure."
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
usage_error "unknown option: $1"
;;
esac
done
if [[ -z "$PR_NUMBER" ]]; then
echo "Error: PR number is required (-n)" >&2
exit 1
usage_error "PR number is required (-n/--number)"
fi
write_metadata() {
@@ -43,60 +43,92 @@ LOGIN_OVERRIDE=""
REPO_OVERRIDE=""
HOST_OVERRIDE=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
# caller or stop gate can tell an invocation defect from a delivery blocker.
usage_error() {
echo "Error: $*" >&2
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-b <comment>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-n|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
PR_NUMBER="$2"
shift 2
;;
-a|--action)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ACTION="$2"
shift 2
;;
-c|--comment)
-b|--body|-c|--comment)
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
# a backward-compatible alias.
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
COMMENT="$2"
shift 2
;;
-l|--login)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LOGIN_OVERRIDE="$2"
shift 2
;;
-r|--repo)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
REPO_OVERRIDE="$2"
shift 2
;;
-H|--host)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
HOST_OVERRIDE="$2"
shift 2
;;
-h|--help)
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>] [-r owner/repo] [-H host]"
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-b <comment>] [--login <name>] [-r owner/repo] [-H host]"
echo ""
echo "Options:"
echo " -n, --number PR number (required)"
echo " -a, --action Review action: approve, request-changes, comment (required)"
echo " -c, --comment Review comment (required for request-changes)"
echo " -b, --body Review comment (required for request-changes; canonical)"
echo " -c, --comment Alias for --body"
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
usage_error "unknown option: $1"
;;
esac
done
if [[ -z "$PR_NUMBER" ]]; then
echo "Error: PR number is required (-n)"
exit 1
usage_error "PR number is required (-n/--number)"
fi
if [[ -z "$ACTION" ]]; then
echo "Error: Action is required (-a): approve, request-changes, comment"
exit 1
usage_error "Action is required (-a/--action): approve, request-changes, comment"
fi
# Validate the action BEFORE any provider contact (codex review of PR #1464:
# an unsupported --action previously reached platform detection and could
# touch the provider before failing with a provider-class status).
case "$ACTION" in
approve|request-changes|comment) ;;
*) usage_error "unknown action '$ACTION': approve, request-changes, comment" ;;
esac
# Body-required actions fail fast too (codex follow-up on PR #1464):
# request-changes and comment both require a body; validate before any
# provider contact.
if [[ ( "$ACTION" == "request-changes" || "$ACTION" == "comment" ) && -z "$COMMENT" ]]; then
usage_error "comment required for $ACTION (-b/--body)"
fi
if [[ -n "$REPO_OVERRIDE" ]]; then
@@ -679,15 +711,18 @@ PY
if [[ "$PLATFORM" == "github" ]]; then
case $ACTION in
approve)
gh pr review "$PR_NUMBER" --approve ${COMMENT:+--body "$COMMENT"}
gh_rc=0
gh pr review "$PR_NUMBER" --approve ${COMMENT:+--body "$COMMENT"} || gh_rc=$?
[[ "$gh_rc" -eq 0 ]] || { echo "Error: GitHub approve failed (gh exit $gh_rc; provider failure, not a usage error)" >&2; exit 1; }
echo "Approved GitHub PR #$PR_NUMBER"
;;
request-changes)
if [[ -z "$COMMENT" ]]; then
echo "Error: Comment required for request-changes"
exit 1
usage_error "comment required for request-changes (-b/--body)"
fi
gh pr review "$PR_NUMBER" --request-changes --body "$COMMENT"
gh_rc=0
gh pr review "$PR_NUMBER" --request-changes --body "$COMMENT" || gh_rc=$?
[[ "$gh_rc" -eq 0 ]] || { echo "Error: GitHub request-changes failed (gh exit $gh_rc; provider failure, not a usage error)" >&2; exit 1; }
echo "Requested changes on GitHub PR #$PR_NUMBER"
;;
comment)
@@ -695,12 +730,13 @@ if [[ "$PLATFORM" == "github" ]]; then
echo "Error: Comment required"
exit 1
fi
gh pr review "$PR_NUMBER" --comment --body "$COMMENT"
gh_rc=0
gh pr review "$PR_NUMBER" --comment --body "$COMMENT" || gh_rc=$?
[[ "$gh_rc" -eq 0 ]] || { echo "Error: GitHub review comment failed (gh exit $gh_rc; provider failure, not a usage error)" >&2; exit 1; }
echo "Added review comment to GitHub PR #$PR_NUMBER"
;;
*)
echo "Error: Unknown action: $ACTION"
exit 1
usage_error "unknown action: $ACTION"
;;
esac
elif [[ "$PLATFORM" == "gitea" ]]; then
@@ -738,8 +774,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
;;
request-changes)
if [[ -z "$COMMENT" ]]; then
echo "Error: Comment required for request-changes"
exit 1
usage_error "comment required for request-changes (-b/--body)"
fi
# Best-effort host for credential resolution only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
@@ -794,8 +829,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
;;
*)
echo "Error: Unknown action: $ACTION"
exit 1
usage_error "unknown action: $ACTION"
;;
esac
else
+14 -4
View File
@@ -11,13 +11,23 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
REPO_OVERRIDE=""
# Usage-error contract (R4): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
echo "Usage: pr-view.sh -n <pr_number> [-r owner/repo] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-n|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
PR_NUMBER="$2"
shift 2
;;
-r|--repo)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
REPO_OVERRIDE="$2"
shift 2
;;
@@ -28,18 +38,18 @@ while [[ $# -gt 0 ]]; do
echo " -n, --number PR number (required)"
echo " -r, --repo Repository slug (default: infer from git origin)"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure."
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
usage_error "unknown option: $1"
;;
esac
done
if [[ -z "$PR_NUMBER" ]]; then
echo "Error: PR number is required (-n)"
exit 1
usage_error "PR number is required (-n/--number)"
fi
if [[ -n "$REPO_OVERRIDE" ]]; then
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# Usage-error contract for issue-assign.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-assign-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-assign.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-assign.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-assign.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -i exits 2"
expect_stderr "Issue number is required" "missing -i message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -i -a -l -m --issue --assignee --labels --milestone; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -i 5 -a --help
expect_rc 2 "short flag value rejected" -i 5 -a -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -a; do
rc=0
run_wrapper_sandboxed -i 5 "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
# 6b. Provider-exit normalization (codex PR #1464): a provider stub exiting
# 2 (its own usage-error status) must surface as wrapper exit 1, never 2.
GH_REPO="$WORK_DIR/repo-gh"
mkdir -p "$GH_REPO"
git -C "$GH_REPO" init -q
git -C "$GH_REPO" remote add origin https://github.com/acme/widgets.git
cat > "$BIN_DIR/gh" <<GHSTUB
#!/usr/bin/env bash
echo "gh \$*" >> "$PROBE_LOG"
if [[ "\$1 \$2" == "issue edit" ]]; then exit 2; fi
exit 0
GHSTUB
chmod +x "$BIN_DIR/gh"
rc=0
(
cd "$GH_REPO"
PATH="$BIN_DIR:$PATH" MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-assign.sh" -i 5 -a someone >"$OUT_FILE" 2>"$ERR_FILE"
) || rc=$?
[[ "$rc" -eq 1 ]] || fail "GitHub path: gh exit 2 must normalize to wrapper exit 1 (got $rc)"
grep -q "provider" "$ERR_FILE" || fail "GitHub path: normalized provider error missing from stderr"
echo "issue-assign.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# Usage-error contract for issue-close.sh (R1/R4, 2026-08-28).
#
# R4: usage errors print to STDERR and exit 2, distinct from provider,
# credential, and verification failures (exit 1). R1: -b/--body is the
# canonical comment flag; -c/--comment remains a compatible alias.
# The comment is OPTIONAL here (an issue may close without one), so unlike
# issue-comment there is no missing-comment arm.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-close-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 0
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-close.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-close.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-close.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "unknown option" "unknown option names itself on stderr"
# 3. Missing required issue number: rc 2, stderr.
expect_rc 2 "missing -i exits 2"
expect_stderr "issue number is required" "missing -i message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -i -b -c --issue --body --comment; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -i 5 -b --help
expect_rc 2 "short flag value rejected" -i 5 -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b -c; do
rc=0
run_wrapper_sandboxed -i 5 "$flag" "closing note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Sandbox arms may issue DETECTION reads only (tea login list via the
# stub); no gh/curl write or read may occur.
if grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" | grep -q .; then
echo "FAIL: a sandbox arm performed a non-detection provider request:" >&2
grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" >&2
exit 1
fi
if grep -qE '^(gh|curl)' "$PROBE_LOG"; then
echo "FAIL: gh or curl was invoked during a sandbox arm:" >&2
grep -E '^(gh|curl)' "$PROBE_LOG" >&2
exit 1
fi
echo "issue-close.sh usage-contract regression passed (R1/R4)"
@@ -42,6 +42,8 @@
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
# success or the failure path — nested function-scoped RETURN traps do not
# clobber each other and every scratch file is removed on all exit paths.
# 11. accepts the canonical -b/--body flag exactly like the -c/--comment alias
# (R1, 2026-08-28): a full verified write via -b alone.
set -euo pipefail
@@ -409,11 +411,28 @@ run_comment() {
seed_state "$mode"
(
cd "$REPO_DIR"
# Provisioned seats export MOSAIC_GIT_IDENTITY and MOSAIC_BRAIN_HOME
# seat-wide (launcher), and both escape this harness's sandboxed HOME:
# detect-platform.sh consults MOSAIC_GIT_IDENTITY BEFORE the repo-local
# mosaic.gitIdentity pin, and resolves the brain home (whose
# fleet/agents presence arms the no-identity fail-loud branch) from
# MOSAIC_BRAIN_HOME before $HOME. Without these explicit empties the
# wrapper either resolves the REAL seat-slot token (stub curl rejects
# it: the documented HTTP 401) or fails loud before any request.
# Set-but-empty reads as unset to detect-platform's "${VAR:-}" forms.
# NOTE: keep this comment block ABOVE the assignment chain — a comment
# inside a backslash-continued prefix chain terminates the command and
# silently demotes every earlier assignment to an unexported subshell
# assignment (measured 2026-08-28: the wrapper then ran without
# MOSAIC_CREDENTIALS_FILE and the suite died at credential resolution
# with zero diagnostic output).
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
HOME="$HOME_DIR" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_GIT_IDENTITY="" \
MOSAIC_BRAIN_HOME="" \
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
@@ -430,7 +449,7 @@ run_comment() {
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
ISSUE_COMMENT_API_BASE="$API_BASE" \
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" "$@"
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" "${BODY_FLAG:--c}" "$BODY" "$@"
) > "$OUTPUT_FILE" 2>&1
}
@@ -614,4 +633,21 @@ done
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
# is not rejecting genuine writes.
# Case 11 (R1, 2026-08-28): -b/--body is the canonical comment flag and must
# drive a full verified write exactly like the -c/--comment alias. BODY_FLAG
# swaps only the flag spelling; every assertion below is case 1's contract.
BODY_FLAG="-b"
run_comment fresh-success
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
echo "FAIL: --body write went through tea instead of REST" >&2
exit 1
fi
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
assert_no_temp_leak "fresh-success-body-flag"
assert_token_not_in_argv "fresh-success-body-flag"
unset BODY_FLAG
echo "issue-comment.sh REST create + exact-id read-back regression passed"
@@ -0,0 +1,172 @@
#!/usr/bin/env bash
# Usage-error contract for issue-comment.sh (R1/R4 remediation, 2026-08-28).
#
# R4: usage errors print to STDERR and exit 2, distinct from provider,
# credential, and verification failures (exit 1), so a caller (or a stop gate)
# can tell an invocation defect from a delivery blocker. Before this contract
# the wrapper exited 1 for usage errors with messages on STDOUT, and a
# value-less flag (-c with no value) died SILENTLY at rc=1 because set -e
# killed the failed `shift 2`. That silent shape is what full-stopped a fleet
# seat: a caller could not distinguish "I invoked it wrong" from "delivery is
# blocked".
#
# R1: -b/--body is the canonical comment flag (matching issue-create,
# issue-edit, pr-create, pr-edit); -c/--comment remains a backward-compatible
# alias.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. Missing required comment exits 2 (stderr).
# 5. A value-less flag (-i -b -c -l and long forms) exits 2 with a
# "requires a value" message on stderr (the former silent-death class).
# 6. -b and -c both pass parsing (the run then fails at platform detection
# in this non-repo fixture, nonzero and NOT 2), proving alias acceptance
# without any provider fixture.
# 7. No arm performs any provider request: PATH shims for gh/tea/curl
# record every invocation and the probe log must stay empty.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Provider shims: any invocation is recorded and fails the run at the end.
# Usage-error arms must exit during argument parsing, before detect_platform,
# so these prove "no provider request on parser failure".
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
# gh doubles as platform probe AND write path in arm 6b: probes exit 0; the
# comment write exits 2 (gh's own usage-error status) to prove the wrapper
# normalizes provider failures to exit 1 instead of propagating 2.
if [[ "\$1 \$2" == "issue comment" ]]; then exit 2; fi
exit 0
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-comment.sh" "$@" )
}
# Hermetic variant for parse-acceptance arms: neutralizes every identity/
# credential source the wrapper consults (seat env vars, HOME, XDG tea config)
# so the arm fails at credential resolution in ANY cwd repo, never reading a
# real token or contacting a provider. Measured 2026-08-28: without this, the
# arm's outcome depended on incidental URL-resolution state (brain cwd died at
# URL-not-found; a stack worktree cwd resolved a configured URL, read the real
# seat token, and invoked the curl stub — the suite then failed its own
# no-provider-contact check, correctly).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-comment.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage on stdout.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-comment.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, message on stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "unknown option" "unknown option names itself on stderr"
# 3. Missing required issue number: rc 2, stderr.
expect_rc 2 "missing -i exits 2"
expect_stderr "issue number is required" "missing -i message on stderr"
# 4. Missing required comment: rc 2, stderr.
expect_rc 2 "missing comment exits 2" -i 5
expect_stderr "comment is required" "missing comment message on stderr"
# 5. Value-less flags: rc 2 with "requires a value" on stderr. The old parser
# died here silently (set -e on the failed shift 2).
for flag in -i -b -c -l --issue --body --comment --login; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -i 5 -b --help
expect_rc 2 "short flag value rejected" -i 5 -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 6. Alias acceptance at parse level: both -b and -c carry a value past
# parsing; the wrapper then fails at platform detection (not a git repo)
# nonzero but NOT as a usage error (rc must not be 2).
for flag in -b -c; do
rc=0
run_wrapper_sandboxed -i 5 "$flag" "some text" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6b. GitHub-path exit normalization (codex blocker on 08a00149): gh's own
# usage errors exit 2; the wrapper must NOT propagate that status (reserved
# for the wrapper's usage-error contract). With a github remote and a gh stub
# whose comment write exits 2, the wrapper must exit 1 with the normalized
# error on stderr.
GH_REPO="$WORK_DIR/repo-gh"
mkdir -p "$GH_REPO"
git -C "$GH_REPO" init -q
git -C "$GH_REPO" remote add origin https://github.com/acme/widgets.git
git -C "$GH_REPO" config mosaic.gitIdentity ""
rc=0
(
cd "$GH_REPO"
PATH="$BIN_DIR:$PATH" MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-comment.sh" -i 5 -b "text" >"$OUT_FILE" 2>"$ERR_FILE"
) || rc=$?
[[ "$rc" -eq 1 ]] || fail "GitHub path: gh exit 2 must normalize to wrapper exit 1 (got $rc)"
grep -q "GitHub comment write failed" "$ERR_FILE" || fail "GitHub path: normalized error missing from stderr"
grep -q "^gh issue comment" "$PROBE_LOG" || fail "GitHub path: gh write was not invoked"
# 7. No provider contact from any usage-error arm (arm 6b's deliberate gh
# invocation is the only permitted entry in the probe log).
if grep -v '^gh issue comment' "$PROBE_LOG" | grep -q .; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
grep -v '^gh issue comment' "$PROBE_LOG" >&2
exit 1
fi
echo "issue-comment.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Usage-error contract for issue-create.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-create.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-create.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-create.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -t exits 2"
expect_stderr "Title is required" "missing -t message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -t -b -l -m --title --body --labels --milestone; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -t smoke -b --help
expect_rc 2 "short flag value rejected" -t smoke -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b; do
rc=0
run_wrapper_sandboxed -t "smoke" "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "issue-create.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Usage-error contract for issue-edit.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-edit-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-edit.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-edit.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-edit.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "unknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -i exits 2"
expect_stderr "issue number is required" "missing -i message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -i -t -b -l -m --issue --title --body --labels --milestone; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -i 5 -b --help
expect_rc 2 "short flag value rejected" -i 5 -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b; do
rc=0
run_wrapper_sandboxed -i 5 "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "issue-edit.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# Usage-error contract for issue-list.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-list-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-list.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-list.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-list.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -s -l -m -a -n -r --state --label --milestone --assignee --limit --repo; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -s --help
expect_rc 2 "short flag value rejected" -s -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -s; do
rc=0
run_wrapper_sandboxed -s open >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "issue-list.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# Usage-error contract for issue-reopen.sh (R1/R4, 2026-08-28).
#
# R4: usage errors print to STDERR and exit 2, distinct from provider,
# credential, and verification failures (exit 1). R1: -b/--body is the
# canonical comment flag; -c/--comment remains a compatible alias.
# The comment is OPTIONAL here (an issue may close without one), so unlike
# issue-comment there is no missing-comment arm.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-reopen-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 0
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-reopen.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-reopen.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-reopen.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "unknown option" "unknown option names itself on stderr"
# 3. Missing required issue number: rc 2, stderr.
expect_rc 2 "missing -i exits 2"
expect_stderr "issue number is required" "missing -i message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -i -b -c --issue --body --comment; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -i 5 -b --help
expect_rc 2 "short flag value rejected" -i 5 -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b -c; do
rc=0
run_wrapper_sandboxed -i 5 "$flag" "closing note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Sandbox arms may issue DETECTION reads only (tea login list via the
# stub); no gh/curl write or read may occur.
if grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" | grep -q .; then
echo "FAIL: a sandbox arm performed a non-detection provider request:" >&2
grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" >&2
exit 1
fi
if grep -qE '^(gh|curl)' "$PROBE_LOG"; then
echo "FAIL: gh or curl was invoked during a sandbox arm:" >&2
grep -E '^(gh|curl)' "$PROBE_LOG" >&2
exit 1
fi
echo "issue-reopen.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Usage-error contract for issue-view.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-view-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-view.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-view.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-view.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -i exits 2"
expect_stderr "Issue number is required" "missing -i message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -i --issue; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -i --help
expect_rc 2 "short flag value rejected" -i -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -i; do
rc=0
run_wrapper_sandboxed -i 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "issue-view.sh usage-contract regression passed (R1/R4)"
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Usage-error contract for lane-brief.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/lane-brief-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/lane-brief.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/lane-brief.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "owner/repo" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -r exits 2"
expect_stderr "required" "missing -r message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -r -m -l -L -n --repo --milestone --label --login --limit; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -r owner/repo -m --help
expect_rc 2 "short flag value rejected" -r owner/repo -m -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -r; do
rc=0
run_wrapper_sandboxed -r owner/repo >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "lane-brief.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Usage-error contract for milestone-close.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/milestone-close-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/milestone-close.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/milestone-close.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: milestone-close.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -t exits 2"
expect_stderr "Milestone title is required" "missing -t message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -t --title; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -t --help --help
expect_rc 2 "short flag value rejected" -t --help -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -t; do
rc=0
run_wrapper_sandboxed -t "smoke" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "milestone-close.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Usage-error contract for milestone-create.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/milestone-create-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/milestone-create.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/milestone-create.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: milestone-create.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -t exits 2"
expect_stderr "Title is required" "missing -t message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -t -d --due --title --desc; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -t smoke -d --help
expect_rc 2 "short flag value rejected" -t smoke -d -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -t; do
rc=0
run_wrapper_sandboxed -t "smoke" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "milestone-create.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# Usage-error contract for milestone-list.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/milestone-list-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/milestone-list.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/milestone-list.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: milestone-list.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -s --state; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -s --help
expect_rc 2 "short flag value rejected" -s -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -s; do
rc=0
run_wrapper_sandboxed -s open >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
# 6b. Provider-exit normalization (codex PR #1464): a provider stub exiting
# 2 (its own usage-error status) must surface as wrapper exit 1, never 2.
GH_REPO="$WORK_DIR/repo-gh"
mkdir -p "$GH_REPO"
git -C "$GH_REPO" init -q
git -C "$GH_REPO" remote add origin https://github.com/acme/widgets.git
cat > "$BIN_DIR/gh" <<GHSTUB
#!/usr/bin/env bash
echo "gh \$*" >> "$PROBE_LOG"
if [[ "\$1" == "api" ]]; then exit 2; fi
exit 0
GHSTUB
chmod +x "$BIN_DIR/gh"
rc=0
(
cd "$GH_REPO"
PATH="$BIN_DIR:$PATH" MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/milestone-list.sh" >"$OUT_FILE" 2>"$ERR_FILE"
) || rc=$?
[[ "$rc" -eq 1 ]] || fail "GitHub path: gh exit 2 must normalize to wrapper exit 1 (got $rc)"
grep -q "provider" "$ERR_FILE" || fail "GitHub path: normalized provider error missing from stderr"
echo "milestone-list.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# Usage-error contract for pr-close.sh (R1/R4, 2026-08-28).
#
# R4: usage errors print to STDERR and exit 2, distinct from provider,
# credential, and verification failures (exit 1). R1: -b/--body is the
# canonical comment flag; -c/--comment remains a compatible alias.
# The comment is OPTIONAL here (an issue may close without one), so unlike
# issue-comment there is no missing-comment arm.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-close-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-close.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-close.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-close.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "unknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -n exits 2"
expect_stderr "PR number is required" "missing -n message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -n -b -c --number --body --comment; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -n 5 -b --help
expect_rc 2 "short flag value rejected" -n 5 -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b -c; do
rc=0
run_wrapper_sandboxed -n 5 "$flag" "closing note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-close.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Usage-error contract for pr-create.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-create-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-create.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-create.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-create.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -t exits 2"
expect_stderr "Title is required" "missing -t message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -t -b -B -H -l -m -i --title --body --base --head --labels --milestone --issue; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -t smoke -b --help
expect_rc 2 "short flag value rejected" -t smoke -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b; do
rc=0
run_wrapper_sandboxed -t "smoke" "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-create.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Usage-error contract for pr-diff.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-diff-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-diff.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-diff.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-diff.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -n exits 2"
expect_stderr "PR number is required" "missing -n message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -n -o -r --number --output --repo --host; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -n --help
expect_rc 2 "short flag value rejected" -n -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -n; do
rc=0
run_wrapper_sandboxed -n 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-diff.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Usage-error contract for pr-edit.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-edit-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-edit.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-edit.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-edit.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -n exits 2"
expect_stderr "Pull request number is required" "missing -n message on stderr"
expect_rc 2 "no edit option exits 2" -n 5
expect_stderr "At least one edit option is required" "no-edit-option message on stderr"
expect_rc 2 "non-integer PR number exits 2" -n abc -t x
expect_stderr "positive integer" "integer check on stderr"
expect_rc 2 "mutually exclusive draft/ready exits 2" -n 5 --draft --ready
expect_stderr "mutually exclusive" "mutual exclusion on stderr"
expect_rc 2 "bad repo format exits 2" -n 5 -t x -r not-a-slug
expect_stderr "OWNER/REPO" "repo format on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -n -t -b -B -l -r -H --number --title --body --base --login --repo --host; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -n 5 -t smoke -b --help
expect_rc 2 "short flag value rejected" -n 5 -t smoke -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b; do
rc=0
run_wrapper_sandboxed -n 5 "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-edit.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Usage-error contract for pr-list.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-list-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-list.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-list.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-list.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -s -l -a -n -r --state --label --author --limit --repo; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -s --help
expect_rc 2 "short flag value rejected" -s -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -s; do
rc=0
run_wrapper_sandboxed -s open >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-list.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# B5 (ruled 2026-08-29, orch-01-adopted): pr-merge --base-line — the
# documented intra-line exception. Arms:
# 1. Base neither main nor next, no flag: policy refusal (rc 1), queue
# guard NOT invoked, hint names the exception.
# 2. Base neither main nor next, matching --base-line: authorized; the
# queue guard IS invoked with the same args (rc from the stub proves
# gates still run) and the audit line is emitted.
# 3. Mismatched --base-line (different branch than the PR base): refusal
# rc 1 with the mismatch named; queue guard NOT invoked.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-baseline}"
FIXTURE_DIR="$WORK_DIR/tools/git"
CALL_LOG="$WORK_DIR/queue-call.log"
OUT_LOG="$WORK_DIR/out.log"
rm -rf "$WORK_DIR"
mkdir -p "$FIXTURE_DIR"
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"mosaic-cli-p0","baseRepository":"mosaicstack/stack","headRefName":"mosaic-cli-p2-socket-res","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}'
SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
exit 42
SH
chmod +x "$FIXTURE_DIR"/*.sh
run_case() { # run_case <extra-args...>
: > "$CALL_LOG"
set +e
(
cd "$WORK_DIR"
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
"$FIXTURE_DIR/pr-merge.sh" -n 123 "$@"
) >"$OUT_LOG" 2>&1
rc=$?
set -e
}
# 1. No flag: policy refusal, no gate invocation.
run_case
[[ "$rc" -eq 1 ]] || { echo "FAIL arm1: rc=$rc want 1" >&2; cat "$OUT_LOG" >&2; exit 1; }
[[ ! -s "$CALL_LOG" ]] || { echo "FAIL arm1: queue guard ran without authorization" >&2; exit 1; }
grep -q "only for PRs targeting" "$OUT_LOG" || { echo "FAIL arm1: policy message missing" >&2; exit 1; }
grep -q -- "--base-line 'mosaic-cli-p0'" "$OUT_LOG" || { echo "FAIL arm1: hint missing" >&2; exit 1; }
# 2. Matching flag: authorized, audit line emitted, gates RUN (stub rc 42).
run_case --base-line mosaic-cli-p0
[[ "$rc" -eq 42 ]] || { echo "FAIL arm2: rc=$rc want 42 (gate stub rc must propagate)" >&2; cat "$OUT_LOG" >&2; exit 1; }
[[ -s "$CALL_LOG" ]] || { echo "FAIL arm2: queue guard NOT invoked despite authorization" >&2; exit 1; }
grep -q -- '-B mosaic-cli-p2-socket-res' "$CALL_LOG" || { echo "FAIL arm2: guard args wrong" >&2; cat "$CALL_LOG" >&2; exit 1; }
grep -q "base-line exception" "$OUT_LOG" || { echo "FAIL arm2: audit line missing" >&2; exit 1; }
# 3. Mismatched flag: refusal, mismatch named, no gate invocation.
run_case --base-line some-other-line
[[ "$rc" -eq 1 ]] || { echo "FAIL arm3: rc=$rc want 1" >&2; cat "$OUT_LOG" >&2; exit 1; }
[[ ! -s "$CALL_LOG" ]] || { echo "FAIL arm3: queue guard ran on a refused merge" >&2; exit 1; }
grep -q "does not match the PR base" "$OUT_LOG" || { echo "FAIL arm3: mismatch message missing" >&2; exit 1; }
echo "pr-merge --base-line exception regression passed (B5)"
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Usage-error contract for pr-metadata.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-metadata.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-metadata.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-metadata.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -n exits 2"
expect_stderr "PR number is required" "missing -n message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -n -o --number --output; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -n --help
expect_rc 2 "short flag value rejected" -n -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -n; do
rc=0
run_wrapper_sandboxed -n 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-metadata.sh usage-contract regression passed (R1/R4)"
View File
@@ -218,7 +218,7 @@ if grep -q 'Unknown option' "$OUTPUT_FILE"; then
cat "$OUTPUT_FILE" >&2
exit 1
fi
grep -q 'Unknown action: bogus-action' "$OUTPUT_FILE"
grep -q "unknown action 'bogus-action'" "$OUTPUT_FILE"
# --- Case 2: -h/--help documents both overrides.
HELP_TEXT="$("$SCRIPT_DIR/pr-review.sh" -h)"
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# Usage-error contract for pr-review.sh (R1/R4, 2026-08-28).
#
# R4: usage errors print to STDERR and exit 2, distinct from provider,
# credential, and verification failures (exit 1). R1: -b/--body is the
# canonical comment flag; -c/--comment remains a compatible alias.
# Required: -n AND -a. The comment is required only for the
# request-changes action (semantic usage check, also rc 2).
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-review.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-review.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-review.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "unknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -n exits 2"
expect_stderr "PR number is required" "missing -n message on stderr"
expect_rc 2 "missing -a exits 2" -n 5
expect_stderr "Action is required" "missing -a message on stderr"
expect_rc 2 "request-changes without comment exits 2" -n 5 -a request-changes
expect_stderr "comment required for request-changes" "request-changes message on stderr"
expect_rc 2 "comment without body exits 2 pre-detection" -n 5 -a comment
expect_stderr "comment required for comment" "comment-without-body message on stderr"
expect_rc 2 "invalid action exits 2 pre-detection" -n 5 -a bogus
expect_stderr "unknown action" "invalid action message on stderr"
# Invalid-action arms must not contact any provider (validation precedes
# detect_platform): probe log empty at this point.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: an invalid-action arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -n -a -b -c -l -r --number --action --body --comment --login --repo; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -n 5 -a comment -b --help
expect_rc 2 "short flag value rejected" -n 5 -a comment -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b -c; do
rc=0
run_wrapper_sandboxed -n 5 -a comment "$flag" "review note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-review.sh usage-contract regression passed (R1/R4)"
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Usage-error contract for pr-view.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-view-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-view.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-view.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-view.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -n exits 2"
expect_stderr "PR number is required" "missing -n message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -n -r --number --repo; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -n --help
expect_rc 2 "short flag value rejected" -n -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -n; do
rc=0
run_wrapper_sandboxed -n 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-view.sh usage-contract regression passed (R1/R4)"
@@ -14,7 +14,6 @@
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh | resolves real credentials (#1007 census, fifth entry); joins CI after the wrapper-half hermeticity fix
# --- tools/git: push guards — measured green locally, CI-image fitness unverified ---
packages/mosaic/framework/tools/git/test-push-guard.sh | measured green at 826a8b3b (46 passed / 0 failed, one run, 2026-07-31); CI-image fitness unverified; #1017 burndown
@@ -35,6 +35,9 @@
#
# OPTIONS
# -L NAME tmux socket name passed to `tmux -L NAME` on the target host
#
# Exit 4: local target session exists on multiple socket servers and no
# -L / MOSAIC_TMUX_SOCKET disambiguated it (B1 stale-twin guard).
# -s DST_SESSION target tmux session (or session:window.pane) [required]
# -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local
# -n DST_HOST hostname to show in the preamble for the target.
@@ -63,8 +66,10 @@
# group 1 = src label group 2 = dst host:session
# group 3 = class (absent => actionable) group 4 = message body
#
# EXIT CODES (passed through from send-message.sh)
# EXIT CODES (passed through from send-message.sh, except 4)
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
# 4 agent-send refusal: local target session exists on multiple socket
# servers and no -L / MOSAIC_TMUX_SOCKET disambiguated it (B1)
set -uo pipefail
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
@@ -154,6 +159,61 @@ FULL="${PREAMBLE} ${MSG}"
B64=$(printf '%s' "$FULL" | base64 -w0)
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
# Exact session matching for the sender target (codex PR #1466): without
# '=', tmux target syntax accepts an unambiguous PREFIX, so a delivery
# aimed at session X can land in X-old. Compound targets (session:win.pane)
# and already-exact ('=...') forms pass through untouched. Computed BEFORE
# socket discovery so the discovery probes use the same target semantics
# (probing '==name' for an already-exact input was a false-negative hit).
DST_TARGET="$DST_SESSION"
case "$DST_SESSION" in
=*) ;;
*:*)
# Compound target (session:win.pane): pin the SESSION component exact
# (=session:win.pane); unpinned, the session part still prefix-matches
# (codex PR #1466: 'agent:0.0' can resolve into 'agent-old').
DST_TARGET="=${DST_SESSION%%:*}:${DST_SESSION#*:}"
;;
*) DST_TARGET="=$DST_SESSION" ;;
esac
# Socket default resolution (B1, 2026-08-29). Precedence: explicit -L >
# launcher-exported MOSAIC_TMUX_SOCKET > unique socket hit > refusal on
# ambiguity > tmux default socket. The ambiguity refusal fires ONLY when
# no explicit or env choice exists and the session name lives on multiple
# servers (measured 2026-08-28/29: tasking sends landed in a stale
# default-socket twin; rc 0 reported honest delivery to the wrong pane).
# Socket discovery scans tmux's own socket dir, ${TMUX_TMPDIR:-/tmp}/tmux-UID
# (codex PR #1466: TMPDIR is not where tmux keeps -L sockets).
# MOSAIC_TMUX_SOCKET is LOCAL-host state (launcher-exported): it must not
# leak into remote sends, where -L would target a socket on the remote
# host (codex PR #1466).
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
fi
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ]; then
socket_dir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
hits=""
for sf in "$socket_dir"/*; do
[ -S "$sf" ] || continue
sname="${sf##*/}"
# '=' forces exact session-name matching: tmux target syntax otherwise
# accepts an unambiguous PREFIX, so a session named X-old on a socket
# would count as a false hit for target X (codex PR #1466).
tmux -L "$sname" has-session -t "$DST_TARGET" 2>/dev/null && hits="$hits$sname"$'\n'
done
hit_count=$(printf '%s' "$hits" | grep -c . || true)
if [ "$hit_count" -gt 1 ]; then
echo "agent-send.sh: REFUSING - session '$DST_SESSION' exists on multiple sockets:" >&2
printf ' %s\n' $hits >&2
echo " Pass -L <socket> explicitly (or export MOSAIC_TMUX_SOCKET to disambiguate)." >&2
exit 4
elif [ "$hit_count" -eq 1 ]; then
SOCKET_NAME="$(printf '%s' "$hits")"
fi
fi
socket_args=()
if [ -n "$SOCKET_NAME" ]; then
socket_args=(-L "$SOCKET_NAME")
@@ -161,9 +221,9 @@ fi
if [ -z "$SSH_TARGET" ]; then
# Local pane: call the canonical sender directly.
exec "$SENDER" "${socket_args[@]}" -t "$DST_SESSION" -b "$B64" -r "$RETRIES" $vflag
exec "$SENDER" "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES" $vflag
else
# Remote pane: ship the sender over ssh and run it local to the target.
ssh -o ConnectTimeout=10 "$SSH_TARGET" \
"bash -s -- ${socket_args[*]@Q} -t '$DST_SESSION' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
"bash -s -- ${socket_args[*]@Q} -t '$DST_TARGET' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
fi
@@ -8,7 +8,15 @@ SOCKET="mosaic-test-$RANDOM-$$"
TARGET="target-$RANDOM"
DEFAULT_TARGET="default-target-$RANDOM"
TMPDIR=$(mktemp -d)
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; tmux kill-session -t "$DEFAULT_TARGET" >/dev/null 2>&1 || true; rm -rf "$TMPDIR"' EXIT
ART_OUT=$(mktemp)
AMB_OUT=$(mktemp)
AMB_ERR=$(mktemp)
A2_OUT=$(mktemp)
A2_ERR=$(mktemp)
UNIQ_OUT=$(mktemp)
UNIQ_ERR=$(mktemp)
TWIN="twin-$RANDOM-$$"
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; tmux kill-session -t "$DEFAULT_TARGET" >/dev/null 2>&1 || true; tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true; rm -rf "$TMPDIR" $ART_OUT $AMB_OUT $AMB_ERR $A2_OUT $A2_ERR $UNIQ_OUT $UNIQ_ERR' EXIT
fail() {
echo "FAIL: $*" >&2
@@ -79,4 +87,83 @@ for i in $(seq 1 "$CONC_N"); do
done
done
# B1 (2026-08-29): socket default resolution in agent-send.sh. Measured
# defect: tasking sends without -L landed in a stale default-socket twin of
# the target seat; rc 0 reported honest delivery to the wrong pane.
# Arm A: session on MULTIPLE sockets, no -L -> refuse with rc 4 naming both.
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1=" " exec bash --noprofile --norc -i'
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1=" " exec bash --noprofile --norc -i'
amb_rc=0
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "must refuse" >$AMB_OUT 2>$AMB_ERR || amb_rc=$?
[ "$amb_rc" -eq 4 ] || fail "ambiguity refusal: rc=$amb_rc want 4 (stderr: $(cat $AMB_ERR))"
grep -q "multiple sockets" $AMB_ERR || fail "ambiguity refusal message missing socket list"
grep -qF "$SOCKET" $AMB_ERR || fail "ambiguity refusal message does not name the test socket"
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
# Arm A2: with MOSAIC_TMUX_SOCKET exported, a twin session is NOT ambiguous:
# the env var disambiguates by precedence (codex PR #1466 blocker).
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1=" " exec bash --noprofile --norc -i'
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1=" " exec bash --noprofile --norc -i'
a2_rc=0
MOSAIC_TMUX_SOCKET="$SOCKET" "$AGENT_SEND" -s "$TWIN" -m "env disambiguated" >$A2_OUT 2>$A2_ERR || a2_rc=$?
[ "$a2_rc" -eq 0 ] || fail "env disambiguation: rc=$a2_rc (stderr: $(cat $A2_ERR))"
sleep 0.2
a2_pane="$(tmux -L "$SOCKET" capture-pane -t "=$TWIN:0.0" -p)" || fail "cannot capture twin (arm A2)"
grep -qF "env disambiguated" <<<"$a2_pane" || fail "env disambiguation did not deliver on the named socket"
a2_default="$(tmux capture-pane -t "=$TWIN:0.0" -p)" || true
if grep -qF "env disambiguated" <<<"$a2_default"; then
fail "env disambiguation cross-delivered to the default-socket twin"
fi
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
# Arm B: session unique to ONE socket, no -L -> auto-resolve to that socket
# and deliver there.
# Arm A3: prefix matching must not produce false socket hits (codex PR
# #1466): a session named TWIN-old must not count as a hit for target
# TWIN (tmux target syntax prefix-matches without '=').
PSEUDO="${TWIN}-old"
tmux new-session -d -s "$PSEUDO" -c "$TMPDIR" 'PS1=" " exec bash --noprofile --norc -i'
A3_ERR=$(mktemp)
a3_rc=0
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "prefix trap" >/dev/null 2>"$A3_ERR" || a3_rc=$?
# TWIN exists nowhere (both twins killed after arm A2); with '=' the
# PSEUDO session is not a hit, so the sender must fail target-not-found
# (rc 1) instead of delivering into the prefix-named session.
[ "$a3_rc" -eq 1 ] || fail "prefix false-hit: rc=$a3_rc want 1 (stderr: $(cat "$A3_ERR"))"
a3_pane="$(tmux capture-pane -t "=$PSEUDO:0.0" -p 2>/dev/null)" || a3_pane=""
if printf '%s' "$a3_pane" | grep -F "prefix trap" >/dev/null; then
fail "delivery landed in the prefix-named session (false socket hit)"
fi
tmux kill-session -t "$PSEUDO" >/dev/null 2>&1 || true
rm -f "$A3_ERR"
# Arm A4: compound targets pin the SESSION component exact (codex PR
# #1466): 'TWIN:0.0' must not resolve into the prefix-named session.
PSEUDO2="${TWIN}-old"
tmux new-session -d -s "$PSEUDO2" -c "$TMPDIR" 'PS1=" " exec bash --noprofile --norc -i'
A4_ERR=$(mktemp)
a4_rc=0
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN:0.0" -m "compound trap" >/dev/null 2>"$A4_ERR" || a4_rc=$?
[ "$a4_rc" -eq 1 ] || fail "compound prefix false-hit: rc=$a4_rc want 1 (stderr: $(cat "$A4_ERR"))"
a4_pane="$(tmux capture-pane -t "=$PSEUDO2:0.0" -p 2>/dev/null)" || a4_pane=""
if printf '%s' "$a4_pane" | grep -F "compound trap" >/dev/null; then
fail "compound delivery landed in the prefix-named session"
fi
tmux kill-session -t "$PSEUDO2" >/dev/null 2>&1 || true
rm -f "$A4_ERR"
uniq_rc=0
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TARGET" -m "autoresolved hello" >$UNIQ_OUT 2>$UNIQ_ERR || uniq_rc=$?
[ "$uniq_rc" -eq 0 ] || fail "unique auto-resolution: rc=$uniq_rc (stderr: $(cat $UNIQ_ERR))"
sleep 0.2
auto_pane="$(capture_named)" || fail "could not capture named socket pane (arm B)"
grep -qF "autoresolved hello" <<<"$auto_pane" || fail "auto-resolution did not deliver to the named-socket pane"
default_pane2="$(capture_default)" || fail "could not capture default socket pane (arm B)"
if grep -qF "autoresolved hello" <<<"$default_pane2"; then
fail "auto-resolution cross-delivered to the default socket pane"
fi
echo "ok - named tmux socket send tools"
File diff suppressed because one or more lines are too long
+6
View File
@@ -11,6 +11,9 @@ import { registerQualityRails } from '@mosaicstack/quality-rails';
import { registerQueueCommand } from '@mosaicstack/queue';
import { registerStorageCommand } from '@mosaicstack/storage';
import { registerTelemetryCommand } from './commands/telemetry.js';
import { registerCommsCommand } from './commands/comms.js';
import { registerQCommand } from './commands/q.js';
import { registerWatchCommand } from './commands/watch.js';
import { registerAgentCommand } from './commands/agent.js';
import { registerInteractionCommand } from './commands/interaction.js';
import { registerConfigCommand } from './commands/config.js';
@@ -428,6 +431,9 @@ registerSkillCommand(program);
// ─── telemetry ───────────────────────────────────────────────────────────────
registerTelemetryCommand(program);
registerWatchCommand(program);
registerQCommand(program);
registerCommsCommand(program);
// ─── update ─────────────────────────────────────────────────────────────
@@ -0,0 +1,67 @@
import { spawnSync } from 'node:child_process';
import { accessSync, constants } from 'node:fs';
import { join } from 'node:path';
import { resolveBrainHome } from '../fleet/brain-home.js';
/**
* Shared brain-tool dispatch (fleet CLI integration, Jason ruling
* 2026-08-28): the npm package embeds the COMMAND SURFACE; operator-owned
* implementations stay in the brain (tools/). Commands resolve the brain
* home (MOSAIC_BRAIN_HOME wins see brain-home.ts) and exec the tool
* there. Nothing operator-specific ships inside the package.
*
* Pass-through contract: arguments, stdout/stderr, and the exit code belong
* to the tool. The CLI adds nothing on success; absent tools fail loudly
* with the resolved path (127) instead of guessing.
*/
/** Absolute path of a brain-relative tool. */
export function resolveBrainTool(mosaicHome: string, relPath: string): string {
return join(resolveBrainHome(mosaicHome), ...relPath.split('/'));
}
/** Map a spawnSync result + tool existence to the CLI exit status. */
export function exitStatusFor(
result: { status: number | null; error?: NodeJS.ErrnoException },
toolExists: boolean,
): number {
if (!toolExists) return 127;
if (result.status !== null) return result.status;
return 125; // killed by signal / could not run
}
export function brainToolExists(tool: string): boolean {
try {
accessSync(tool, constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Exec a brain tool with full pass-through. `interpreter` runs the tool
* through e.g. python3 (renderers); omit it for executable scripts.
* Returns the process exit status; callers assign it to process.exitCode.
*/
export function execBrainTool(
mosaicHome: string,
relPath: string,
args: string[],
interpreter?: string,
): number {
const tool = resolveBrainTool(mosaicHome, relPath);
if (!brainToolExists(tool)) {
console.error(
`mosaic: brain tool not found (expected ${tool}). ` +
'Tool suites live in the brain tree under tools/; ' +
'check MOSAIC_BRAIN_HOME or the brain checkout.',
);
return 127;
}
const result = interpreter
? spawnSync(interpreter, [tool, ...args], { stdio: 'inherit', env: process.env })
: spawnSync(tool, args, { stdio: 'inherit', env: process.env });
return exitStatusFor(result, true);
}
+143
View File
@@ -0,0 +1,143 @@
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
writeFileSync,
readFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it } from 'vitest';
import { fleetCommsSendArgs, registerCommsCommand, tmuxSendArgs } from './comms.js';
describe('arg translation', () => {
it('tmux path: -s/-C/-L/-f/-m per agent-send.sh getopts', () => {
expect(tmuxSendArgs('orch-01', 'hello', {})).toEqual(['-s', 'orch-01', '-m', 'hello']);
expect(
tmuxSendArgs('orch-01', 'unused', {
class: 'actionable',
socket: 'mosaic-fleet',
file: '/tmp/body.txt',
}),
).toEqual(['-s', 'orch-01', '-C', 'actionable', '-L', 'mosaic-fleet', '-f', '/tmp/body.txt']);
});
it('fleet-comms path: -t site/agent and -c class', () => {
expect(fleetCommsSendArgs('usc', 'fred', 'hi', {})).toEqual(['-t', 'usc/fred', '-m', 'hi']);
expect(fleetCommsSendArgs('usc', 'fred', 'hi', { class: 'human' })).toEqual([
'-t',
'usc/fred',
'-c',
'human',
'-m',
'hi',
]);
});
});
describe('registerCommsCommand routing', () => {
const savedBrain = process.env['MOSAIC_BRAIN_HOME'];
const savedRepo = process.env['MOSAIC_FLEET_COMMS_REPO'];
const savedAgent = process.env['MOSAIC_AGENT_NAME'];
afterEach(() => {
for (const [k, v] of [
['MOSAIC_BRAIN_HOME', savedBrain],
['MOSAIC_FLEET_COMMS_REPO', savedRepo],
['MOSAIC_AGENT_NAME', savedAgent],
] as const) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
process.exitCode = undefined;
});
function fixture(): { brain: string; repo: string; tmuxLog: string; commsLog: string } {
const brain = mkdtempSync(join(tmpdir(), 'comms-brain-'));
const repo = mkdtempSync(join(tmpdir(), 'comms-repo-'));
mkdirSync(join(brain, 'tools', 'tmux'), { recursive: true });
mkdirSync(join(repo, 'tools'), { recursive: true });
const tmuxLog = join(brain, 'tmux.log');
const commsLog = join(repo, 'comms.log');
writeFileSync(
join(brain, 'tools', 'tmux', 'agent-send.sh'),
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(tmuxLog)}\nexit 7\n`,
);
writeFileSync(
join(repo, 'tools', 'comms-send.sh'),
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(commsLog)}\nprintf 'FLEET_COMMS_REPO=%s FLEET_COMMS_SITE=%s\\n' "$FLEET_COMMS_REPO" "$FLEET_COMMS_SITE" >> ${JSON.stringify(commsLog)}\nexit 5\n`,
);
chmodSync(join(brain, 'tools', 'tmux', 'agent-send.sh'), 0o755);
chmodSync(join(repo, 'tools', 'comms-send.sh'), 0o755);
process.env['MOSAIC_BRAIN_HOME'] = brain;
process.env['MOSAIC_FLEET_COMMS_REPO'] = repo;
process.env['MOSAIC_AGENT_NAME'] = 'tester';
return { brain, repo, tmuxLog, commsLog };
}
it('default routes same-host via agent-send with translated flags and passes rc through', async () => {
const f = fixture();
const program = new Command();
registerCommsCommand(program);
await program.parseAsync(
[
'comms',
'send',
'orch-01',
'--class',
'actionable',
'--socket',
'mosaic-fleet',
'verdict',
'landed',
],
{ from: 'user' },
);
expect(process.exitCode).toBe(7);
expect(readFileSync(f.tmuxLog, 'utf8').trim()).toBe(
'-s orch-01 -C actionable -L mosaic-fleet -m verdict landed',
);
expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked
});
it('--site routes inter-site via comms-send with site-prefixed target and passes rc through', async () => {
const f = fixture();
const program = new Command();
registerCommsCommand(program);
await program.parseAsync(
['comms', 'send', 'fred', '--site', 'usc', '--class', 'human', 'hello', 'there'],
{ from: 'user' },
);
expect(process.exitCode).toBe(5);
expect(readFileSync(f.commsLog, 'utf8').split('\n')[0]?.trim()).toBe(
'-t usc/fred -c human -m hello there',
);
// The sender must bind comms-send.sh to the SELECTED repo (codex 9c8b6ebf).
expect(readFileSync(f.commsLog, 'utf8')).toContain(
`FLEET_COMMS_REPO=${f.repo} FLEET_COMMS_SITE=usc`,
);
expect(existsSync(f.tmuxLog)).toBe(false); // same-host tool never invoked
});
it('inter-site without MOSAIC_AGENT_NAME is an invocation defect (exit 2)', async () => {
const f = fixture();
delete process.env['MOSAIC_AGENT_NAME'];
const program = new Command();
registerCommsCommand(program);
await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' });
expect(process.exitCode).toBe(2);
expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked
});
it('missing fleet-comms repo fails 127 naming the expected path', async () => {
fixture();
process.env['MOSAIC_FLEET_COMMS_REPO'] = '/nonexistent-comms-repo';
const program = new Command();
registerCommsCommand(program);
await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' });
expect(process.exitCode).toBe(127);
});
});
+135
View File
@@ -0,0 +1,135 @@
import type { Command } from 'commander';
import { spawnSync } from 'node:child_process';
import { accessSync, constants } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import { execBrainTool } from './brain-dispatch.js';
/**
* `mosaic comms send` routed agent messaging (FLEET-COMMS.md doctrine).
*
* Same-host (default): brain tools/tmux/agent-send.sh. Inter-site
* (--site <site>): the fleet-comms repo's comms-send.sh never for
* local traffic (a git round trip per message; Jason 2026-08-28).
*
* Exit codes pass through BOTH paths. rc=2 (text in pane, still draft) is
* a CONTRACT, not a failure: never retry, confirm with capture-pane.
*/
export interface CommsSendOptions {
readonly class?: string;
readonly file?: string;
readonly socket?: string;
readonly site?: string;
readonly commsRepo?: string;
}
export function defaultCommsRepo(): string {
return process.env['MOSAIC_FLEET_COMMS_REPO'] ?? join(homedir(), 'src', 'fleet-comms');
}
/** Build the agent-send.sh argv for the same-host path. */
export function tmuxSendArgs(target: string, message: string, opts: CommsSendOptions): string[] {
const args = ['-s', target];
if (opts.class) args.push('-C', opts.class);
if (opts.socket) args.push('-L', opts.socket);
if (opts.file) args.push('-f', opts.file);
else args.push('-m', message);
return args;
}
/** Build the comms-send.sh argv for the inter-site path. */
export function fleetCommsSendArgs(
site: string,
target: string,
message: string,
opts: CommsSendOptions,
): string[] {
const args = ['-t', `${site}/${target}`];
if (opts.class) args.push('-c', opts.class);
args.push('-m', message);
return args;
}
export function registerCommsCommand(program: Command): void {
const cmd: Command = program
.command('comms')
.description(
'Routed agent messaging: tmux same-host (default), fleet-comms inter-site (--site)',
)
.command('send')
.description('send <target> [message...] — same-host tmux unless --site is given')
.option('--class <class>', 'terminal-log | actionable | human | reaction | digest')
.option('--file <path>', 'message body from file (same-host path only)')
.option('--socket <name>', 'tmux socket for the same-host send (e.g. mosaic-fleet)')
.option('--site <site>', 'route via fleet-comms to <site>/<target>')
.option('--comms-repo <path>', 'fleet-comms checkout', defaultCommsRepo())
.argument('<target>', 'destination seat (session name)')
.argument('[message...]', 'message text (joined; or use --file)')
.action(
async (
target: string,
messageWords: string[],
opts: CommsSendOptions & Record<string, unknown>,
command: Command,
) => {
let mosaicHome: string | undefined;
for (let anc: Command | null = command; anc; anc = anc.parent) {
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
if (v !== undefined) {
mosaicHome = v;
break;
}
}
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
const message = messageWords.join(' ');
if (opts.site) {
const repo = opts.commsRepo ?? defaultCommsRepo();
const tool = join(repo, 'tools', 'comms-send.sh');
try {
accessSync(tool, constants.X_OK);
} catch {
console.error(
`mosaic comms: fleet-comms sender not found (expected ${tool}). ` +
'Clone the fleet-comms repo or point --comms-repo at it.',
);
process.exitCode = 127;
return;
}
if (!process.env['MOSAIC_AGENT_NAME']) {
console.error(
'mosaic comms: inter-site sends require MOSAIC_AGENT_NAME (sending identity).',
);
process.exitCode = 2; // invocation defect: fixable by the caller
return;
}
// comms-send.sh locates its working repo via FLEET_COMMS_REPO
// (default $HOME/src/fleet-comms); without this, --comms-repo
// would select the executable but not the repository it operates
// on (codex review of 9c8b6ebf).
const env = { ...process.env, FLEET_COMMS_SITE: opts.site, FLEET_COMMS_REPO: repo };
const result = spawnSync(tool, fleetCommsSendArgs(opts.site, target, message, opts), {
stdio: 'inherit',
env,
});
process.exitCode = result.status ?? 125;
return;
}
// Same-host: the brain tool owns validation (bad class -> its rc 3)
// and absence (execBrainTool -> 127 with the resolved path).
process.exitCode = execBrainTool(
home,
'tools/tmux/agent-send.sh',
tmuxSendArgs(target, message, opts),
);
},
);
cmd.addHelpText(
'after',
'\nExit codes pass through. rc=2 means the text reached the pane but is still a draft: NEVER retry (double-send); confirm with tmux capture-pane.',
);
}
@@ -106,6 +106,7 @@ describe('registerFleetCommand', () => {
'init',
'install',
'install-systemd',
'logins',
'migrate-v1',
'persona',
'plan',
+19 -1
View File
@@ -17,7 +17,7 @@ import { homedir, hostname, userInfo } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fleetAgentEnvDir } from '../fleet/brain-home.js';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import { spawn, spawnSync } from 'node:child_process';
import * as readline from 'node:readline';
import type { Command } from 'commander';
import YAML from 'yaml';
@@ -1606,6 +1606,24 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.option('--mosaic-home <path>', 'Mosaic home directory', paths.mosaicHome)
.option('--roster <path>', 'Fleet roster path');
// fleet logins (P4 gap closure, 2026-08-29): project per-seat git tokens
// from the brain into usable credentials. The implementation is the
// framework fleet tool; args pass through verbatim (--apply --seat X).
cmd
.command('logins')
.description('Per-seat git credentials: seat-logins.sh pass-through (--apply --seat <name>)')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((...rest: unknown[]) => {
const cmdObj = rest[rest.length - 1] as { args: string[] };
const script = `${frameworkRoot}/tools/fleet/seat-logins.sh`;
const result = spawnSync('bash', [script, ...cmdObj.args], {
stdio: 'inherit',
env: process.env,
});
process.exit(result.status ?? 125);
});
cmd
.command('init')
.description('Initialize a local fleet roster')
+3
View File
@@ -1162,6 +1162,8 @@ const COORD_SUBCMDS: Record<string, string> = {
test: 'smoke-test.sh',
resume: 'session-resume.sh',
recover: 'session-resume.sh',
board: 'board-roll.sh',
roll: 'board-roll.sh',
};
function runCoord(args: string[]): never {
@@ -1195,6 +1197,7 @@ Commands:
run [--project <path>] Launch runtime with mission context
smoke Run orchestration smoke checks
resume [--project <path>] Crash recovery
board Roll the orchestration board (board-roll.sh)
Runtime: --claude (default) | --codex | --pi | --yolo`);
process.exit(subcmd === 'help' ? 0 : 1);
+91
View File
@@ -0,0 +1,91 @@
import { mkdirSync, mkdtempSync, writeFileSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it } from 'vitest';
import { registerQCommand, resolveQuestionTool } from './q.js';
import { exitStatusFor, resolveBrainTool } from './brain-dispatch.js';
describe('resolveBrainTool', () => {
const saved = process.env['MOSAIC_BRAIN_HOME'];
afterEach(() => {
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
else process.env['MOSAIC_BRAIN_HOME'] = saved;
});
it('joins brain home with the relative tool path', () => {
const tmp = mkdtempSync(join(tmpdir(), 'dispatch-resolve-'));
process.env['MOSAIC_BRAIN_HOME'] = tmp;
expect(resolveBrainTool('/nonexistent/mosaic-home', 'tools/questions/q-new.sh')).toBe(
join(tmp, 'tools', 'questions', 'q-new.sh'),
);
});
});
describe('resolveQuestionTool', () => {
const saved = process.env['MOSAIC_BRAIN_HOME'];
afterEach(() => {
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
else process.env['MOSAIC_BRAIN_HOME'] = saved;
});
it('maps new/render subcommands to their brain tools', () => {
const tmp = mkdtempSync(join(tmpdir(), 'q-resolve-'));
process.env['MOSAIC_BRAIN_HOME'] = tmp;
expect(resolveQuestionTool(tmp, 'new')).toBe(join(tmp, 'tools', 'questions', 'q-new.sh'));
expect(resolveQuestionTool(tmp, 'render')).toBe(join(tmp, 'tools', 'questions', 'render.py'));
expect(resolveQuestionTool(tmp, 'bogus')).toBeUndefined();
});
});
describe('registerQCommand usage + dispatch', () => {
const saved = process.env['MOSAIC_BRAIN_HOME'];
afterEach(() => {
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
else process.env['MOSAIC_BRAIN_HOME'] = saved;
process.exitCode = undefined;
});
it('exit 2 with the subcommand list when no/unknown subcommand', async () => {
process.env['MOSAIC_BRAIN_HOME'] = mkdtempSync(join(tmpdir(), 'q-usage-'));
const program = new Command();
registerQCommand(program);
await program.parseAsync(['q'], { from: 'user' });
expect(process.exitCode).toBe(2);
process.exitCode = undefined;
await program.parseAsync(['q', 'bogus'], { from: 'user' });
expect(process.exitCode).toBe(2);
process.exitCode = undefined;
// Reserved property names must not leak through the record lookup.
await program.parseAsync(['q', 'toString'], { from: 'user' });
expect(process.exitCode).toBe(2);
});
it('execs the brain tool with pass-through args and exit code', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'q-live-'));
process.env['MOSAIC_BRAIN_HOME'] = tmp;
mkdirSync(join(tmp, 'tools', 'questions'), { recursive: true });
const stub = join(tmp, 'tools', 'questions', 'q-new.sh');
writeFileSync(stub, '#!/usr/bin/env bash\necho "called with: $*"\nexit 7\n');
chmodSync(stub, 0o755);
const program = new Command();
registerQCommand(program);
await program.parseAsync(['q', 'new', '--slug', 'x', '--question', 'why'], { from: 'user' });
expect(process.exitCode).toBe(7);
});
});
describe('exitStatusFor (shared dispatch contract)', () => {
it('maps absent tool to 127', () => {
expect(exitStatusFor({ status: 0 }, false)).toBe(127);
});
it('passes tool status through', () => {
expect(exitStatusFor({ status: 7 }, true)).toBe(7);
});
it('maps signal death to 125', () => {
expect(exitStatusFor({ status: null }, true)).toBe(125);
});
});
+63
View File
@@ -0,0 +1,63 @@
import type { Command } from 'commander';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import { execBrainTool, resolveBrainTool } from './brain-dispatch.js';
/**
* `mosaic q` tracked decision questions (brain tools/questions).
* `new` files a question file (one FILE per question, merge-conflict
* impossible by construction); `render` regenerates the
* docs/OPEN-QUESTIONS.md index (id allocation happens in the renderer).
*/
const SUBCOMMANDS: Record<string, { path: string; interpreter?: string; help: string }> = {
new: {
path: 'tools/questions/q-new.sh',
help: 'file a question (--question, --slug, --owed-by, ...)',
},
render: {
path: 'tools/questions/render.py',
interpreter: 'python3',
help: 'regenerate docs/OPEN-QUESTIONS.md (owns Q-id allocation)',
},
};
export function resolveQuestionTool(mosaicHome: string, sub: string): string | undefined {
const entry = SUBCOMMANDS[sub];
return entry ? resolveBrainTool(mosaicHome, entry.path) : undefined;
}
export function registerQCommand(program: Command): void {
const cmd: Command = program
.command('q')
.description('Tracked decision questions: file and render (brain tools/questions)')
.allowUnknownOption()
.argument('[args...]', 'subcommand + args passed through to the question tools')
.action(async (args: string[], _opts: unknown, command: Command) => {
let mosaicHome: string | undefined;
for (let anc: Command | null = command; anc; anc = anc.parent) {
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
if (v !== undefined) {
mosaicHome = v;
break;
}
}
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
const sub = args[0];
if (!sub || !Object.hasOwn(SUBCOMMANDS, sub)) {
console.error('mosaic q: expected a subcommand:');
for (const [name, entry] of Object.entries(SUBCOMMANDS)) {
console.error(` mosaic q ${name} ${entry.help}`);
}
process.exitCode = 2; // usage error contract: invocation defect
return;
}
const entry = SUBCOMMANDS[sub]!;
process.exitCode = execBrainTool(home, entry.path, args.slice(1), entry.interpreter);
});
cmd.addHelpText(
'after',
'\nEverything after the subcommand is passed through verbatim (args, output, exit code).',
);
}
@@ -0,0 +1,72 @@
import { mkdirSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it } from 'vitest';
import { exitStatusFor, registerWatchCommand, resolveAgentWatchTool } from './watch.js';
// The dispatch command execs a real process with inherited stdio; the spec
// covers the pure resolution and exit-mapping surfaces plus the absent-tool
// path (which exits without spawning). Live pass-through is exercised by the
// fleet smoke test against the real brain tool.
describe('resolveAgentWatchTool', () => {
const saved = process.env['MOSAIC_BRAIN_HOME'];
afterEach(() => {
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
else process.env['MOSAIC_BRAIN_HOME'] = saved;
});
it('honors MOSAIC_BRAIN_HOME over the canonical brain', () => {
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
process.env['MOSAIC_BRAIN_HOME'] = tmp;
expect(resolveAgentWatchTool('/nonexistent/mosaic-home')).toBe(
join(tmp, 'tools', 'agent-watch', 'agent-watch.sh'),
);
});
it('resolves inside the brain tools tree', () => {
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
process.env['MOSAIC_BRAIN_HOME'] = tmp;
const tool = resolveAgentWatchTool(tmp);
expect(tool.endsWith(join('tools', 'agent-watch', 'agent-watch.sh'))).toBe(true);
});
});
describe('exitStatusFor', () => {
it('maps absent tool to 127', () => {
expect(exitStatusFor({ status: 0 }, false)).toBe(127);
});
it('passes the tool exit status through', () => {
expect(exitStatusFor({ status: 2 }, true)).toBe(2);
expect(exitStatusFor({ status: 78 }, true)).toBe(78);
});
it('maps signal death / null status to 125', () => {
expect(exitStatusFor({ status: null }, true)).toBe(125);
});
});
describe('registerWatchCommand absent-tool path', () => {
const saved = process.env['MOSAIC_BRAIN_HOME'];
afterEach(() => {
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
else process.env['MOSAIC_BRAIN_HOME'] = saved;
process.exitCode = undefined;
});
it('sets exitCode 127 with the resolved path when the tool is missing', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'watch-missing-'));
// The suite directory exists but the tool file does not.
mkdirSync(join(tmp, 'tools', 'agent-watch'), { recursive: true });
process.env['MOSAIC_BRAIN_HOME'] = tmp;
const program = new Command();
registerWatchCommand(program);
await program.parseAsync(['watch', 'list'], { from: 'user' });
expect(process.exitCode).toBe(127);
});
});
+68
View File
@@ -0,0 +1,68 @@
import type { Command } from 'commander';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import {
brainToolExists,
execBrainTool,
exitStatusFor,
resolveBrainTool,
} from './brain-dispatch.js';
export { exitStatusFor };
/**
* `mosaic watch` dispatch to the brain's agent-watch suite.
* See brain-dispatch.ts for the architecture and pass-through contract.
*/
export function resolveAgentWatchTool(mosaicHome: string): string {
return resolveBrainTool(mosaicHome, 'tools/agent-watch/agent-watch.sh');
}
export function registerWatchCommand(program: Command): void {
const cmd: Command = program
.command('watch')
.description('Wake-me-when watchers (agent-watch): start, list, stop')
// allowUnknownOption + variadic = full ordered pass-through: unknown
// options (--name, --when, ...) and their values land in args verbatim
// (commander 13 measured behavior), so the tool owns its own flag
// surface without the CLI needing passThroughOptions (which would
// force enablePositionalOptions fleet-wide on the root program).
.allowUnknownOption()
// The tool owns help too: without this, commander would intercept
// --help and answer with wrapper help instead of agent-watch's own
// (codex review of 18f3dd49).
.helpOption(false)
.argument('[args...]', 'args passed through to agent-watch.sh')
.action(async (args: string[], _opts: unknown, command: Command) => {
// --mosaic-home is not global in this CLI; walk parents for it and
// fall back to the default. MOSAIC_BRAIN_HOME (seat launchers export
// it) wins inside resolveBrainHome regardless.
let mosaicHome: string | undefined;
for (let anc: Command | null = command; anc; anc = anc.parent) {
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
if (v !== undefined) {
mosaicHome = v;
break;
}
}
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
const tool = resolveAgentWatchTool(home);
if (!brainToolExists(tool)) {
console.error(
`mosaic watch: agent-watch not found (expected ${tool}). ` +
'The watcher suite lives in the brain tree under tools/agent-watch/; ' +
'check MOSAIC_BRAIN_HOME or the brain checkout.',
);
process.exitCode = 127;
return;
}
process.exitCode = execBrainTool(home, 'tools/agent-watch/agent-watch.sh', args);
});
cmd.addHelpText(
'after',
'\nEverything after `mosaic watch` is passed through to agent-watch.sh verbatim (args, output, exit code).',
);
}