Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e858f1bc62 | ||
|
|
9c8b6ebfe1 | ||
|
|
7d86d55577 | ||
|
|
18f3dd49ec | ||
|
|
41f1bc0d37 | ||
|
|
a27b7dc9be |
@@ -4,14 +4,14 @@ import { AppModule } from '../app.module.js';
|
||||
import { HierarchyModule } from '../hierarchy/hierarchy.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy route inventory (contract 1 §6.3).
|
||||
* Hierarchy route-inventory baseline (contract 1 §6.3(a)).
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
interface RouteEntry {
|
||||
@@ -77,32 +77,7 @@ function routesOf(controller: Type<unknown>): RouteEntry[] {
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)', () => {
|
||||
describe('hierarchy route-inventory baseline (§6.3(a))', () => {
|
||||
const inventory = collectControllers(AppModule).flatMap(routesOf);
|
||||
|
||||
it('control: the enumeration sees the known route surface', () => {
|
||||
@@ -113,21 +88,19 @@ describe('hierarchy route inventory (§6.3)', () => {
|
||||
expect(inventory.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
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('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('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());
|
||||
it('HierarchyModule itself declares no controllers', () => {
|
||||
expect((Reflect.getMetadata('controllers', HierarchyModule) ?? []) as unknown[]).toEqual([]);
|
||||
const hierarchyControllers = collectControllers(HierarchyModule);
|
||||
expect(hierarchyControllers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,33 +61,6 @@ 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,15 +154,8 @@ 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 (scope === 'admin') return role === 'admin';
|
||||
if (role === 'admin') return true;
|
||||
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 repository (HierarchyRepository) is the allowlisted
|
||||
* writer and calls into this on its own transactions.
|
||||
* hierarchy command repositories (M4-1b-ii) are the allowlisted writers and
|
||||
* call into this on their own transactions.
|
||||
*/
|
||||
|
||||
export type HierarchyAuditVerb = (typeof HIERARCHY_AUDIT_VERBS)[number];
|
||||
|
||||
@@ -1,965 +0,0 @@
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,242 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,28 +1,17 @@
|
||||
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 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.
|
||||
* 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.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [HierarchyController],
|
||||
providers: [
|
||||
HierarchyAuditRepository,
|
||||
HierarchyGrantEvaluationService,
|
||||
HierarchyRepository,
|
||||
HierarchyService,
|
||||
],
|
||||
exports: [HierarchyAuditRepository, HierarchyGrantEvaluationService],
|
||||
providers: [HierarchyAuditRepository],
|
||||
exports: [HierarchyAuditRepository],
|
||||
})
|
||||
export class HierarchyModule {}
|
||||
|
||||
@@ -1,935 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,18 +176,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
).toBe(false);
|
||||
expect(
|
||||
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
|
||||
).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);
|
||||
}
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
|
||||
@@ -322,20 +311,14 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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 = {
|
||||
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
|
||||
const { service } = makeService({
|
||||
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, unowned',
|
||||
name: 'same tenant',
|
||||
},
|
||||
{
|
||||
id: 'project-tenant-b',
|
||||
@@ -345,50 +328,39 @@ 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');
|
||||
|
||||
const actors = [
|
||||
makeAdminActor('role-bearing-user', 'tenant-a'),
|
||||
makePlatformAdminActor('role-bearing-user'),
|
||||
];
|
||||
service.registerTools(server, actor);
|
||||
|
||||
for (const actor of actors) {
|
||||
const { service } = makeService(fixtures);
|
||||
const { server, tools } = makeCapturingServer();
|
||||
service.registerTools(server, actor);
|
||||
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']);
|
||||
|
||||
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 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 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']);
|
||||
}
|
||||
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']);
|
||||
});
|
||||
|
||||
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
|
||||
const { service, brain } = makeService({
|
||||
projects: [
|
||||
// §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-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
|
||||
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
|
||||
],
|
||||
missions: [
|
||||
@@ -401,29 +373,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
],
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
// 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,
|
||||
],
|
||||
});
|
||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
|
||||
service.registerTools(server, actor);
|
||||
|
||||
@@ -466,7 +416,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('denies coordination tools to every role-derived actor and keeps the granted path server-derived', async () => {
|
||||
it('keeps admin-only coordination tools on server-derived paths', async () => {
|
||||
const { service, coord } = makeService();
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const member = makeMemberActor('authenticated-user');
|
||||
@@ -483,25 +433,10 @@ 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 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' });
|
||||
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
|
||||
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
|
||||
});
|
||||
|
||||
|
||||
@@ -63,6 +63,20 @@ 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,
|
||||
@@ -75,17 +89,15 @@ const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.memory_save_insight,
|
||||
]);
|
||||
|
||||
/**
|
||||
* 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: {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -156,22 +168,41 @@ type TaskLike = TenantScopedLike & {
|
||||
userId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
);
|
||||
}
|
||||
|
||||
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
|
||||
return projects.filter((project) => project.ownerId === actor.userId);
|
||||
if (isGlobalAdminActor(actor)) return projects;
|
||||
return projects.filter(
|
||||
(project) =>
|
||||
project.ownerId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
|
||||
);
|
||||
}
|
||||
|
||||
function filterMissionsByDirectActorScope<T extends MissionLike>(
|
||||
actor: McpActorContext,
|
||||
missions: T[],
|
||||
): T[] {
|
||||
return missions.filter((mission) => mission.userId === actor.userId);
|
||||
if (isGlobalAdminActor(actor)) return missions;
|
||||
return missions.filter(
|
||||
(mission) =>
|
||||
mission.userId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
|
||||
);
|
||||
}
|
||||
|
||||
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
|
||||
@@ -262,6 +293,7 @@ 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;
|
||||
}
|
||||
@@ -270,6 +302,8 @@ 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),
|
||||
@@ -283,6 +317,7 @@ 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;
|
||||
@@ -304,7 +339,7 @@ export class McpService implements OnModuleDestroy {
|
||||
actor: McpActorContext,
|
||||
refs: { projectId?: string | null; missionId?: string | null },
|
||||
): Promise<void> {
|
||||
if (!refs.projectId && !refs.missionId) {
|
||||
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
|
||||
throw new Error('MCP task scope denied');
|
||||
}
|
||||
await this.assertTaskReferencesAuthorized(actor, refs);
|
||||
@@ -314,6 +349,8 @@ 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(),
|
||||
@@ -330,6 +367,7 @@ 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)),
|
||||
);
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
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
|
||||
@@ -55,56 +43,6 @@ export const PIPE_GUARDED_DTOS: Array<{
|
||||
target: BootstrapSetupDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
},
|
||||
{
|
||||
name: 'CreateCompanyDto',
|
||||
target: CreateCompanyDto,
|
||||
properties: ['name', 'slug', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'RenameNodeDto',
|
||||
target: RenameNodeDto,
|
||||
properties: ['name', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'ChangeCompanyVisibilityDto',
|
||||
target: ChangeCompanyVisibilityDto,
|
||||
properties: ['visibility', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'DeleteNodeDto',
|
||||
target: DeleteNodeDto,
|
||||
properties: ['idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'CreateEstateDto',
|
||||
target: CreateEstateDto,
|
||||
properties: ['companyId', 'name', 'slug', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'CreatePlatformProjectDto',
|
||||
target: CreatePlatformProjectDto,
|
||||
properties: ['estateId', 'name', 'slug', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'TransferEstateDto',
|
||||
target: TransferEstateDto,
|
||||
properties: ['destinationCompanyId', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'TransferPlatformProjectDto',
|
||||
target: TransferPlatformProjectDto,
|
||||
properties: ['destinationEstateId', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'CreateGrantDto',
|
||||
target: CreateGrantDto,
|
||||
properties: ['userId', 'targetKind', 'targetId', 'role', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'ChangeGrantDto',
|
||||
target: ChangeGrantDto,
|
||||
properties: ['role', 'idempotencyKey'],
|
||||
},
|
||||
];
|
||||
|
||||
export class PipeMetatypeCheckError extends Error {
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
# Deployment Mode and Conversion Contract (D3)
|
||||
|
||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 6 of 9).
|
||||
Authority: PRD D3 (Part I §3) — two modes chosen at install time,
|
||||
Standalone and Enterprise, with the mode table (brains, user-data
|
||||
isolation, secrets, conversion); Standalone → Enterprise conversion is
|
||||
**one-way** and Enterprise is a **terminal state**. PRD D14 (Part I §7)
|
||||
— the per-user brain split is optional in Standalone and keeping it is
|
||||
the recommended default because it preserves forward-compatibility with
|
||||
the one-way conversion. PRD D11 (Part I §9) — v1 ships the Standalone
|
||||
flow only; Enterprise conversion is explicitly deferred. PRD D3
|
||||
federation clause — federation is intentionally not fully designed,
|
||||
deferred, and nothing in v1 may foreclose it.
|
||||
|
||||
Revision 2 (luna review F1–F7): the identity precondition restated in
|
||||
identity-contract terms with a conversion-local acknowledgment record
|
||||
this contract owns (F1); a durable, keyed preparation state with a
|
||||
Standalone-safe representation rule, an in-transaction re-check fence,
|
||||
and an exact flip boundary (F2); the §5.4 unknown-value rule stated
|
||||
directly without the contradictory non-exhaustiveness clause (F3); the
|
||||
D14 boundary bound here with a stable column-allowlist witness instead
|
||||
of delegated to an unratified layout (F4); the conversion witness
|
||||
matrix extended to every §4.2/§4.4 condition (F5); the mode-record
|
||||
writer coverage imported concretely from contract 1 §6.3 with a named
|
||||
schema, closed writer set, crafted-write probe, and mode-resolution
|
||||
assertion (F6); the mode read command flagged as a §12.1 drafting
|
||||
addition rather than a D8 mandate (F7). Ownership language aligned
|
||||
with contract 3 revision 2: mode is recorded at bootstrap and read by
|
||||
the wizard as input.
|
||||
|
||||
This contract binds the mode as a canonical platform property (§2), the
|
||||
per-mode obligations and which contract owns each (§3), the conversion
|
||||
transition (§4), the v1 non-foreclosure obligations (§5), and their
|
||||
witnesses (§6). Domain semantics stay with their owning contracts:
|
||||
wizard branching (contract 3 §2), identity/SSO
|
||||
(`identity-lifecycle.md`), custody and per-user brain mechanics
|
||||
(contract 7, `custody-schema.md`), tool mapping
|
||||
(`tool-gateway-mapping.md`).
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
1. **Mode**: the platform-wide deployment mode, exactly one of
|
||||
`standalone` or `enterprise`. The vocabulary is closed in v1;
|
||||
extension (e.g. a federation mode) is by amendment to this contract,
|
||||
never ad hoc.
|
||||
2. **Conversion**: the one-way transition `standalone → enterprise`.
|
||||
No other mode transition exists.
|
||||
3. **Conversion preconditions**: the verifiable conditions of §4.2 that
|
||||
must all hold before the mode record may change.
|
||||
4. **Preparation unit**: one re-runnable piece of pre-conversion work —
|
||||
the migration of one secret to the Vault backend, or the partition
|
||||
of one user's brain content (§4.3).
|
||||
|
||||
## 2. Mode is a canonical recorded property
|
||||
|
||||
1. Mode is recorded canonically in the platform database at bootstrap
|
||||
as the operator's install-time choice (D3: modes are "chosen at
|
||||
install time"). The record is a single-row keyed record
|
||||
(`platform_mode`: mode value, recorded-at timestamp, bootstrap epoch
|
||||
reference); this contract owns it, the bootstrap writer performs the
|
||||
one v1 write (§6.2), and the wizard reads it as input (contract 3
|
||||
§2.3). Mode is never derived from feature state (presence of Vault,
|
||||
count of brains, count of users), and no component may infer a
|
||||
different mode than the record states.
|
||||
2. The record is readable by any authenticated user through a Gateway
|
||||
command with CLI exposure. This read command is a **drafting
|
||||
addition** ratified with this contract (PRD §12.1), not a D8
|
||||
mandate: D8 binds only that any surface exposing the value goes
|
||||
through official tooling. When a webUI surface consumes the read, a
|
||||
mapping row is added to `tool-gateway-mapping.md` by amendment —
|
||||
the same route §4.4 already binds for the conversion command.
|
||||
Components branch on the read value only.
|
||||
3. The record is immutable except by the §4 conversion transition.
|
||||
Editing it by direct database access, config file, environment
|
||||
variable, or wizard re-run is non-conformant (contract 3 §2.3:
|
||||
changing mode later is conversion, not a wizard re-run).
|
||||
|
||||
## 3. Per-mode obligations (owner map)
|
||||
|
||||
The PRD mode table binds four rows; this contract assigns each an
|
||||
owning contract so no obligation is unowned and none is bound twice:
|
||||
|
||||
| Obligation | Standalone | Enterprise | Owner |
|
||||
| ------------------- | -------------------------------------- | -------------------------------------------------- | --------------------------------------------- |
|
||||
| Brains | one mosaic-brain (system + user files) | system brain for config + one brain per user | contract 7 (custody/brain mechanics) |
|
||||
| User-data isolation | single user | no user-data leakage between users; sharing opt-in | contract 7 (enforced by architecture, D14) |
|
||||
| Secrets | OpenBao/Vault or flat files | OpenBao/Vault REQUIRED | this contract (§4.2 gate; steady-state check) |
|
||||
| Conversion | may convert to Enterprise, one-way | terminal state | this contract (§4) |
|
||||
|
||||
The Standalone brains row states the default layout, not the only
|
||||
valid one: the D14 per-user split is a MAY in Standalone with keeping
|
||||
it the recommended default (PRD §7, contract 7 §6), and Vault-backed
|
||||
secrets are equally valid Standalone configuration. Both prepared
|
||||
states are therefore themselves valid Standalone states — the fact
|
||||
§4.3 relies on.
|
||||
|
||||
In Enterprise steady state, a flat-file secrets backend is
|
||||
non-conformant; the platform refuses to start Enterprise-mode
|
||||
components against a flat-file secrets configuration (fail-closed, not
|
||||
warn-and-run).
|
||||
|
||||
## 4. Conversion transition
|
||||
|
||||
1. **Direction and terminality.** The only transition is
|
||||
`standalone → enterprise`. `enterprise → standalone` does not exist:
|
||||
there is no command, no admin override, and no support path. An
|
||||
attempt is refused with the precondition/state error class of the
|
||||
command envelope (`tool-gateway-mapping.md` §4.2).
|
||||
2. **Preconditions (all verified before the record changes):**
|
||||
- Secrets: OpenBao/Vault is configured and reachable, and every
|
||||
required secret is served from the Vault backend — none from a
|
||||
flat-file backend. Secret migration completes before conversion;
|
||||
this contract does not define the migration tooling, only the
|
||||
gate.
|
||||
- Brains: the per-user brain split required by the Enterprise row of
|
||||
§3 is established for **every** existing user (or the deployment
|
||||
already kept the split, the D14 recommended default). Brain
|
||||
partitioning mechanics are contract 7; this contract binds only
|
||||
that the split is complete before the mode flips.
|
||||
- Identity: at least one platform administrator account exists that
|
||||
is active in identity-contract terms — authenticated capability,
|
||||
not banned, not deactivated (identity §2, §5). And the conversion
|
||||
request carries a **configuration acknowledgment**: the current
|
||||
canonical values of registration mode and per-provider JIT
|
||||
enablement (identity §2.2, §4.1), echoed back in the request. A
|
||||
mismatch between the echoed values and the canonical values at
|
||||
verification refuses the conversion. This acknowledgment record
|
||||
is conversion-local, owned by this contract, and stored with the
|
||||
§4.4 audit event as the precondition evidence; it adds no
|
||||
identity-contract obligation and no mode-specific identity
|
||||
default — identity's own defaults remain valid states.
|
||||
3. **Preparation state and the flip boundary.** Preparatory work is
|
||||
tracked durably: each preparation unit (§1.4) records its
|
||||
completion in a preparation table keyed by (bootstrap epoch, unit
|
||||
identity — the secret's path, the user's id), written in the same
|
||||
transaction as the unit's own effect where the unit's backend
|
||||
allows it, and reconciled from the backend's actual state where it
|
||||
does not (a secret already served by Vault, a brain already split,
|
||||
is complete regardless of the table). Units are at-most-once per
|
||||
key and re-runnable across attempts. **Standalone-safe
|
||||
representation:** every preparation unit moves the deployment into
|
||||
a state that is itself valid Standalone configuration (§3 note), so
|
||||
an interrupted preparation leaves a fully operational Standalone
|
||||
deployment reading its state through the ordinary contracts — no
|
||||
rollback, fencing, or special Standalone read path is needed, and
|
||||
no component behavior may key on "preparation in progress".
|
||||
**The flip:** one transaction that (a) locks the mode record, (b)
|
||||
re-verifies every §4.2 precondition after acquiring the lock, and
|
||||
(c) writes the mode record and the §4.4 audit event. Any re-check
|
||||
failure aborts with no write. External state that changes after the
|
||||
re-check but before commit is bounded by the transaction window;
|
||||
an external backend (Vault) failing after conversion is an
|
||||
Enterprise runtime fault handled by §3's fail-closed steady-state
|
||||
rule, not a conversion defect. An interrupted or failed conversion
|
||||
leaves the record `standalone` and the platform fully operational;
|
||||
there is no intermediate mode and no half-converted state
|
||||
observable through the record.
|
||||
4. **Authority and audit.** Conversion is a platform-administrator
|
||||
command carrying an explicit irreversibility acknowledgment in its
|
||||
request (distinct from the §4.2 configuration acknowledgment). It
|
||||
is an official Gateway/CLI command (D8): when built, it is added to
|
||||
the tool↔Gateway mapping by amendment (`tool-gateway-mapping.md`
|
||||
§3.3). The transition emits an audit event (actor, prior mode, new
|
||||
mode, precondition evidence reference including the configuration
|
||||
acknowledgment) in the same transaction as the record change; the
|
||||
event survives indefinitely. A refused attempt emits a refusal
|
||||
event naming the failed precondition class and actor, with no
|
||||
mode-change event.
|
||||
|
||||
## 5. v1 obligations (non-foreclosure)
|
||||
|
||||
v1 ships Standalone only (D11); the conversion command is deferred
|
||||
work. v1 still MUST:
|
||||
|
||||
1. Record the mode per §2 at bootstrap, with `enterprise` a reserved,
|
||||
refused value for bootstrap — v1 bootstrap accepts `standalone`
|
||||
only. The wizard reads the record (contract 3 §2.3); nothing in v1
|
||||
writes it after bootstrap.
|
||||
2. Keep the §2.3 immutability rule: no v1 surface mutates the mode
|
||||
record.
|
||||
3. Not foreclose conversion: the v1 platform database holds no
|
||||
sensitive user content — sensitive categories live in the owning
|
||||
user's brain, and postgres holds structure, consent records, and
|
||||
pointers only (the D14 boundary, PRD §7). Custody mechanics are
|
||||
contract 7's; this contract binds the boundary itself here so v1
|
||||
cannot ship a layout that makes the §4.2 brain precondition
|
||||
unsatisfiable, and §6.3 gives it a stable witness that does not
|
||||
depend on contract 7's internals. Conversion implementation
|
||||
additionally requires contract 7 ratified.
|
||||
4. Not foreclose federation: v1 components accept exactly the two §1.1
|
||||
values wherever a mode value is parsed and refuse any other value
|
||||
**before side effects** — a refused configuration, not undefined
|
||||
behavior and not a crash mid-operation. Forward compatibility lives
|
||||
in storage and architecture, not in parser speculation: the mode
|
||||
record's storage is not structurally locked to two values (no
|
||||
database-level two-value enum), and any future value (e.g. a
|
||||
federation mode) is defined by a versioned amendment to this
|
||||
contract before any component accepts it. The PRD defers
|
||||
federation's shape entirely; this contract does not presume it
|
||||
arrives as a third mode value.
|
||||
|
||||
## 6. Verification requirements
|
||||
|
||||
Binding on the implementing PRs:
|
||||
|
||||
1. **Mode-record witness (v1):** after bootstrap the mode is readable
|
||||
via the Gateway command and CLI and equals the bootstrap-recorded
|
||||
choice; bootstrap with mode `enterprise` is refused; bootstrap with
|
||||
any unknown mode value is refused before side effects (§5.4).
|
||||
2. **Writer-coverage witness (v1):** the mode record's writer set is
|
||||
closed by the same three-prong static assertion contract 1 §6.3(b)
|
||||
defines — symbol, class-table literal, and raw-execution prongs
|
||||
with its allowlist composition rules — scoped to the
|
||||
`platform_mode` table, with a writer allowlist containing exactly
|
||||
the bootstrap writer in v1 (and exactly plus the conversion command
|
||||
at the conversion milestone). Companions: a crafted direct write
|
||||
attempted in a test fails and leaves the record unchanged; a
|
||||
mode-resolution assertion that no shipped component derives mode
|
||||
from feature state (mode reads occur only through the §2.2 read
|
||||
surface — static assertion over Gateway, CLI, bootstrap, and
|
||||
repository sources).
|
||||
3. **D14-boundary witness (v1):** a column-allowlist assertion in the
|
||||
style of contract 1 §6.2 that the platform database schema contains
|
||||
no sensitive-content column — the §5.3 boundary — stable regardless
|
||||
of contract 7's internals (contract 7 §7 carries the full custody
|
||||
witnesses).
|
||||
4. **No-downgrade witness (conversion milestone):** with mode
|
||||
`enterprise`, a conversion request to `standalone` (and any crafted
|
||||
mode-write) is refused with the precondition/state error class and
|
||||
no record change.
|
||||
5. **Precondition witnesses (conversion milestone),** each refused
|
||||
with no record change and no partial mode effect, parameterized
|
||||
over both OpenBao and Vault where secrets are involved:
|
||||
(a) secrets backend unreachable; (b) one required secret still
|
||||
flat-file backed (migration incomplete); (c) one unpartitioned user
|
||||
brain in a **multi-user** deployment where every other user is
|
||||
partitioned; (d) no active platform administrator (the only admin
|
||||
banned or deactivated); (e) configuration acknowledgment missing or
|
||||
mismatching the canonical registration/JIT values; (f) actor not a
|
||||
platform administrator (authorization refusal); (g) irreversibility
|
||||
acknowledgment absent. And the steady-state rule: an
|
||||
Enterprise-mode component started against a flat-file secrets
|
||||
configuration refuses to start (§3).
|
||||
6. **Interruption and fence witnesses (conversion milestone):** fault
|
||||
injection aborting conversion after each preparation unit and
|
||||
between preparation and flip leaves the record `standalone` and the
|
||||
platform operational in Standalone semantics (§4.3
|
||||
Standalone-safety), and a re-attempt completes without duplicating
|
||||
prepared state (at-most-once keys); a precondition invalidated
|
||||
after preparation but before the flip (a secret reverted to
|
||||
flat-file) is caught by the in-transaction re-check and refused.
|
||||
7. **Audit witnesses (conversion milestone):** a completed conversion
|
||||
has exactly one mode-change audit event, same-transaction with the
|
||||
record change (transaction linkage asserted), carrying actor, prior
|
||||
mode, new mode, and the precondition evidence reference including
|
||||
the configuration acknowledgment; a failed attempt has a refusal
|
||||
event naming the failed precondition class and no mode-change
|
||||
event; the mode-change event remains queryable after subsequent
|
||||
unrelated audit activity (retention probe).
|
||||
8. **Mapping witness (conversion milestone):** the conversion command
|
||||
and the mode read command each have their
|
||||
`tool-gateway-mapping.md` row (added by amendment per §2.2/§4.4)
|
||||
before the commands ship.
|
||||
|
||||
## Ruling request
|
||||
|
||||
Ratify sections 1–6 as written, with one decision embedded:
|
||||
|
||||
- Decision (§5): v1 implements the **mode record and its immutability
|
||||
only** — bootstrap records `standalone`, the `enterprise` value is
|
||||
reserved and refused, and the conversion command itself is deferred
|
||||
to the Enterprise milestone, consistent with D11's deferred list.
|
||||
v1 carries three obligations beyond the record: the closed writer
|
||||
assertion, the D14 column boundary, and the unknown-value refusal
|
||||
(§6.1–§6.3) — these are the non-foreclosure floor, not hidden
|
||||
conversion work. Alternative if rejected: build the conversion
|
||||
command inside v1 — rejected because D11 scopes v1 to the Standalone
|
||||
slice and conversion depends on contract 7 custody mechanics that
|
||||
are themselves not in the v1 slice.
|
||||
@@ -514,47 +514,3 @@ 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 §§1–9 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.
|
||||
|
||||
@@ -24,26 +24,6 @@ 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
|
||||
@@ -262,191 +242,6 @@ 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 1–7 as written, with one decision embedded and one
|
||||
@@ -462,17 +257,3 @@ 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.
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
# Tool↔Gateway Mapping Contract (D8)
|
||||
|
||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 5 of 9).
|
||||
Authority: PRD D8/D12 (Part I §8) — the webUI sits OVER official tooling:
|
||||
every webUI operation goes through the Gateway API backed by the same
|
||||
official framework tooling the CLI uses, and a webUI operation with no
|
||||
backing tool is scored **blocked on tooling** and the tool is built
|
||||
first. Measured input: the webui-audit A5 tooling baseline
|
||||
(operation-by-operation inventory of the current Gateway surface and the
|
||||
P1 gaps, cross-reviewed; `fleet/lanes/webui-audit/findings/
|
||||
A5-tooling-baseline.md` in the estate brain). The T10 ruling adopted the
|
||||
targeted-update plan including building the D8 tools in A5's rank order.
|
||||
|
||||
Revision 2 (GLM review F1–F5): the §2 table completed against an
|
||||
independent re-measurement of the live `apps/web` surface (mission
|
||||
reads, coordination status, capability-gated `turn:send` added); rank-6
|
||||
composition corrected to ranks 1 and 4; SOT citations corrected to §3
|
||||
invariant 11 / REQ-TASK-001 / §5+A1; the §3.2 retirement clause
|
||||
softened to match what the owning contracts actually schedule; §6.1
|
||||
scoped to outbound calls with an extractability lint, and §6.3 given
|
||||
static companions for §4.1 and §4.3.
|
||||
|
||||
This contract binds three things: the operation→tool mapping itself
|
||||
(§2–§3), the command envelope every mapped operation satisfies
|
||||
(§4), and the process rule that keeps the mapping closed (§5). Domain
|
||||
semantics stay with their owning contracts — hierarchy (contract 1,
|
||||
`hierarchy-schema.md`), grants (contract 2, `rbac-grant-model.md`),
|
||||
wizard (contract 3, `onboarding-wizard.md`), identity
|
||||
(`identity-lifecycle.md`), kanban lifecycle (`native-kanban-sot.md`
|
||||
§5 and Amendment A1), roll-up (contract 8), API artifact format
|
||||
(contract 9).
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
1. **Official tool**: a command implemented in the framework packages and
|
||||
exposed through the Gateway API; the CLI remains the primary execution
|
||||
method for the same command (D8). The webUI is a Gateway client only.
|
||||
2. **Mapped operation**: a webUI operation with a named official path in
|
||||
§2 or §3. Anything else the webUI wants to do is unmapped and follows
|
||||
§5.
|
||||
3. **Legacy non-substitute**: an existing endpoint that resembles a P1
|
||||
need but is contractually barred from backing it (§3.2).
|
||||
|
||||
## 2. P0 mapping (current operations, ratified as-is)
|
||||
|
||||
This table is the complete measured P0 surface: every Gateway call the
|
||||
web app's production sources make at this revision's head appears as a
|
||||
row (independently re-measured at review; the three calls the first
|
||||
measurement missed — mission reads, coordination status, and the
|
||||
capability-gated `turn:send` emit — are rows below). The surface stays
|
||||
bound to these paths:
|
||||
|
||||
| WebUI operation | Official path |
|
||||
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Register / log in / log out / OIDC callback | better-auth mount `/api/auth/*`; `GET /api/sso/providers` |
|
||||
| List/show projects (legacy read) | `GET /api/projects`, `GET /api/projects/:id` |
|
||||
| List tasks / task detail (legacy read) | `GET /api/tasks`, `GET /api/tasks/:id` — with the filtered legacy project/mission reads the same surfaces use |
|
||||
| Mission list (legacy read) | `GET /api/missions` |
|
||||
| Coordination status (legacy read) | `GET /api/coord/status` |
|
||||
| Conversation CRUD/search/messages | `/api/conversations*` |
|
||||
| Chat turn / stop / thinking / command execute+approve / streaming | `/chat` socket events `message`, `abort`, `set:thinking`, `command:execute`, `command:approve`; `turn:send` (capability-gated — emitted only when the server advertises the pi turn-runtime capability, which the current Gateway does not) |
|
||||
| Harness/model selection | `GET /api/harnesses*`, `GET/PUT /api/chat/preferences/selection` |
|
||||
| Preferences; provider inspect/test | `/api/memory/preferences`, `GET /api/providers`, `POST /api/providers/test` |
|
||||
| Admin users / roles / ban / health | `/api/admin/users*`, `/api/admin/health` |
|
||||
|
||||
P0 rows inherit §4 obligations as their backing controllers are next
|
||||
touched; they are not required to be retrofitted in one sweep.
|
||||
|
||||
## 3. P1 mapping (bound to the build-first tools)
|
||||
|
||||
1. Every P1 operation maps to exactly one build-first command family, in
|
||||
the T10-ruled rank order:
|
||||
|
||||
| Rank | Command family (owning contract) | P1 webUI operations it backs |
|
||||
| ---- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Hierarchy command family (contract 1 §5; grants attach per contract 2) | Company/estate/platform-project/workspace CRUD, parentage and reparenting, hierarchy reads; the wizard's initial-hierarchy step (contract 3 §3.4) |
|
||||
| 2 | Hierarchy RBAC command/evaluator (contract 2) | Grant create/change/revoke at company/estate/platform-project; inherited evaluation down to workspace; authorization-safe hierarchy queries |
|
||||
| 3 | Typed kanban command/query surface (SOT §5, Amendment A1) | Workspace task lifecycle (create/edit/cancel/archive/move), board rank, typed queries |
|
||||
| 4 | Agent enrollment command | Enroll one agent: harness, credential reference/API-key intake (values never echoed), name/persona, assignment scope (contract 3 §3.5) |
|
||||
| 5 | Authorized roll-up query (contract 8) | Read-only aggregated task counts/statuses at every hierarchy level over readable workspaces only |
|
||||
| 6 | Onboarding orchestration (contract 3) | The re-runnable wizard flow, composing ranks 1 and 4 (its only grant write rides inside the rank-1 company-create command, contract 2 §4.3) |
|
||||
|
||||
2. **Legacy non-substitutes.** The following MUST NOT back any P1
|
||||
operation, matching the audit findings: legacy `/api/projects` and
|
||||
`/api/tasks` CRUD (planning-data records, not hierarchy nodes and not
|
||||
the typed kanban boundary); `POST /api/workspaces` (filesystem
|
||||
bootstrap, not audited hierarchy parentage); `/api/teams` reads (no
|
||||
grants, no inheritance); `POST /api/bootstrap/setup` (one-shot
|
||||
epoch transition, identity §3 — not the re-runnable wizard); the MCP
|
||||
`brain_*` task mutations (legacy Brain writes, not the typed kanban
|
||||
commands). These stay serving their existing P0/host consumers until
|
||||
the owning contract (or a successor amendment) schedules each
|
||||
retirement — no such migration is scheduled at this revision; the
|
||||
freeze stands on its own.
|
||||
3. New P1 mapping rows (operations this table does not list) are added by
|
||||
amending this contract, not ad hoc (§5).
|
||||
|
||||
## 4. Command envelope (request / result / error / audit)
|
||||
|
||||
Binding on every mapped operation the build-first families expose:
|
||||
|
||||
1. **Typed request and result.** Each command and query has an explicit
|
||||
request DTO and result DTO in the shared types package, validated at
|
||||
the Gateway boundary; unvalidated pass-through and `any`-typed
|
||||
payloads are non-conformant. Mutations on records with an
|
||||
expected-version rule in their owning contract carry the expected
|
||||
version in the request and fail on mismatch with the conflict error
|
||||
class (SOT §3 invariant 11 and REQ-TASK-001's concurrent-update
|
||||
conflict acceptance; hierarchy per contract 1).
|
||||
2. **Error taxonomy.** Every error result carries a stable
|
||||
machine-readable code from a closed per-family enum plus an HTTP
|
||||
status mapping, distinguishing at minimum: validation failure,
|
||||
authentication failure, authorization refusal, not-found, conflict
|
||||
(version/uniqueness), precondition/state refusal (e.g. bootstrap
|
||||
epoch, suspended team subjects), and internal fault. Where contract
|
||||
2's no-existence-oracle rule applies, authorization refusal and
|
||||
not-found are indistinguishable on the wire for unauthorized readers
|
||||
— same code, same status, same shape.
|
||||
3. **Audit linkage.** A mutating mapped operation emits exactly the
|
||||
audit events its owning contract defines (contract 1 §5.2, contract 2
|
||||
§4.4, identity §§2–4, SOT audit rules); the envelope contributes the
|
||||
correlation: every request accepts/generates a correlation id,
|
||||
carried into the audit events and returned in the result, so a UI
|
||||
action is traceable end to end. The mapping layer itself adds no
|
||||
second audit stream.
|
||||
4. **Fail-closed.** A mapped operation that cannot evaluate its
|
||||
authorization or reach its owning tool refuses (contract 2 §3.5); the
|
||||
envelope never degrades to an unauthorized fallback read or a direct
|
||||
data access.
|
||||
5. **CLI parity.** Each build-first family is invocable through the
|
||||
official CLI against the same Gateway commands with the same
|
||||
request/result/error contracts. No webUI-only command exists; a
|
||||
Gateway command without CLI exposure is a conformance gap tracked at
|
||||
the family's implementing issue.
|
||||
|
||||
## 5. Closure rule (blocked on tooling)
|
||||
|
||||
1. A webUI change that needs an operation with no mapping row is
|
||||
**blocked on tooling**: the backing tool is built and mapped first
|
||||
(D8). Scoring a gap "blocked on tooling" is mandatory, not
|
||||
discretionary; working around it in the UI (direct DB or filesystem
|
||||
access, calling a legacy non-substitute, embedding domain logic in
|
||||
the web app) is non-conformant.
|
||||
2. The mapping is enforced closed by §6.1's inventory witness: the web
|
||||
app's network surface must be a subset of the mapped paths.
|
||||
|
||||
## 6. Verification requirements
|
||||
|
||||
Binding on the implementing PRs:
|
||||
|
||||
1. **Network-surface inventory witness:** a CI assertion extracting the
|
||||
web app's outbound Gateway calls — route literals at request call
|
||||
sites and outbound socket emits in `apps/web` sources (inbound
|
||||
handler registrations are not calls and are out of scope) — and
|
||||
failing on any call outside the §2/§3 mapped paths. The inventory is
|
||||
closed like contract 1 §6.3's allowlist: a new call fails until a
|
||||
mapping row exists in the same PR. Dynamic route construction that
|
||||
evades extraction is resolved toward the witness, enforced by an
|
||||
extractability lint: every request call site takes a literal or
|
||||
template-literal path, and a call site that does not fails the
|
||||
assertion itself (the web-side analogue of contract 1's
|
||||
raw-execution prong), never an exemption for the caller.
|
||||
2. **Non-substitute witness:** the P1 surfaces (hierarchy, RBAC, kanban,
|
||||
enrollment, roll-up, wizard UI) make zero calls to the §3.2 legacy
|
||||
endpoints — asserted by the same inventory, scoped per surface.
|
||||
3. **Envelope witnesses per family:** for each build-first family — a
|
||||
request with an invalid DTO is refused with the validation code; a
|
||||
version-mismatch mutation returns the conflict code; an unauthorized
|
||||
read of an existing node and a read of a nonexistent node return
|
||||
indistinguishable results where the no-existence-oracle rule applies;
|
||||
a correlation id submitted on a mutation appears in its audit
|
||||
event(s) and result. Two static companions: a type-level assertion
|
||||
that the family's boundary accepts no `any`-typed or unvalidated
|
||||
pass-through payload (§4.1), and a single-emitter assertion that the
|
||||
mapped operation's audit events originate only from the owning
|
||||
contract's audit emitter (§4.3's no-second-audit-stream, made
|
||||
checkable).
|
||||
4. **CLI-parity witness:** for each family, a CLI smoke invocation of at
|
||||
least one command and one query against the Gateway succeeds with the
|
||||
same typed result the web client receives.
|
||||
5. **Fail-closed witness:** with the owning tool or grant state
|
||||
unreachable (fault injection), the mapped operation returns the
|
||||
internal-fault or authorization-refusal class and performs no
|
||||
fallback read/write (extends contract 2 §7.6 to the mapping layer).
|
||||
|
||||
## Ruling request
|
||||
|
||||
Ratify sections 1–6 as written, with one decision embedded:
|
||||
|
||||
- Decision (§3.2): the legacy endpoints named there are **frozen for new
|
||||
consumers** as of ratification — existing P0/host consumers keep
|
||||
working, new UI or tool code may not call them, and each is retired by
|
||||
the migration its owning contract schedules. Alternative if rejected:
|
||||
allow P1 surfaces to reuse legacy endpoints as interim backends —
|
||||
rejected by the audit's finding that they cannot satisfy the
|
||||
hierarchy/kanban/RBAC contracts, so the interim would ship
|
||||
non-conformant semantics.
|
||||
@@ -1,5 +0,0 @@
|
||||
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
@@ -141,13 +141,6 @@
|
||||
"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', 'visibility', 'created_at', 'updated_at'],
|
||||
companies: ['id', 'name', 'slug', '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,61 +377,7 @@ 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: '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,
|
||||
);
|
||||
.values({ userId: userA, companyId, role: `${T}-other-role`, grantedBy: userA });
|
||||
});
|
||||
|
||||
// ── §6.1 NOT NULLs ─────────────────────────────────────────────────────────
|
||||
@@ -445,7 +391,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}, 'viewer', NULL)`,
|
||||
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, 'x', 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. Its
|
||||
* single entry is the M4-1b-ii hierarchy command repository — the sole
|
||||
* production module permitted to write the class tables. The infrastructure register
|
||||
* 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
|
||||
* 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,15 +181,13 @@ const CLASS_TABLES = [
|
||||
|
||||
/**
|
||||
* Writer allowlist (§6.3b): hierarchy command/repository modules only.
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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',
|
||||
];
|
||||
const WRITER_ALLOWLIST: string[] = [];
|
||||
|
||||
/**
|
||||
* Infrastructure register: closed enumeration of legitimate non-hierarchy raw
|
||||
|
||||
@@ -1063,24 +1063,13 @@ 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.
|
||||
|
||||
/** 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 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(),
|
||||
});
|
||||
|
||||
export const estates = pgTable(
|
||||
'estates',
|
||||
@@ -1121,10 +1110,6 @@ 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',
|
||||
{
|
||||
@@ -1141,8 +1126,7 @@ export const hierarchyGrants = pgTable(
|
||||
platformProjectId: uuid('platform_project_id').references(() => platformProjects.id, {
|
||||
onDelete: 'cascade',
|
||||
}),
|
||||
// Role vocabulary per contract 2 §2: exactly viewer ⊂ member ⊂ owner,
|
||||
// totally ordered; CHECK below closes the column to that vocabulary.
|
||||
// Role vocabulary and its CHECK constraint are contract 2 §2 (M4-2).
|
||||
role: text('role').notNull(),
|
||||
grantedBy: text('granted_by')
|
||||
.notNull()
|
||||
@@ -1150,7 +1134,6 @@ 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',
|
||||
@@ -1187,7 +1170,6 @@ export const HIERARCHY_AUDIT_VERBS = [
|
||||
'create',
|
||||
'rename',
|
||||
'transfer',
|
||||
'visibility_change',
|
||||
'delete',
|
||||
'grant_create',
|
||||
'grant_change',
|
||||
@@ -1238,7 +1220,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', 'visibility_change', 'delete', 'grant_create', 'grant_change', 'grant_revoke')`,
|
||||
sql`verb IN ('create', 'rename', 'transfer', 'delete', 'grant_create', 'grant_change', 'grant_revoke')`,
|
||||
),
|
||||
check(
|
||||
'hierarchy_audit_events_target_kind_check',
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
# 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"
|
||||
@@ -34,36 +33,25 @@ Examples:
|
||||
$(basename "$0") -i 42 -l "in-progress" -m "0.2.0"
|
||||
$(basename "$0") -i 42 -a @me
|
||||
EOF
|
||||
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
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# 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
|
||||
;;
|
||||
@@ -91,35 +79,20 @@ PLATFORM=$(detect_platform)
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
if [[ -n "$ASSIGNEE" ]]; then
|
||||
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; }
|
||||
gh issue edit "$ISSUE" --add-assignee "$ASSIGNEE"
|
||||
fi
|
||||
if [[ "$REMOVE_ASSIGNEE" == true ]]; then
|
||||
# Get current assignees and remove them
|
||||
# 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
|
||||
}
|
||||
CURRENT=$(gh issue view "$ISSUE" --json assignees -q '.assignees[].login' 2>/dev/null | tr '\n' ',')
|
||||
if [[ -n "$CURRENT" ]]; then
|
||||
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; }
|
||||
gh issue edit "$ISSUE" --remove-assignee "${CURRENT%,}"
|
||||
fi
|
||||
fi
|
||||
if [[ -n "$LABELS" ]]; then
|
||||
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; }
|
||||
gh issue edit "$ISSUE" --add-label "$LABELS"
|
||||
fi
|
||||
if [[ -n "$MILESTONE" ]]; then
|
||||
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; }
|
||||
gh issue edit "$ISSUE" --milestone "$MILESTONE"
|
||||
fi
|
||||
echo "Issue #$ISSUE updated successfully"
|
||||
;;
|
||||
@@ -158,9 +131,7 @@ case "$PLATFORM" in
|
||||
fi
|
||||
|
||||
if [[ "$NEEDS_EDIT" == true ]]; then
|
||||
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; }
|
||||
"${CMD[@]}"
|
||||
echo "Issue #$ISSUE updated successfully"
|
||||
else
|
||||
echo "No changes specified"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
# issue-close.sh - Close an issue on GitHub or Gitea
|
||||
# Usage: issue-close.sh -i <issue_number> [-b <comment>]
|
||||
# (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28)
|
||||
# Usage: issue-close.sh -i <issue_number> [-c <comment>]
|
||||
|
||||
set -e
|
||||
|
||||
@@ -13,49 +12,35 @@ 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
|
||||
;;
|
||||
-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)"
|
||||
-c|--comment)
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: issue-close.sh -i <issue_number> [-b <comment>]"
|
||||
echo "Usage: issue-close.sh -i <issue_number> [-c <comment>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -b, --body Comment to add before closing (optional; canonical)"
|
||||
echo " -c, --comment Alias for --body"
|
||||
echo " -c, --comment Comment to add before closing (optional)"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
usage_error "issue number is required (-i/--issue)"
|
||||
echo "Error: Issue number is required (-i)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect platform and close issue
|
||||
@@ -97,22 +82,10 @@ 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_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
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||
fi
|
||||
gh issue close "$ISSUE_NUMBER"
|
||||
echo "Closed GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
||||
@@ -134,9 +107,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
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; }
|
||||
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||
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 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
||||
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 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
||||
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)"
|
||||
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
||||
LOGIN_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
|
||||
@@ -74,39 +74,26 @@ 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:-2}"
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# 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
|
||||
;;
|
||||
@@ -144,9 +131,7 @@ case "$PLATFORM" in
|
||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||
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; }
|
||||
"${CMD[@]}"
|
||||
;;
|
||||
gitea)
|
||||
if command -v tea >/dev/null 2>&1; then
|
||||
|
||||
@@ -14,39 +14,25 @@ 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
|
||||
;;
|
||||
@@ -60,18 +46,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
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
usage_error "issue number is required (-i/--issue)"
|
||||
echo "Error: Issue number is required (-i)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
@@ -82,9 +68,7 @@ if [[ "$PLATFORM" == "github" ]]; then
|
||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||
[[ -n "$LABELS" ]] && CMD+=(--add-label "$LABELS")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||
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; }
|
||||
"${CMD[@]}"
|
||||
echo "Updated GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
REPO_SLUG=$(get_repo_slug) || {
|
||||
@@ -100,9 +84,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||
[[ -n "$LABELS" ]] && CMD+=(--add-labels "$LABELS")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||
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; }
|
||||
"${CMD[@]}"
|
||||
echo "Updated Gitea issue #$ISSUE_NUMBER"
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
|
||||
@@ -36,46 +36,33 @@ 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:-2}"
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# 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
|
||||
;;
|
||||
@@ -108,9 +95,7 @@ case "$PLATFORM" in
|
||||
[[ -n "$LABEL" ]] && CMD+=(--label "$LABEL")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||
[[ -n "$ASSIGNEE" ]] && CMD+=(--assignee "$ASSIGNEE")
|
||||
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; }
|
||||
"${CMD[@]}"
|
||||
;;
|
||||
gitea)
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
@@ -129,9 +114,7 @@ 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
|
||||
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; }
|
||||
"${CMD[@]}"
|
||||
;;
|
||||
*)
|
||||
echo "Error: Could not detect git platform" >&2
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
# issue-reopen.sh - Reopen a closed issue on GitHub or Gitea
|
||||
# Usage: issue-reopen.sh -i <issue_number> [-b <comment>]
|
||||
# (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28)
|
||||
# Usage: issue-reopen.sh -i <issue_number> [-c <comment>]
|
||||
|
||||
set -e
|
||||
|
||||
@@ -12,49 +11,35 @@ 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
|
||||
;;
|
||||
-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)"
|
||||
-c|--comment)
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: issue-reopen.sh -i <issue_number> [-b <comment>]"
|
||||
echo "Usage: issue-reopen.sh -i <issue_number> [-c <comment>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -b, --body Comment to add when reopening (optional; canonical)"
|
||||
echo " -c, --comment Alias for --body"
|
||||
echo " -c, --comment Comment to add when reopening (optional)"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
usage_error "issue number is required (-i/--issue)"
|
||||
echo "Error: Issue number is required (-i)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
@@ -95,34 +80,18 @@ 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_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
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||
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
|
||||
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; }
|
||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $REPO_ARGS
|
||||
fi
|
||||
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; }
|
||||
tea issue reopen "$ISSUE_NUMBER" $REPO_ARGS
|
||||
else
|
||||
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
|
||||
@@ -8,14 +8,6 @@ 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
|
||||
@@ -82,7 +74,6 @@ 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
|
||||
;;
|
||||
@@ -97,21 +88,21 @@ while [[ $# -gt 0 ]]; do
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
usage_error "Issue number is required"
|
||||
echo "Error: Issue number is required (-i)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
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; }
|
||||
gh issue view "$ISSUE_NUMBER"
|
||||
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,25 +28,18 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
REPO="" MILESTONE="" LABEL="" LOGIN="" LIMIT=100
|
||||
# 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" ;;
|
||||
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 ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$REPO" ]] || usage_error "-r/--repo <owner/repo> required"
|
||||
[[ -n "$REPO" ]] || { echo "FATAL: -r <owner/repo> required" >&2; exit 2; }
|
||||
|
||||
# Resolve login: explicit -L, then $GITEA_LOGIN, then owner inference, then the
|
||||
# shared default-login resolver. Owner inference comes before the shared fallback
|
||||
@@ -79,7 +72,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 1; }
|
||||
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 2; }
|
||||
|
||||
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,20 +8,11 @@ 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
|
||||
;;
|
||||
@@ -34,30 +25,28 @@ while [[ $# -gt 0 ]]; do
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$TITLE" ]]; then
|
||||
usage_error "Milestone title is required"
|
||||
echo "Error: Milestone title is required (-t)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
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; }
|
||||
gh api -X PATCH "/repos/{owner}/{repo}/milestones/$(gh api "/repos/{owner}/{repo}/milestones" --jq ".[] | select(.title==\"$TITLE\") | .number")" -f state=closed
|
||||
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
|
||||
}
|
||||
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; }
|
||||
tea milestone close "$TITLE" $REPO_ARGS
|
||||
echo "Closed Gitea milestone: $TITLE"
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
# 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"
|
||||
@@ -38,31 +37,21 @@ 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:-2}"
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# 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
|
||||
;;
|
||||
@@ -85,18 +74,14 @@ PLATFORM=$(detect_platform)
|
||||
if [[ "$LIST_ONLY" == true ]]; then
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
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; }
|
||||
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)\t\(.title)\t\(.state)\t\(.open_issues)/\(.closed_issues) issues"'
|
||||
;;
|
||||
gitea)
|
||||
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
||||
exit 1
|
||||
}
|
||||
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; }
|
||||
tea milestones list $REPO_ARGS
|
||||
;;
|
||||
*)
|
||||
echo "Error: Could not detect git platform" >&2
|
||||
@@ -107,7 +92,8 @@ if [[ "$LIST_ONLY" == true ]]; then
|
||||
fi
|
||||
|
||||
if [[ -z "$TITLE" ]]; then
|
||||
usage_error "Title is required (-t) for creating milestones"
|
||||
echo "Error: Title is required (-t) for creating milestones" >&2
|
||||
usage
|
||||
fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
@@ -123,9 +109,7 @@ case "$PLATFORM" in
|
||||
+ (if $d != "" then {"description": $d} else {} end)
|
||||
+ (if $due != "" then {"due_on": ($due + "T00:00:00Z")} else {} end)')
|
||||
|
||||
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; }
|
||||
gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD"
|
||||
echo "Milestone '$TITLE' created successfully"
|
||||
;;
|
||||
gitea)
|
||||
@@ -136,9 +120,7 @@ case "$PLATFORM" in
|
||||
CMD=(tea milestones create --title "$TITLE")
|
||||
[[ -n "$DESCRIPTION" ]] && CMD+=(--description "$DESCRIPTION")
|
||||
[[ -n "$DUE_DATE" ]] && CMD+=(--deadline "$DUE_DATE")
|
||||
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; }
|
||||
"${CMD[@]}" $REPO_ARGS
|
||||
echo "Milestone '$TITLE' created successfully"
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -8,20 +8,11 @@ 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
|
||||
;;
|
||||
@@ -34,7 +25,8 @@ while [[ $# -gt 0 ]]; do
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
@@ -42,17 +34,13 @@ done
|
||||
detect_platform >/dev/null
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
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; }
|
||||
gh api "/repos/{owner}/{repo}/milestones?state=$STATE" --jq '.[] | "\(.title) (\(.state)) - \(.open_issues) open, \(.closed_issues) closed"'
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
||||
exit 1
|
||||
}
|
||||
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; }
|
||||
tea milestone list $REPO_ARGS
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
exit 1
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
# pr-close.sh - Close a pull request without merging on GitHub or Gitea
|
||||
# Usage: pr-close.sh -n <pr_number> [-b <comment>]
|
||||
# (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28)
|
||||
# Usage: pr-close.sh -n <pr_number> [-c <comment>]
|
||||
|
||||
set -e
|
||||
|
||||
@@ -12,80 +11,50 @@ 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
|
||||
;;
|
||||
-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)"
|
||||
-c|--comment)
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: pr-close.sh -n <pr_number> [-b <comment>]"
|
||||
echo "Usage: pr-close.sh -n <pr_number> [-c <comment>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -n, --number PR number (required)"
|
||||
echo " -b, --body Comment before closing (optional; canonical)"
|
||||
echo " -c, --comment Alias for --body"
|
||||
echo " -c, --comment Comment before closing (optional)"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$PR_NUMBER" ]]; then
|
||||
usage_error "PR number is required (-n/--number)"
|
||||
echo "Error: PR number is required (-n)"
|
||||
exit 1
|
||||
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_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
|
||||
gh pr comment "$PR_NUMBER" --body "$COMMENT"
|
||||
fi
|
||||
gh pr close "$PR_NUMBER"
|
||||
echo "Closed GitHub PR #$PR_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
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; }
|
||||
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||
fi
|
||||
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; }
|
||||
tea pr close "$PR_NUMBER" $(get_gitea_repo_args)
|
||||
echo "Closed Gitea PR #$PR_NUMBER"
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
|
||||
@@ -135,51 +135,37 @@ Examples:
|
||||
$(basename "$0") -i 42 -b "Implements the feature described in #42"
|
||||
$(basename "$0") -t "WIP: New feature" --draft
|
||||
EOF
|
||||
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
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# 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
|
||||
;;
|
||||
@@ -280,9 +266,7 @@ case "$PLATFORM" in
|
||||
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||
[[ "$DRAFT" == true ]] && CMD+=(--draft)
|
||||
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; }
|
||||
"${CMD[@]}"
|
||||
;;
|
||||
gitea)
|
||||
# tea pull create syntax. Always pass --repo because tea repo inference
|
||||
|
||||
@@ -50,45 +50,38 @@ Options:
|
||||
-H, --host HOST Explicit Gitea host (required with --repo off-host)
|
||||
-h, --help Show this help message
|
||||
EOF
|
||||
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
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
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 ;;
|
||||
-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 ;;
|
||||
-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 ;;
|
||||
--draft)
|
||||
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
|
||||
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
||||
DRAFT_MODE="draft"; shift ;;
|
||||
--ready)
|
||||
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
|
||||
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
||||
DRAFT_MODE="ready"; shift ;;
|
||||
-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 ;;
|
||||
-l|--login) LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-r|--repo) REPO_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-H|--host) 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 2; }
|
||||
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 2; }
|
||||
[[ -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; }
|
||||
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
|
||||
echo "Error: At least one edit option is required" >&2
|
||||
exit 2
|
||||
exit 1
|
||||
fi
|
||||
[[ -z "$REPO_OVERRIDE" || "$REPO_OVERRIDE" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || {
|
||||
echo "Error: --repo must be OWNER/REPO" >&2
|
||||
exit 2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ -n "$HOST_OVERRIDE" || -n "$REPO_OVERRIDE" ]]; then
|
||||
@@ -99,24 +92,18 @@ fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 2; }
|
||||
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 1; }
|
||||
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")
|
||||
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; }
|
||||
"${CMD[@]}"
|
||||
fi
|
||||
if [[ "$DRAFT_MODE" == "draft" ]]; then
|
||||
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; }
|
||||
gh pr ready "$PR_NUMBER" --undo
|
||||
elif [[ "$DRAFT_MODE" == "ready" ]]; then
|
||||
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; }
|
||||
gh pr ready "$PR_NUMBER"
|
||||
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] [--base-line BRANCH] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--no-ci-expected] [--co-author-trailers --escalate-to PRINCIPAL]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -30,12 +30,6 @@ 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
|
||||
@@ -52,7 +46,6 @@ EOF
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
BASE_LINE_OVERRIDE=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-n|--number)
|
||||
@@ -71,11 +64,6 @@ 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
|
||||
@@ -184,17 +172,8 @@ if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
|
||||
else
|
||||
repo_decl_warn_absent_irreversible "pr-merge"
|
||||
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
|
||||
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
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
|
||||
@@ -43,92 +43,60 @@ 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
|
||||
;;
|
||||
-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)"
|
||||
-c|--comment)
|
||||
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> [-b <comment>] [--login <name>] [-r owner/repo] [-H host]"
|
||||
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <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 " -b, --body Review comment (required for request-changes; canonical)"
|
||||
echo " -c, --comment Alias for --body"
|
||||
echo " -c, --comment Review comment (required for request-changes)"
|
||||
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
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown option: $1"
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$PR_NUMBER" ]]; then
|
||||
usage_error "PR number is required (-n/--number)"
|
||||
echo "Error: PR number is required (-n)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$ACTION" ]]; then
|
||||
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)"
|
||||
echo "Error: Action is required (-a): approve, request-changes, comment"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
@@ -711,18 +679,15 @@ PY
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
case $ACTION in
|
||||
approve)
|
||||
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; }
|
||||
gh pr review "$PR_NUMBER" --approve ${COMMENT:+--body "$COMMENT"}
|
||||
echo "Approved GitHub PR #$PR_NUMBER"
|
||||
;;
|
||||
request-changes)
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
usage_error "comment required for request-changes (-b/--body)"
|
||||
echo "Error: Comment required for request-changes"
|
||||
exit 1
|
||||
fi
|
||||
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; }
|
||||
gh pr review "$PR_NUMBER" --request-changes --body "$COMMENT"
|
||||
echo "Requested changes on GitHub PR #$PR_NUMBER"
|
||||
;;
|
||||
comment)
|
||||
@@ -730,13 +695,12 @@ if [[ "$PLATFORM" == "github" ]]; then
|
||||
echo "Error: Comment required"
|
||||
exit 1
|
||||
fi
|
||||
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; }
|
||||
gh pr review "$PR_NUMBER" --comment --body "$COMMENT"
|
||||
echo "Added review comment to GitHub PR #$PR_NUMBER"
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown action: $ACTION"
|
||||
echo "Error: Unknown action: $ACTION"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
@@ -774,7 +738,8 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
;;
|
||||
request-changes)
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
usage_error "comment required for request-changes (-b/--body)"
|
||||
echo "Error: Comment required for request-changes"
|
||||
exit 1
|
||||
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
|
||||
@@ -829,7 +794,8 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown action: $ACTION"
|
||||
echo "Error: Unknown action: $ACTION"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,136 +0,0 @@
|
||||
#!/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 "$2: stderr missing '$1'"
|
||||
grep -q "$1" "$ERR_FILE" || fail "$desc: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage on stdout.
|
||||
@@ -124,13 +124,6 @@ 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).
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,130 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,136 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/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)"
|
||||
Executable → Regular
@@ -1,132 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,153 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,133 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/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)"
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/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)"
|
||||
Executable → Regular
@@ -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)"
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
#!/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
@@ -1162,8 +1162,6 @@ 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 {
|
||||
@@ -1197,7 +1195,6 @@ 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);
|
||||
|
||||
Reference in New Issue
Block a user