Compare commits

...
Author SHA1 Message Date
fred 6550bda5e1 docs: Amendment 1 rev 3 — terra round-2 fixes
ci/woodpecker/pr/ci Pipeline was successful
- user FKs text not uuid (users.id is BetterAuth text; matches
  hierarchy_grants shape)
- actor receipt split from canonical audit event: receipt carries
  {target id, old, new}; canonical event keeps hierarchy schema §5.2's
  full immutable snapshot; no audit read conferred; §8.6.3 witnesses
  both sides
- kanban SOT Amendment A3 (§10) expressly extends A2's sole-disclosure
  rule by exactly the capability-holder carve-out; ruling request names
  it
2026-08-29 14:04:12 -05:00
fred 99a0c89c5a docs: Amendment 1 rev 2 — terra round-1 fixes
ci/woodpecker/pr/ci Pipeline was canceled
Existence disclosure stated as a bounded carve-out to hierarchy schema
§6.7/§2.8 instead of denied (§8.1); platform_capabilities fully pinned
(uuid PK, timestamptz, both user FKs ON DELETE RESTRICT mirroring
contract 1 §3.3, column-set exhaustiveness) (§8.2); assignment rides the
normal admin command surface, sole writer (§8.3); disjunct-independent
fail-closed evaluation and capability deletion added to §6.1's
revocation enumeration (§8.4); §8.6 rewritten as a capability-only
command matrix with disclosure-bound, both-polarity assignment, and
per-transport revocation witnesses.
2026-08-29 13:49:47 -05:00
fred 009a0ef18e docs: contract 2 Amendment 1 — company-CRUD capability definition
ci/woodpecker/pr/ci Pipeline was canceled
Defines the capability class hierarchy schema §5.5 / contract 1
Amendment 1 anticipates: a platform-scoped, admin-assigned, audited
delegation of exactly the company visibility command. No read surface,
no other company operation; §2 vocabulary and §3 evaluation untouched.
Ends in a one-sentence ruling request (S2 process).
2026-08-29 13:35:59 -05: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
69 changed files with 11893 additions and 267 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)),
);
+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 {
+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.
@@ -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
@@ -46,7 +46,7 @@ usage_error() {
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
[[ $# -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
;;
@@ -54,12 +54,12 @@ while [[ $# -gt 0 ]]; do
# 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 ]] || usage_error "option $1 requires a value"
[[ $# -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 ]] || usage_error "option $1 requires a value"
[[ $# -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
;;
@@ -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
+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)
@@ -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
@@ -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
@@ -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)"
@@ -97,7 +97,7 @@ expect_rc() { # expect_rc <want> <desc> <args...>
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$desc: stderr missing '$1'"
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage on stdout.
@@ -124,6 +124,13 @@ for flag in -i -b -c -l --issue --body --comment --login; do
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).
@@ -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,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,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)"
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)"
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.',
);
}
+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).',
);
}