Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3e1a761aa | ||
|
|
54adbd2b3d | ||
|
|
311b4dda59 |
@@ -1,106 +0,0 @@
|
||||
import { RequestMethod, type Type } from '@nestjs/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AppModule } from '../app.module.js';
|
||||
import { HierarchyModule } from '../hierarchy/hierarchy.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy route-inventory baseline (contract 1 §6.3(a)).
|
||||
*
|
||||
* 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 {
|
||||
method: string;
|
||||
path: string;
|
||||
controller: string;
|
||||
}
|
||||
|
||||
/** Module-metadata entry: a module class or a DynamicModule-shaped object. */
|
||||
type ModuleEntry =
|
||||
| Type<unknown>
|
||||
| { module: Type<unknown>; imports?: unknown[]; controllers?: Type<unknown>[] };
|
||||
|
||||
function collectControllers(root: ModuleEntry): Type<unknown>[] {
|
||||
const visited = new Set<unknown>();
|
||||
const controllers: Type<unknown>[] = [];
|
||||
const walk = (entry: ModuleEntry | undefined | null): void => {
|
||||
if (!entry || visited.has(entry)) return;
|
||||
visited.add(entry);
|
||||
const moduleClass = typeof entry === 'function' ? entry : entry.module;
|
||||
// Entries with no resolvable class (forwardRef wrappers, async dynamic
|
||||
// modules) carry no decorator metadata to read here.
|
||||
if (typeof moduleClass !== 'function') return;
|
||||
if (visited.has(moduleClass) && typeof entry !== 'function') return;
|
||||
visited.add(moduleClass);
|
||||
// 'controllers' / 'imports' are the metadata keys the @Module decorator writes.
|
||||
const declared = (Reflect.getMetadata('controllers', moduleClass) ?? []) as Type<unknown>[];
|
||||
controllers.push(...declared);
|
||||
if (typeof entry !== 'function' && entry.controllers) controllers.push(...entry.controllers);
|
||||
const imports = [
|
||||
...((Reflect.getMetadata('imports', moduleClass) ?? []) as ModuleEntry[]),
|
||||
...(typeof entry !== 'function' ? ((entry.imports ?? []) as ModuleEntry[]) : []),
|
||||
];
|
||||
for (const imported of imports) walk(imported);
|
||||
};
|
||||
walk(root);
|
||||
return controllers;
|
||||
}
|
||||
|
||||
function routesOf(controller: Type<unknown>): RouteEntry[] {
|
||||
// 'path' on the class is the @Controller prefix; 'path'/'method' on a
|
||||
// handler are written by the @Get/@Post/... route decorators.
|
||||
const base = (Reflect.getMetadata('path', controller) ?? '') as string | string[];
|
||||
const bases = Array.isArray(base) ? base : [base];
|
||||
const routes: RouteEntry[] = [];
|
||||
const prototype = controller.prototype as Record<string, unknown>;
|
||||
for (const name of Object.getOwnPropertyNames(prototype)) {
|
||||
if (name === 'constructor') continue;
|
||||
const handler = Object.getOwnPropertyDescriptor(prototype, name)?.value;
|
||||
if (typeof handler !== 'function') continue;
|
||||
const method = Reflect.getMetadata('method', handler) as number | undefined;
|
||||
if (method === undefined) continue;
|
||||
const sub = (Reflect.getMetadata('path', handler) ?? '/') as string;
|
||||
for (const prefix of bases) {
|
||||
const path = `/${prefix}/${sub}`.replace(/\/+/g, '/').replace(/(.)\/$/, '$1');
|
||||
routes.push({
|
||||
method: RequestMethod[method] ?? String(method),
|
||||
path,
|
||||
controller: controller.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
describe('hierarchy route-inventory baseline (§6.3(a))', () => {
|
||||
const inventory = collectControllers(AppModule).flatMap(routesOf);
|
||||
|
||||
it('control: the enumeration sees the known route surface', () => {
|
||||
const paths = inventory.map((r) => `${r.method} ${r.path}`);
|
||||
expect(paths).toContain('GET /health');
|
||||
expect(paths).toContain('POST /api/workspaces');
|
||||
expect(paths).toContain('GET /api/teams');
|
||||
expect(inventory.length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('declares zero hierarchy mutation routes before M4-1b-ii', () => {
|
||||
const hierarchyRoutes = inventory.filter((r) =>
|
||||
/hierarch|compan|estate|platform[-_]?project/i.test(r.path),
|
||||
);
|
||||
expect(
|
||||
hierarchyRoutes,
|
||||
'a hierarchy route landed without replacing the §6.3(a) baseline with a command-family inventory',
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('HierarchyModule itself declares no controllers', () => {
|
||||
expect((Reflect.getMetadata('controllers', HierarchyModule) ?? []) as unknown[]).toEqual([]);
|
||||
const hierarchyControllers = collectControllers(HierarchyModule);
|
||||
expect(hierarchyControllers).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,6 @@ import { GCModule } from './gc/gc.module.js';
|
||||
import { HarnessModule } from './harness/harness.module.js';
|
||||
import { ReloadModule } from './reload/reload.module.js';
|
||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||
import { HierarchyModule } from './hierarchy/hierarchy.module.js';
|
||||
import { QueueModule } from './queue/queue.module.js';
|
||||
import { FederationModule } from './federation/federation.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
@@ -66,7 +65,6 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
||||
QueueModule,
|
||||
ReloadModule,
|
||||
WorkspaceModule,
|
||||
HierarchyModule,
|
||||
...(federationEnabled ? [FederationModule] : []),
|
||||
],
|
||||
controllers: [HealthController],
|
||||
|
||||
@@ -1,247 +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,
|
||||
hierarchyOutbox,
|
||||
platformProjects,
|
||||
runPgliteMigrations,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import {
|
||||
HierarchyAuditIdempotencyConflictError,
|
||||
HierarchyAuditRepository,
|
||||
HierarchyNodeNotFoundError,
|
||||
type AppendHierarchyEventInput,
|
||||
} from './hierarchy-audit.repository.js';
|
||||
|
||||
/**
|
||||
* Repository-level §6.4 witnesses for the hierarchy audit machinery
|
||||
* (contract 1 §5.2, REQ-AUD-001): same-transaction atomicity of state +
|
||||
* event + outbox, rollback leaving no residue, idempotent replay, snapshot
|
||||
* parent chains, events surviving target deletion, per-target ordering, and
|
||||
* the outbox claim/complete/release CAS. The schema-level constraints are
|
||||
* witnessed in packages/db/src/hierarchy-audit.witness.test.ts.
|
||||
*/
|
||||
describe('hierarchy audit repository integration', (): void => {
|
||||
let dataDir: string;
|
||||
let handle: DbHandle;
|
||||
let moduleRef: TestingModule;
|
||||
let repo: HierarchyAuditRepository;
|
||||
|
||||
const input = (
|
||||
overrides: Partial<AppendHierarchyEventInput> = {},
|
||||
): AppendHierarchyEventInput => ({
|
||||
actorId: 'user-actor',
|
||||
verb: 'create',
|
||||
targetKind: 'company',
|
||||
targetId: randomUUID(),
|
||||
targetSnapshot: { id: 'x', slug: 'x', name: 'x', parentChain: [] },
|
||||
correlationId: 'corr-1',
|
||||
idempotencyKey: `key-${randomUUID()}`,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-audit-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [HierarchyAuditRepository, { provide: DB, useValue: handle.db }],
|
||||
}).compile();
|
||||
repo = moduleRef.get(HierarchyAuditRepository);
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
await moduleRef.close();
|
||||
await handle.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('commits state, event, and outbox record atomically in one transaction', async () => {
|
||||
const companyId = randomUUID();
|
||||
const key = `key-${randomUUID()}`;
|
||||
await handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Atomic Co', slug: 'atomic-co' });
|
||||
const snapshot = await repo.snapshot(tx, 'company', companyId);
|
||||
const result = await repo.append(tx, {
|
||||
...input({ targetId: companyId, idempotencyKey: key }),
|
||||
targetSnapshot: { ...snapshot },
|
||||
});
|
||||
expect(result.replayed).toBe(false);
|
||||
expect(result.event.idempotencyKey).toBe(key);
|
||||
});
|
||||
const events = await handle.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
|
||||
expect(events).toHaveLength(1);
|
||||
const outbox = await handle.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.eventId, events[0]!.id));
|
||||
expect(outbox).toHaveLength(1);
|
||||
expect(outbox[0]).toMatchObject({
|
||||
status: 'pending',
|
||||
idempotencyKey: key,
|
||||
correlationId: 'corr-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('a rolled-back transaction leaves no state, no event, and no outbox record', async () => {
|
||||
const companyId = randomUUID();
|
||||
const key = `key-${randomUUID()}`;
|
||||
await expect(
|
||||
handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Doomed Co', slug: 'doomed-co' });
|
||||
await repo.append(tx, input({ targetId: companyId, idempotencyKey: key }));
|
||||
throw new Error('deliberate rollback');
|
||||
}),
|
||||
).rejects.toThrow('deliberate rollback');
|
||||
const [companyRows, eventRows, outboxRows] = await Promise.all([
|
||||
handle.db.select().from(companies).where(eq(companies.id, companyId)),
|
||||
handle.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, key)),
|
||||
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key)),
|
||||
]);
|
||||
expect(companyRows).toHaveLength(0);
|
||||
expect(eventRows).toHaveLength(0);
|
||||
expect(outboxRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('replays a duplicate idempotency key without inserting a second event or outbox record', async () => {
|
||||
const first = input();
|
||||
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
expect(original.replayed).toBe(false);
|
||||
expect(replay.replayed).toBe(true);
|
||||
expect(replay.event.id).toBe(original.event.id);
|
||||
const outbox = await handle.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.eventId, original.event.id));
|
||||
expect(outbox).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws on a duplicate idempotency key carrying different event content', async () => {
|
||||
const first = input();
|
||||
await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
await expect(
|
||||
handle.db.transaction(async (tx) =>
|
||||
repo.append(tx, { ...first, verb: 'rename', targetId: randomUUID() }),
|
||||
),
|
||||
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
|
||||
});
|
||||
|
||||
it('throws on a duplicate idempotency key whose transfer destination differs', async () => {
|
||||
const from = { kind: 'company' as const, id: randomUUID(), slug: 'src-co' };
|
||||
const to = { kind: 'company' as const, id: randomUUID(), slug: 'dst-co' };
|
||||
const first = input({
|
||||
verb: 'transfer',
|
||||
targetKind: 'estate',
|
||||
transferFrom: from,
|
||||
transferTo: to,
|
||||
});
|
||||
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
expect(original.replayed).toBe(false);
|
||||
// Identical retry replays; a retry re-routed to a different destination must conflict.
|
||||
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
expect(replay.replayed).toBe(true);
|
||||
await expect(
|
||||
handle.db.transaction(async (tx) =>
|
||||
repo.append(tx, { ...first, transferTo: { ...to, id: randomUUID() } }),
|
||||
),
|
||||
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
|
||||
});
|
||||
|
||||
it('builds root-first parent chains and rejects unknown nodes', async () => {
|
||||
const companyId = randomUUID();
|
||||
const estateId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
await handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Chain Co', slug: 'chain-co' });
|
||||
await tx
|
||||
.insert(estates)
|
||||
.values({ id: estateId, name: 'Chain Estate', slug: 'chain-estate', companyId });
|
||||
await tx
|
||||
.insert(platformProjects)
|
||||
.values({ id: projectId, name: 'Chain Project', slug: 'chain-project', estateId });
|
||||
});
|
||||
const snapshot = await repo.snapshot(handle.db, 'platform_project', projectId);
|
||||
expect(snapshot).toMatchObject({ id: projectId, slug: 'chain-project', name: 'Chain Project' });
|
||||
expect(snapshot.parentChain).toEqual([
|
||||
{ kind: 'company', id: companyId, slug: 'chain-co' },
|
||||
{ kind: 'estate', id: estateId, slug: 'chain-estate' },
|
||||
]);
|
||||
await expect(repo.snapshot(handle.db, 'estate', randomUUID())).rejects.toThrow(
|
||||
HierarchyNodeNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps events readable, in per-target seq order, after the target row is deleted', async () => {
|
||||
const companyId = randomUUID();
|
||||
await handle.db.transaction(async (tx) => {
|
||||
await tx.insert(companies).values({ id: companyId, name: 'Mortal Co', slug: 'mortal-co' });
|
||||
const snapshot = await repo.snapshot(tx, 'company', companyId);
|
||||
await repo.append(tx, input({ targetId: companyId, targetSnapshot: { ...snapshot } }));
|
||||
});
|
||||
await handle.db.transaction(async (tx) => {
|
||||
const snapshot = await repo.snapshot(tx, 'company', companyId);
|
||||
await repo.append(tx, {
|
||||
...input({ verb: 'delete', targetId: companyId }),
|
||||
targetSnapshot: { ...snapshot },
|
||||
});
|
||||
await tx.delete(companies).where(eq(companies.id, companyId));
|
||||
});
|
||||
const events = await repo.eventsForTarget(companyId);
|
||||
expect(events.map((e) => e.verb)).toEqual(['create', 'delete']);
|
||||
expect(events[1]!.seq).toBeGreaterThan(events[0]!.seq);
|
||||
expect((events[1]!.targetSnapshot as { id: string }).id).toBe(companyId);
|
||||
});
|
||||
|
||||
it('claims the oldest pending outbox record exactly once, completes and releases by CAS', async () => {
|
||||
// Drain records left pending by earlier cases so ordering is deterministic.
|
||||
for (;;) {
|
||||
const drained = await repo.claimPendingOutbox();
|
||||
if (!drained) break;
|
||||
await repo.completeOutbox(drained.id);
|
||||
}
|
||||
const older = await handle.db.transaction(async (tx) => repo.append(tx, input()));
|
||||
const newer = await handle.db.transaction(async (tx) => repo.append(tx, input()));
|
||||
|
||||
const claimed = await repo.claimPendingOutbox();
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.eventId).toBe(older.event.id);
|
||||
expect(claimed!.status).toBe('processing');
|
||||
|
||||
// Delivery fails: release returns it to pending and it is claimable again.
|
||||
await repo.releaseOutbox(claimed!.id);
|
||||
const reclaimed = await repo.claimPendingOutbox();
|
||||
expect(reclaimed!.id).toBe(claimed!.id);
|
||||
|
||||
await repo.completeOutbox(reclaimed!.id);
|
||||
const done = await handle.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.id, reclaimed!.id));
|
||||
expect(done[0]!.status).toBe('delivered');
|
||||
expect(done[0]!.deliveredAt).not.toBeNull();
|
||||
// completeOutbox is CAS-guarded on 'processing': completing again is a no-op.
|
||||
await repo.completeOutbox(reclaimed!.id);
|
||||
|
||||
const second = await repo.claimPendingOutbox();
|
||||
expect(second!.eventId).toBe(newer.event.id);
|
||||
await repo.completeOutbox(second!.id);
|
||||
expect(await repo.claimPendingOutbox()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,274 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
companies,
|
||||
eq,
|
||||
estates,
|
||||
hierarchyAuditEvents,
|
||||
hierarchyOutbox,
|
||||
platformProjects,
|
||||
type Db,
|
||||
type HIERARCHY_AUDIT_TARGET_KINDS,
|
||||
type HIERARCHY_AUDIT_VERBS,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
/**
|
||||
* Hierarchy audit event + outbox machinery (contract 1 §5.2).
|
||||
*
|
||||
* Every hierarchy mutation writes its semantic audit event AND the event's
|
||||
* outbox record on the caller's transaction, so state, event, and outbox
|
||||
* commit or roll back together. Events reference their target by an
|
||||
* immutable snapshot (id, slug, parent chain at event time), never by a
|
||||
* foreign key into the class tables — append-only events survive the
|
||||
* deletion of their target. This module exposes no update or delete path
|
||||
* for events: append-only is a property of the code surface, witnessed by
|
||||
* the integration tests.
|
||||
*
|
||||
* This is NOT a class-table writer: it touches only the audit/outbox
|
||||
* tables, so it does not appear on the writer-coverage allowlist. The
|
||||
* hierarchy command repositories (M4-1b-ii) are the allowlisted writers and
|
||||
* call into this on their own transactions.
|
||||
*/
|
||||
|
||||
export type HierarchyAuditVerb = (typeof HIERARCHY_AUDIT_VERBS)[number];
|
||||
export type HierarchyTargetKind = (typeof HIERARCHY_AUDIT_TARGET_KINDS)[number];
|
||||
export type HierarchyNodeKind = Exclude<HierarchyTargetKind, 'grant'>;
|
||||
|
||||
export interface ParentChainEntry {
|
||||
readonly kind: HierarchyNodeKind;
|
||||
readonly id: string;
|
||||
readonly slug: string;
|
||||
}
|
||||
|
||||
/** Immutable node snapshot at event time; parentChain is root-first. */
|
||||
export interface HierarchyNodeSnapshot {
|
||||
readonly id: string;
|
||||
readonly slug: string;
|
||||
readonly name: string;
|
||||
readonly parentChain: readonly ParentChainEntry[];
|
||||
}
|
||||
|
||||
export interface AppendHierarchyEventInput {
|
||||
readonly actorId: string;
|
||||
readonly verb: HierarchyAuditVerb;
|
||||
readonly targetKind: HierarchyTargetKind;
|
||||
readonly targetId: string;
|
||||
/** Node events: HierarchyNodeSnapshot. Grant events: subject/target/role snapshot (contract 2 §4.4). */
|
||||
readonly targetSnapshot: Record<string, unknown>;
|
||||
/** Present exactly on transfers (CHECK-enforced): source/destination parent { kind, id, slug }. */
|
||||
readonly transferFrom?: ParentChainEntry;
|
||||
readonly transferTo?: ParentChainEntry;
|
||||
readonly correlationId: string;
|
||||
/** Prior event in the causal chain (e.g. the delete event causing cascaded grant_revoke events). */
|
||||
readonly causationId?: string;
|
||||
readonly idempotencyKey: string;
|
||||
}
|
||||
|
||||
export type HierarchyAuditEventRow = typeof hierarchyAuditEvents.$inferSelect;
|
||||
export type HierarchyOutboxRow = typeof hierarchyOutbox.$inferSelect;
|
||||
|
||||
export interface AppendHierarchyEventResult {
|
||||
readonly event: HierarchyAuditEventRow;
|
||||
/** True when the idempotency key had already committed an identical event (REQ-AUD-001 duplicate suppression). */
|
||||
readonly replayed: boolean;
|
||||
}
|
||||
|
||||
type Tx = Pick<Db, 'insert' | 'select'>;
|
||||
|
||||
export class HierarchyAuditIdempotencyConflictError extends Error {
|
||||
constructor(idempotencyKey: string) {
|
||||
super(
|
||||
`hierarchy audit idempotency key ${idempotencyKey} already exists with different event content`,
|
||||
);
|
||||
this.name = 'HierarchyAuditIdempotencyConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class HierarchyNodeNotFoundError extends Error {
|
||||
constructor(kind: HierarchyNodeKind, id: string) {
|
||||
super(`hierarchy node not found: ${kind} ${id}`);
|
||||
this.name = 'HierarchyNodeNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one audit event and its outbox record on the caller's transaction.
|
||||
* A duplicate idempotency key with identical semantic content returns the
|
||||
* prior event (replayed: true) without inserting anything; a duplicate key
|
||||
* with different content throws.
|
||||
*/
|
||||
export async function appendHierarchyEvent(
|
||||
tx: Tx,
|
||||
input: AppendHierarchyEventInput,
|
||||
): Promise<AppendHierarchyEventResult> {
|
||||
const inserted = await tx
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values({
|
||||
actorId: input.actorId,
|
||||
verb: input.verb,
|
||||
targetKind: input.targetKind,
|
||||
targetId: input.targetId,
|
||||
targetSnapshot: input.targetSnapshot,
|
||||
transferFrom: input.transferFrom ?? null,
|
||||
transferTo: input.transferTo ?? null,
|
||||
correlationId: input.correlationId,
|
||||
causationId: input.causationId ?? null,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const event = inserted[0];
|
||||
if (event) {
|
||||
await tx.insert(hierarchyOutbox).values({
|
||||
eventId: event.id,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
correlationId: input.correlationId,
|
||||
});
|
||||
return { event, replayed: false };
|
||||
}
|
||||
|
||||
const prior = await tx
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.idempotencyKey, input.idempotencyKey))
|
||||
.limit(1);
|
||||
const existing = prior[0];
|
||||
if (!existing || !sameEvent(existing, input)) {
|
||||
throw new HierarchyAuditIdempotencyConflictError(input.idempotencyKey);
|
||||
}
|
||||
// Event and outbox committed atomically the first time, so the outbox
|
||||
// record already exists; a replay inserts nothing.
|
||||
return { event: existing, replayed: true };
|
||||
}
|
||||
|
||||
/** Key-order-independent serialization: jsonb does not preserve key order. */
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
const body = Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
||||
.join(',');
|
||||
return `{${body}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function sameEvent(row: HierarchyAuditEventRow, input: AppendHierarchyEventInput): boolean {
|
||||
return (
|
||||
row.actorId === input.actorId &&
|
||||
row.verb === input.verb &&
|
||||
row.targetKind === input.targetKind &&
|
||||
row.targetId === input.targetId &&
|
||||
row.correlationId === input.correlationId &&
|
||||
(row.causationId ?? null) === (input.causationId ?? null) &&
|
||||
canonicalJson(row.targetSnapshot) === canonicalJson(input.targetSnapshot) &&
|
||||
// Transfer source/destination are semantic content (§5.2): a retry with a
|
||||
// different destination must conflict, never silently replay.
|
||||
canonicalJson(row.transferFrom ?? null) === canonicalJson(input.transferFrom ?? null) &&
|
||||
canonicalJson(row.transferTo ?? null) === canonicalJson(input.transferTo ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the immutable snapshot for a node: its row plus the parent chain up
|
||||
* to the company root, root-first, read on the caller's transaction so the
|
||||
* snapshot is consistent with the mutation it audits.
|
||||
*/
|
||||
export async function buildNodeSnapshot(
|
||||
tx: Tx,
|
||||
kind: HierarchyNodeKind,
|
||||
id: string,
|
||||
): Promise<HierarchyNodeSnapshot> {
|
||||
if (kind === 'company') {
|
||||
const rows = await tx.select().from(companies).where(eq(companies.id, id)).limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
|
||||
return { id: row.id, slug: row.slug, name: row.name, parentChain: [] };
|
||||
}
|
||||
if (kind === 'estate') {
|
||||
const rows = await tx.select().from(estates).where(eq(estates.id, id)).limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
|
||||
const parent = await buildNodeSnapshot(tx, 'company', row.companyId);
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
parentChain: [...parent.parentChain, { kind: 'company', id: parent.id, slug: parent.slug }],
|
||||
};
|
||||
}
|
||||
const rows = await tx.select().from(platformProjects).where(eq(platformProjects.id, id)).limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
|
||||
const parent = await buildNodeSnapshot(tx, 'estate', row.estateId);
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
parentChain: [...parent.parentChain, { kind: 'estate', id: parent.id, slug: parent.slug }],
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HierarchyAuditRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/** Compose an event+outbox append into a caller-owned transaction. */
|
||||
append(tx: Tx, input: AppendHierarchyEventInput): Promise<AppendHierarchyEventResult> {
|
||||
return appendHierarchyEvent(tx, input);
|
||||
}
|
||||
|
||||
snapshot(tx: Tx, kind: HierarchyNodeKind, id: string): Promise<HierarchyNodeSnapshot> {
|
||||
return buildNodeSnapshot(tx, kind, id);
|
||||
}
|
||||
|
||||
/** Per-target ordered event history (REQ-AUD-001 per-target ordering; read-only). */
|
||||
async eventsForTarget(targetId: string): Promise<HierarchyAuditEventRow[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(hierarchyAuditEvents)
|
||||
.where(eq(hierarchyAuditEvents.targetId, targetId))
|
||||
.orderBy(asc(hierarchyAuditEvents.seq));
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the oldest pending outbox record (claim-by-CAS: the UPDATE is
|
||||
* guarded on status so a lost race returns null and the caller retries).
|
||||
*/
|
||||
async claimPendingOutbox(): Promise<HierarchyOutboxRow | null> {
|
||||
const candidates = await this.db
|
||||
.select()
|
||||
.from(hierarchyOutbox)
|
||||
.where(eq(hierarchyOutbox.status, 'pending'))
|
||||
.orderBy(asc(hierarchyOutbox.createdAt))
|
||||
.limit(1);
|
||||
const candidate = candidates[0];
|
||||
if (!candidate) return null;
|
||||
const claimed = await this.db
|
||||
.update(hierarchyOutbox)
|
||||
.set({ status: 'processing', updatedAt: new Date() })
|
||||
.where(and(eq(hierarchyOutbox.id, candidate.id), eq(hierarchyOutbox.status, 'pending')))
|
||||
.returning();
|
||||
return claimed[0] ?? null;
|
||||
}
|
||||
|
||||
async completeOutbox(id: string): Promise<void> {
|
||||
const now = new Date();
|
||||
await this.db
|
||||
.update(hierarchyOutbox)
|
||||
.set({ status: 'delivered', deliveredAt: now, updatedAt: now })
|
||||
.where(and(eq(hierarchyOutbox.id, id), eq(hierarchyOutbox.status, 'processing')));
|
||||
}
|
||||
|
||||
/** Return a claimed record to pending (delivery failed; it stays replayable). */
|
||||
async releaseOutbox(id: string): Promise<void> {
|
||||
await this.db
|
||||
.update(hierarchyOutbox)
|
||||
.set({ status: 'pending', updatedAt: new Date() })
|
||||
.where(and(eq(hierarchyOutbox.id, id), eq(hierarchyOutbox.status, 'processing')));
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HierarchyAuditRepository } from './hierarchy-audit.repository.js';
|
||||
|
||||
/**
|
||||
* Hierarchy (tenancy/authorization structure) feature module.
|
||||
*
|
||||
* M4-1b-i ships the audit event + outbox machinery only (contract 1 §5.2).
|
||||
* The hierarchy command family — controllers, DTOs, and the allowlisted
|
||||
* class-table repositories — lands in M4-1b-ii once contract 2 (RBAC grant
|
||||
* model) merges; until then this module exposes no routes, which the
|
||||
* route-inventory witness asserts.
|
||||
*/
|
||||
@Module({
|
||||
providers: [HierarchyAuditRepository],
|
||||
exports: [HierarchyAuditRepository],
|
||||
})
|
||||
export class HierarchyModule {}
|
||||
@@ -882,12 +882,3 @@ Objective: for alpha 0.0.50, the release cannot publish, report, or display work
|
||||
### Out of scope
|
||||
|
||||
The canonical dispatcher/control-plane vertical slice (work graph, execution attempts, fenced leases, typed check-in, independent verifier dispatch) is decided post-alpha (SDLC-D-033, option B). Multi-pipeline verification certificates (SDLC-D-034 option B) are post-alpha. Full AF-1..AF-4 objective matrices and Mission Control portfolio surfaces are post-alpha.
|
||||
|
||||
## Official CLI Capability and Tool Migration Workstream (T78)
|
||||
|
||||
Normative contract on integration trunk `next`:
|
||||
[docs/requirements/cli-capability-migration.md](./requirements/cli-capability-migration.md):
|
||||
migrates agent-facing operations from directly invoked scripts into documented, first-class
|
||||
`mosaic` CLI command groups, together with the central-registry resolver, capability catalog,
|
||||
adapter boundary, and phased legacy-tool-tree decommission the migration requires. The contract
|
||||
carries its own implementation hold and delivery stages.
|
||||
|
||||
+1
-3
@@ -12,9 +12,7 @@ design; scoping one requires its own PRD section or requirements doc plus
|
||||
review.
|
||||
|
||||
Phases are product phases. The in-flight platform workstreams (KBN-100/101
|
||||
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, T78 CLI
|
||||
capability migration
|
||||
([requirements](./requirements/cli-capability-migration.md)), and the other
|
||||
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, and the other
|
||||
Part II contracts in the PRD) run as parallel tracks under their own issues
|
||||
and are prerequisites where noted.
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
- [Active task rollup](TASKS.md) — orchestrator-owned work state; workers do not modify it.
|
||||
- [MVP mission manifest](MISSION-MANIFEST.md) — control-plane mission rollup; activity and status remain under its authorized owner.
|
||||
- [Documentation catalog and truth audit](reports/documentation/2026-08-10-docs-catalog-audit.md) — complete baseline inventory, evidence labels, broken-link clusters, and migration recommendations.
|
||||
- [CLI capability migration requirements](requirements/cli-capability-migration.md): T78 official CLI capability and tool migration contract, normative contract with implementation hold (M0).
|
||||
|
||||
## Protected current authority and executable books
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,748 +0,0 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
source_of_truth: true
|
||||
---
|
||||
|
||||
# Official Mosaic CLI Capability and Tool Migration
|
||||
|
||||
- **Workstream:** T78
|
||||
- **Status:** active requirements contract, implementation held by the M0 gates
|
||||
- **Decision authority:** Jason Woltje
|
||||
- **Design owner:** Vision
|
||||
- **Integration trunk:** `next`
|
||||
|
||||
This contract is authoritative only on the integration trunk `next`. Branch copies are proposals.
|
||||
Publication does not authorize implementation until the M0 milestone, task-graph, interface, and
|
||||
partition gates pass.
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Migrate agent-facing operations from directly invoked scripts into documented, first-class command
|
||||
groups in the existing TypeScript and Node.js `mosaic` CLI. The CLI becomes the stable interface
|
||||
for operators, agents, the webUI, future seat containers, and future `mosaicd` execution.
|
||||
|
||||
The mission also phases out the installed `~/.config/mosaic/tools` script surface. Existing scripts
|
||||
may remain private compatibility adapters only while measured consumers still require them.
|
||||
|
||||
## 2. Product alignment
|
||||
|
||||
Items 1 through 3 implement PRD D8 and D12:
|
||||
|
||||
1. The CLI is the primary execution surface.
|
||||
2. The webUI uses Gateway APIs backed by the same official capability contracts.
|
||||
3. A missing official capability is built before a webUI bypass is accepted.
|
||||
|
||||
This contract adds one explicit extension beyond D8 and D12: no harness, skill, or agent receives a
|
||||
separate business-logic path around the CLI and Gateway capability contract.
|
||||
|
||||
This contract does not replace the fleet north star, issue `#1382`, the fleet configuration
|
||||
contract `#758`, the exact fleet communications contract `#766`, or future container and `mosaicd`
|
||||
specifications. It defines the interfaces those tracks consume.
|
||||
|
||||
## 3. Fixed decisions
|
||||
|
||||
| ID | Decision |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| T78-D1 | Extend the existing official TypeScript and Node.js `mosaic` CLI. A second Python or shell entrypoint is forbidden. |
|
||||
| T78-D2 | Expose documented groups such as `mosaic git`, `mosaic comms`, and `mosaic ci`. A generic public `mosaic tools` passthrough is forbidden. |
|
||||
| T78-D3 | Resolve homes, endpoints, sockets, tool locations, and runtime paths through the central registry and one typed resolver. Commands do not hard-code them. |
|
||||
| T78-D4 | One rootless container per seat is the target sandbox. It has a read-only root filesystem, no container-runtime socket, and lifecycle through future `mosaicd`. |
|
||||
| T78-D5 | Dispatch is per-site. Localhost `orch-01` alone dispatches USC-seat implementation. Homelab `orch-01` alone dispatches homelab-seat implementation and homelab-owned surfaces. |
|
||||
| T78-D6 | Tmux and fleet-comms remain temporary communications adapters behind a transport-neutral CLI contract. |
|
||||
| T78-D7 | Decommissioning is phased and mechanically enforced. Removal requires zero measured consumers and a discriminating planted-reference control. |
|
||||
|
||||
Derived security boundary:
|
||||
|
||||
- `~/.mosaic/tools` is canonical working source during migration. It is not automatically trusted
|
||||
runtime installation state.
|
||||
- Reviewed source is promoted into installed or packaged runtime artifacts.
|
||||
- A multi-writer brain-repository push must not silently replace credential-bearing executable code
|
||||
used by every seat.
|
||||
|
||||
## 4. Explicitly rejected alternatives
|
||||
|
||||
| Alternative | Rejection reason |
|
||||
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| Separate Python CLI | Creates a second contract, release path, and policy surface. |
|
||||
| Public `mosaic tools <script>` passthrough | Preserves script names and paths as the API instead of defining capabilities. |
|
||||
| CLI allowlists as the sandbox | Parser allowlists do not isolate files, credentials, processes, networks, or container control. |
|
||||
| Execute the synced working tree as the final runtime | A brain push would become host-wide code execution authority. |
|
||||
| Big-bang script rewrite and deletion | Mature queue, identity, credential, and uncertainty behavior would be changed without parity evidence. |
|
||||
| Tmux-shaped communications API | It would force future Matrix or native transports to preserve tmux concepts. |
|
||||
|
||||
## 5. Terminology
|
||||
|
||||
- **Central registry:** the schema-v1 `config.json` authority filed in issue `#1382`.
|
||||
- **Registry resolver:** the typed reader that validates and resolves central-registry values.
|
||||
- **Capability catalog:** the typed inventory of public capability identifiers and behavior. It is
|
||||
not the central registry.
|
||||
- **Capability policy:** data that maps verified actor and lane identity to allowed capabilities and
|
||||
scopes.
|
||||
- **Local adapter:** a temporary in-process or private-script implementation used before `mosaicd`
|
||||
is available.
|
||||
- **Broker adapter:** the future client transport to `mosaicd` outside the seat container.
|
||||
- **Installed legacy tree:** `~/.config/mosaic/tools`.
|
||||
- **Canonical working source:** `~/.mosaic/tools` during the migration period.
|
||||
- **Runtime artifact:** reviewed package or installed bytes actually executed by a seat.
|
||||
|
||||
## 6. Public CLI grammar
|
||||
|
||||
### CLI-REQ-001: First-class command groups
|
||||
|
||||
The official help surface MUST register domain groups directly:
|
||||
|
||||
```text
|
||||
mosaic git ...
|
||||
mosaic comms ...
|
||||
mosaic ci ...
|
||||
```
|
||||
|
||||
Future domains MAY include `infra`, `identity`, and other reviewed capability families. They MUST
|
||||
NOT appear through a generic script dispatcher.
|
||||
|
||||
### CLI-REQ-002: Stable command shape
|
||||
|
||||
New capability commands use this grammar:
|
||||
|
||||
```text
|
||||
mosaic <domain> <resource> <verb> [target] [options]
|
||||
```
|
||||
|
||||
The first pilot freezes these paths:
|
||||
|
||||
```text
|
||||
mosaic git issue list
|
||||
mosaic git issue view <number>
|
||||
mosaic git issue comment <number> --input <path|->
|
||||
```
|
||||
|
||||
Capability identifiers are independent from display text:
|
||||
|
||||
| Command | Capability ID | Class |
|
||||
| -------------------------- | ------------------- | ---------------- |
|
||||
| `mosaic git issue list` | `git.issue.list` | read |
|
||||
| `mosaic git issue view` | `git.issue.view` | read |
|
||||
| `mosaic git issue comment` | `git.issue.comment` | bounded mutation |
|
||||
|
||||
Renaming a command path does not silently rename its capability identifier. Either change requires a
|
||||
versioned compatibility decision.
|
||||
|
||||
### CLI-REQ-003: Common targeting options
|
||||
|
||||
The pilot supports:
|
||||
|
||||
- `--instance <name>` for the configured provider instance.
|
||||
- `--repo <owner/name>` for the provider repository.
|
||||
- `--format <table|json>` for output selection.
|
||||
- `--correlation-id <id>` for a caller-supplied valid identifier. Omission generates one.
|
||||
- `--idempotency-key <key>` for mutations. Omission generates one and returns it.
|
||||
|
||||
An instance may be inferred only when the registry has exactly one valid instance for that domain.
|
||||
A repository may be inferred only from a validated current repository declaration and an
|
||||
unambiguous canonical remote. Ambiguity fails closed and names the missing field.
|
||||
|
||||
No public option forces local compatibility mode when policy selected broker mode. A caller cannot
|
||||
downgrade the execution boundary.
|
||||
|
||||
### CLI-REQ-004: Mutation input
|
||||
|
||||
`git.issue.comment` reads its body from `--input <path>` or stdin with `--input -`. The CLI MUST:
|
||||
|
||||
1. reject a missing or empty body.
|
||||
2. apply a documented byte limit before provider access.
|
||||
3. never place the body in process arguments, diagnostics, or audit metadata.
|
||||
4. compute a body digest for read-back verification without exposing the body.
|
||||
5. avoid automatic retry after an uncertain provider mutation.
|
||||
|
||||
### CLI-REQ-005: Structured result envelope
|
||||
|
||||
JSON output uses one versioned envelope:
|
||||
|
||||
```ts
|
||||
interface CapabilityResultV1<T> {
|
||||
schemaVersion: 1;
|
||||
capabilityId: string;
|
||||
status: 'succeeded' | 'invalid' | 'denied' | 'failed' | 'uncertain' | 'unavailable';
|
||||
executionMode: 'local-adapter' | 'mosaicd';
|
||||
identityTrust: 'local-asserted' | 'runtime-verified';
|
||||
correlationId: string;
|
||||
idempotencyKey?: string;
|
||||
target: Record<string, string | number | boolean | null>;
|
||||
data?: T;
|
||||
diagnostics: Array<{
|
||||
code: string;
|
||||
message: string;
|
||||
field?: string;
|
||||
retryable: boolean;
|
||||
}>;
|
||||
audit:
|
||||
| { authority: 'mosaicd'; recorded: true; eventId: string }
|
||||
| { authority: 'none'; recorded: false; localEventId?: string };
|
||||
}
|
||||
```
|
||||
|
||||
`target` and `diagnostics` contain no credentials or unbounded provider body. Table output is a
|
||||
human view of the same result and cannot carry a different verdict.
|
||||
|
||||
### CLI-REQ-006: Exit behavior
|
||||
|
||||
| Exit | Meaning |
|
||||
| ---: | --------------------------------------------------------------------------- |
|
||||
| 0 | `succeeded` |
|
||||
| 2 | `invalid`: invalid input, invalid configuration, or unsupported schema |
|
||||
| 3 | `denied` by capability or scope policy |
|
||||
| 4 | `failed` with a confirmed non-success outcome |
|
||||
| 5 | `uncertain`, including a mutation whose provider result cannot be confirmed |
|
||||
| 6 | `unavailable`, including missing broker, credentials, or required adapter |
|
||||
|
||||
A provider HTTP success alone is insufficient. The adapter validates the expected response shape.
|
||||
A mutation that may have landed but lacks confirmation returns exit 5 and is never described as
|
||||
failed or safe to retry. For a provider-native idempotent mutation, manual reconciliation MAY retry
|
||||
the same key. For `uncertain-no-retry`, help directs the caller to a read-back check and forbids
|
||||
mutation retry.
|
||||
|
||||
### CLI-REQ-007: Help and discovery
|
||||
|
||||
The capability catalog generates or validates:
|
||||
|
||||
- `mosaic --help` command-group listing.
|
||||
- group and command help.
|
||||
- stable capability identifiers.
|
||||
- machine-readable capability discovery.
|
||||
- documentation tables.
|
||||
- policy-generation inputs.
|
||||
- tests that reject undocumented public commands and orphaned capabilities.
|
||||
|
||||
## 7. Central registry resolver
|
||||
|
||||
### CFG-REQ-001: One distinct resolver
|
||||
|
||||
Implement one exported resolver named `MosaicRegistryResolver` or another name explicitly approved
|
||||
in the contract review. It MUST NOT be named `ConfigService`. The existing
|
||||
`packages/mosaic/src/config/config-service.ts` exports `ConfigService` for SOUL, USER, and TOOLS
|
||||
content and remains a separate concern.
|
||||
|
||||
### CFG-REQ-002: Frozen schema consumption
|
||||
|
||||
The resolver consumes schema v1 from issue `#1382` without creating parallel keys. Every key is
|
||||
optional. The exact v1 surface is:
|
||||
|
||||
- `$schema`, with the known marker `mosaic-config-v1`.
|
||||
- `mosaicHome`, reserved, null, and without a v1 consumer.
|
||||
- `brainHome`, default `~/.mosaic`.
|
||||
- `instances.gitea.<name>.url`.
|
||||
- `fleet.socket`.
|
||||
- `harnessConfig.pi.agentDir`.
|
||||
- `harnessConfig.claude.configDir`.
|
||||
- `harnessConfig.claude.secureStorageDir`.
|
||||
|
||||
Credential values, model and effort defaults, and `fleet.rosterPath` are forbidden. A non-null
|
||||
`mosaicHome` value fails validation because v1 reserves the field without implementing relocation.
|
||||
An absent or null `$schema` is interpreted as v1, the exact `mosaic-config-v1` marker is accepted,
|
||||
and every other non-null marker fails before value resolution.
|
||||
|
||||
Absent or null values select the framework default. A `~` path prefix expands at read time and is
|
||||
never rewritten into the user file. Unknown top-level and nested keys warn loudly and are ignored
|
||||
for rolling-version compatibility. Every warning and machine-readable diagnostic names the full
|
||||
ignored key path, so a typo is visible at every read.
|
||||
|
||||
Fail-closed read behavior applies to invalid JSON, a failed C1 version check, a known key with an
|
||||
invalid type or value, and a present but empty or invalid override. An optional
|
||||
`mosaic registry validate` lint mode MAY reject unknown keys for operator validation, but the normal
|
||||
resolver read path does not. This top-level group is separate from the existing `mosaic config`
|
||||
commands backed by `ConfigService`.
|
||||
|
||||
### CFG-REQ-003: Resolution precedence
|
||||
|
||||
For each supported value, resolution follows exactly:
|
||||
|
||||
1. the schema-defined `MOSAIC_<KEY>_OVERRIDE` environment override.
|
||||
2. validated `config.json` value.
|
||||
3. one centralized framework default, when the key defines a default.
|
||||
|
||||
A present override always wins. An empty or invalid override fails and does not fall through to the
|
||||
file or default. `$schema` and reserved `mosaicHome` have no environment override. Consumed values
|
||||
use this collision-free mapping:
|
||||
|
||||
| Registry key | Environment override |
|
||||
| --------------------------------------- | ---------------------------------------------------------- |
|
||||
| `brainHome` | `MOSAIC_BRAIN_HOME_OVERRIDE` |
|
||||
| `fleet.socket` | `MOSAIC_FLEET_SOCKET_OVERRIDE` |
|
||||
| `harnessConfig.pi.agentDir` | `MOSAIC_HARNESS_CONFIG_PI_AGENT_DIR_OVERRIDE` |
|
||||
| `harnessConfig.claude.configDir` | `MOSAIC_HARNESS_CONFIG_CLAUDE_CONFIG_DIR_OVERRIDE` |
|
||||
| `harnessConfig.claude.secureStorageDir` | `MOSAIC_HARNESS_CONFIG_CLAUDE_SECURE_STORAGE_DIR_OVERRIDE` |
|
||||
| `instances.gitea.<name>.url` | `MOSAIC_INSTANCES_GITEA_<NAME>_URL_OVERRIDE` |
|
||||
|
||||
Gitea instance names match `[a-z][a-z0-9-]*`. The override name uppercases the instance name and
|
||||
maps hyphen to underscore. Underscores are not valid in source instance names, so two valid names
|
||||
cannot flatten to the same override.
|
||||
|
||||
A value without a valid result fails before adapter or provider access. Invalid known URLs, socket
|
||||
names, paths, and value types fail closed. Unknown keys follow CFG-REQ-002.
|
||||
|
||||
### CFG-REQ-004: Typed provenance
|
||||
|
||||
Every resolved value carries non-secret provenance:
|
||||
|
||||
```ts
|
||||
type RegistryValueSource = 'override' | 'registry' | 'framework-default';
|
||||
|
||||
interface ResolvedRegistryValue<T> {
|
||||
key: string;
|
||||
value: T;
|
||||
source: RegistryValueSource;
|
||||
schemaVersion: 1;
|
||||
}
|
||||
```
|
||||
|
||||
Machine-readable diagnostics include the full path of every ignored unknown key. Diagnostics may
|
||||
name a known key and source class. They do not emit credential values or unrelated configuration.
|
||||
|
||||
### CFG-REQ-005: Bootstrap and path safety
|
||||
|
||||
Registry discovery is the fixed path `~/.config/mosaic/config.json`. It has no v1 search path and no
|
||||
alternate location. The file is the one user-updatable path inside `~/.config/mosaic` and is
|
||||
protected by a deny-wins upgrade carve-out. Upgrades never overwrite user edits.
|
||||
|
||||
This fixed bootstrap avoids circular dependence on reserved `mosaicHome`. A seat container reads its
|
||||
own internal `~/.config/mosaic/config.json`, supplied by the container topology, rather than a host
|
||||
path or a relocation flag. Path values are expanded, normalized, validated, and tested under at
|
||||
least two distinct home roots.
|
||||
|
||||
No command embeds home directories, script locations, provider endpoints, seat paths, or tmux socket
|
||||
names outside the resolver and its reviewed defaults.
|
||||
|
||||
### CFG-REQ-006: Schema evolution
|
||||
|
||||
A new key requires:
|
||||
|
||||
1. a named consumer.
|
||||
2. a `#1382` schema amendment.
|
||||
3. joint ACK from the frozen-schema and resolver-contract custodians until handoff, recorded by
|
||||
custodian-authored commits rather than relayed tokens alone.
|
||||
4. parser, invalid-input, default, and two-root tests.
|
||||
5. documentation in the same reviewed change.
|
||||
|
||||
Speculative keys are forbidden.
|
||||
|
||||
### CFG-REQ-007: Joint freeze evidence
|
||||
|
||||
The v1 resolver contract is jointly frozen:
|
||||
|
||||
- Fred, frozen-schema custodian, accepted C1 and C3 through token
|
||||
`CLI-T78-REGISTRY-FREEZE ACCEPT`, then accepted amended C2 through token
|
||||
`CLI-T78-REGISTRY-C2 ACCEPT`.
|
||||
- Homelab `orch-01`, issue and resolver-contract custodian, accepted C1 and C3 and supplied the
|
||||
adopted C2 rolling-version amendment in the fleet-comms repository, message
|
||||
`sites/usc/20260827T004509Z__to-vision__from-homelab.orch-01__683e4c.md`, blob
|
||||
`35a7c4c1e54eb9196abeef3135d211ee1dfc46db`.
|
||||
|
||||
Durable lane provenance is recorded in the Mosaic brain repository at
|
||||
`fleet/lanes/cli-migration/registry-freeze-evidence.md` and the independent custodian-authored
|
||||
`fleet/lanes/cli-migration/registry-freeze-fred-ack.md`. Schema evolution after this freeze still
|
||||
follows CFG-REQ-006.
|
||||
|
||||
## 8. Capability catalog and policy
|
||||
|
||||
### CAP-REQ-001: One typed catalog
|
||||
|
||||
Each capability definition records:
|
||||
|
||||
```ts
|
||||
type CapabilityEffect = 'read' | 'bounded-mutation' | 'privileged-mutation';
|
||||
|
||||
interface CapabilityDefinitionV1 {
|
||||
id: string;
|
||||
commandPath: readonly string[];
|
||||
effect: CapabilityEffect;
|
||||
targetSchema: string;
|
||||
inputSchema: string;
|
||||
outputSchema: string;
|
||||
credentialClass: string | null;
|
||||
requiredScopes: readonly string[];
|
||||
auditRequired: boolean;
|
||||
timeoutMs: number;
|
||||
idempotency: 'read' | 'required-key' | 'provider-native' | 'uncertain-no-retry';
|
||||
adapterId: string;
|
||||
deprecation: 'active' | 'deprecated' | 'removed';
|
||||
}
|
||||
```
|
||||
|
||||
The catalog is data consumed by the parser, help, policy, documentation, and tests. Command handlers
|
||||
must not maintain independent copies of these facts.
|
||||
|
||||
### CAP-REQ-002: Policy is not parser logic
|
||||
|
||||
Capability grants map verified actor identity and lane to capability IDs and resource scopes. They
|
||||
are data. A named seat receives no authority from its name alone.
|
||||
|
||||
The user-editable central registry is placement and endpoint configuration, not authorization
|
||||
policy. It MUST NOT contain lane grants or let a seat self-grant capability scope. Target authority
|
||||
lives in the `mosaicd` control-plane store outside seat containers and returns a policy revision and
|
||||
digest with every decision.
|
||||
|
||||
Before `mosaicd`, local compatibility mode may evaluate a package-owned policy for behavior and test
|
||||
parity, but it reports locally asserted identity and makes no broker-grade authorization claim. Mode
|
||||
selection is declared by topology and policy, never inferred from broker availability. A missing or
|
||||
unhealthy required broker returns `unavailable`. It never falls back to local mode.
|
||||
|
||||
A capability using a shared, service, operator, or admin credential is broker-only. Local mode may
|
||||
use only the acting seat's own credential against a registry endpoint. Privileged infrastructure,
|
||||
merge, deployment, identity, authorization, and secret-management cutover requires `mosaicd`. A
|
||||
future policy-store key or broker endpoint still requires CFG-REQ-006 and the `mosaicd` topology
|
||||
contract.
|
||||
|
||||
### CAP-REQ-003: Identity trust
|
||||
|
||||
CLI arguments and ordinary environment variables are actor hints, not authorization identity. The
|
||||
local adapter reports that identity is locally asserted and MUST NOT claim broker-grade
|
||||
authorization. `mosaicd` derives or verifies actor identity from the authenticated seat runtime.
|
||||
|
||||
### CAP-REQ-004: Positive and denied controls
|
||||
|
||||
Every capability test includes:
|
||||
|
||||
1. an allowed request with expected result.
|
||||
2. a denied request differing only in the relevant lane or scope.
|
||||
3. a malformed target or configuration denial.
|
||||
4. a credential-redaction assertion.
|
||||
5. a verdict-discrimination control that proves the test can fail.
|
||||
|
||||
## 9. Adapter and broker contract
|
||||
|
||||
### EXE-REQ-001: One capability request
|
||||
|
||||
```ts
|
||||
interface CapabilityRequestV1 {
|
||||
schemaVersion: 1;
|
||||
capabilityId: string;
|
||||
actorHint?: { seat?: string; lane?: string };
|
||||
target: Record<string, string | number | boolean | null>;
|
||||
arguments: Record<string, string | number | boolean | null>;
|
||||
correlationId: string;
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Credential values and unbounded comment bodies are not serialized into audit-safe request metadata.
|
||||
Body content travels through a bounded private input channel appropriate to the adapter.
|
||||
|
||||
### EXE-REQ-002: Local compatibility adapter
|
||||
|
||||
The local adapter MAY call a reviewed in-process implementation or a private script adapter. It
|
||||
MUST preserve existing queue guards, wrapper-first behavior, credential resolution, response
|
||||
validation, and mutation uncertainty. It reports `executionMode: local-adapter` and
|
||||
`identityTrust: local-asserted`.
|
||||
|
||||
Private child adapters receive bodies and credentials only through stdin, owner-only temporary
|
||||
files, or inherited file descriptors, never child-process arguments. Captured child stderr, shell
|
||||
trace, and diagnostics are inside the redaction boundary. Local results always use
|
||||
`audit: { authority: 'none', recorded: false }`. A local event identifier is not authoritative
|
||||
audit evidence.
|
||||
|
||||
The local adapter is compatibility, not a sandbox or authorization claim.
|
||||
|
||||
### EXE-REQ-003: `mosaicd` broker adapter
|
||||
|
||||
The broker adapter sends the same logical request to `mosaicd` outside the seat container.
|
||||
`mosaicd` owns:
|
||||
|
||||
- authoritative seat identity, recorded in audit from the derived runtime identity rather than
|
||||
`actorHint`.
|
||||
- capability and scope authorization.
|
||||
- credential resolution.
|
||||
- operation execution.
|
||||
- output sanitization.
|
||||
- audit persistence.
|
||||
- bounded timeout and cancellation behavior.
|
||||
|
||||
A contradictory `actorHint` produces a diagnostic and never replaces the derived actor. Broker
|
||||
results report `identityTrust: runtime-verified`. `audit.recorded: true` is valid only after
|
||||
`mosaicd` confirms persistence and returns its event ID. Consumers verify authoritative evidence
|
||||
against the broker trail, not the seat-produced envelope alone.
|
||||
|
||||
The transport and endpoint are supplied by immutable container topology and the reviewed central
|
||||
registry contract. No command hard-codes a daemon socket.
|
||||
|
||||
### EXE-REQ-004: Packaged implementation boundary
|
||||
|
||||
The initial TypeScript layout is:
|
||||
|
||||
- `packages/mosaic/src/central-registry/` for `MosaicRegistryResolver`, schema, and provenance.
|
||||
- `packages/mosaic/src/capabilities/` for catalog, request, result, policy interfaces, and tests.
|
||||
- `packages/mosaic/src/capabilities/adapters/local/` for temporary local adapter modules.
|
||||
- `packages/mosaic/src/capabilities/adapters/mosaicd/` for the broker client seam.
|
||||
- `packages/mosaic/src/commands/git.ts`, with later first-class domain files following the same
|
||||
command pattern.
|
||||
|
||||
Remaining script implementations may be promoted under `packages/mosaic/framework/tools/` as
|
||||
private packaged adapters during transition. Their installed paths are resolver-owned and are not
|
||||
public command contracts. No production adapter imports or executes source from the brain working
|
||||
tree as the final path.
|
||||
|
||||
### EXE-REQ-005: Container boundary
|
||||
|
||||
The representative seat container has:
|
||||
|
||||
- one seat identity.
|
||||
- rootless execution.
|
||||
- read-only root filesystem, with explicit bounded writable mounts.
|
||||
- a read-only internal `~/.config/mosaic/config.json` supplied by topology.
|
||||
- no host credential tree.
|
||||
- no shared host or fleet tmux socket.
|
||||
- a dedicated per-seat tmux socket only for one named, reviewed temporary adapter with a stated
|
||||
removal stage.
|
||||
- no Docker, Podman, or other container-runtime socket.
|
||||
- no installed legacy tool tree mount.
|
||||
- network access limited to declared capability paths.
|
||||
|
||||
Container implementation is outside this mission. Contract and compatibility tests are inside it.
|
||||
|
||||
## 10. Communications portability
|
||||
|
||||
### COM-REQ-001: Transport-neutral public contract
|
||||
|
||||
Public communications capabilities use logical addresses, messages, correlation IDs, delivery
|
||||
status, and adapter diagnostics. Tmux pane, socket, retry, and draft details stay below the public
|
||||
contract.
|
||||
|
||||
### COM-REQ-002: Transitional semantics
|
||||
|
||||
The tmux adapter preserves the measured `rc=2` behavior: content reached a pane as a draft, so the
|
||||
operation is not retried automatically. Fleet-comms preserves durable cross-site message identity
|
||||
and acknowledgment behavior.
|
||||
|
||||
### COM-REQ-003: Future transport replacement
|
||||
|
||||
A Matrix or native transport implementation passes the same contract tests. Callers do not change
|
||||
command paths, capability IDs, or result interpretation when the adapter changes.
|
||||
|
||||
## 11. Canonical source and runtime integrity
|
||||
|
||||
### SRC-REQ-001: Reviewed baseline
|
||||
|
||||
The F11 baseline is commit `5be5825`. Inventory report `585f214`, code review `3fe8de7`, and
|
||||
security review `e270098` are the M0 evidence. Both reviews found no blocker.
|
||||
|
||||
### SRC-REQ-002: Required M1 corrections
|
||||
|
||||
Before expanding direct execution from the working tree:
|
||||
|
||||
1. fix the `check-helper-drift.sh` environment assignment that suppresses version diagnostics.
|
||||
2. strip 20 dangling Excalidraw `node_modules` symlinks.
|
||||
3. add `tools/**/node_modules/` to the brain `.gitignore`.
|
||||
4. keep the reviewed `package-lock.json` as the reproducible dependency contract.
|
||||
5. correct the baseline report's misleading path-count headline.
|
||||
6. move `ci-publish-watch.sh` credential headers from process arguments to curl stdin
|
||||
configuration when that suite is changed.
|
||||
|
||||
### SRC-REQ-003: Source is not installation
|
||||
|
||||
Runtime code is loaded from reviewed package or installed artifacts, not directly from a mutable
|
||||
multi-writer checkout as the final design. Any transitional direct execution requires:
|
||||
|
||||
- a protected-path review rule.
|
||||
- an accepted digest anchored outside the synced tree in reviewed package metadata or Stack source.
|
||||
- verification before execution, including every credential-helper invocation.
|
||||
- a periodic verifier whose mismatch alert reaches a human.
|
||||
- a stated removal point.
|
||||
|
||||
### SRC-REQ-004: Credential helper integrity
|
||||
|
||||
The host-wide git credential helper and its accepted pin cannot be replaceable by the same synced
|
||||
commit. Transition requires an independently anchored verifier and alert. Final state moves the
|
||||
helper into the reviewed runtime installation or another explicitly protected location.
|
||||
|
||||
## 12. Migration and decommission
|
||||
|
||||
### MIG-REQ-001: Consumer census
|
||||
|
||||
Inventory every direct caller of `~/.config/mosaic/tools`, grouped as:
|
||||
|
||||
- skills and guides.
|
||||
- hooks and generated harness configuration.
|
||||
- systemd units and timers.
|
||||
- launchers and provisioning.
|
||||
- tests and CI.
|
||||
- direct agent commands.
|
||||
- private tool-to-tool calls.
|
||||
- production consumers.
|
||||
|
||||
Each census run creates a fresh randomized planted legacy reference at a unique path and is valid
|
||||
only when the detector reports that run's exact plant. Every host at every site still running the
|
||||
installed legacy tree is censused independently. An empty result without the fresh control, or a
|
||||
zero from only one host, is not evidence.
|
||||
|
||||
### MIG-REQ-002: Risk-ordered waves
|
||||
|
||||
Migrate in this order:
|
||||
|
||||
1. read-only status, health, list, and view.
|
||||
2. bounded CI and communications.
|
||||
3. issue, pull-request, and milestone mutation.
|
||||
4. credentialed infrastructure.
|
||||
5. merge, deployment, identity, authorization, and secret management.
|
||||
|
||||
Each wave proves contract parity before consumer cutover. Waves 1 through 3 may use local mode with
|
||||
acting-seat credentials. Wave 4 cutover is broker-only when it uses a shared or service credential.
|
||||
Wave 5 cutover is always broker-only and begins only after the M6 `mosaicd` boundary gate passes.
|
||||
|
||||
### MIG-REQ-003: Protected consumers
|
||||
|
||||
- M365 credentials, AD status, and six production consumers remain Peggy-owned until exact signoff
|
||||
and timer-aware tests.
|
||||
- Fleet-doctor, seat-service, Woodpecker extras, and their units remain Veronica-owned until exact
|
||||
replacement proof and named handoff.
|
||||
- Brain guards are excluded from wholesale removal.
|
||||
- The active A2 hold applies to `tools/seat-service/` and
|
||||
`fleet/bin/launch-seat-claude.sh` only.
|
||||
- Fleet configuration issue `#758` retains its own normative contract and delivery DAG. T78 does
|
||||
not re-scope or absorb its missing `inspect` and `validate` verbs. T78 measures and consumes the
|
||||
stable fleet surface only after `#758` completion or an explicit owner handoff.
|
||||
|
||||
### MIG-REQ-004: Compatibility and deprecation
|
||||
|
||||
Compatibility shims are private and time-bounded. Each shim:
|
||||
|
||||
- names its public replacement.
|
||||
- preserves existing safety behavior.
|
||||
- emits a machine-detectable deprecation diagnostic without corrupting JSON output.
|
||||
- has a measured consumer and removal issue.
|
||||
- cannot be used to add new direct callers.
|
||||
|
||||
### MIG-REQ-005: Final removal
|
||||
|
||||
The installed `~/.config/mosaic/tools` script surface is removed only after:
|
||||
|
||||
1. all active consumers use official capabilities.
|
||||
2. the census reports zero with a firing planted control.
|
||||
3. Constitution and wrapper-first gates are mechanically enforced by the CLI path.
|
||||
4. systemd units are regenerated, daemon-reloaded, re-enabled, and behavior-tested.
|
||||
5. fleet-doctor state is preserved.
|
||||
6. clean install, upgrade, rollback, and stale-install tests pass.
|
||||
7. user, admin, developer, API, and migration documentation is current.
|
||||
|
||||
## 13. Testing requirements
|
||||
|
||||
### TST-REQ-001: Resolver
|
||||
|
||||
- exact schema-v1 valid fixture.
|
||||
- absent, null, exact-v1, and unknown-non-null `$schema` cases.
|
||||
- unknown top-level and nested key warnings with full-path diagnostics.
|
||||
- `mosaic registry validate` lint rejection of the same unknown-key fixture.
|
||||
- invalid URL, path, socket, and type failures.
|
||||
- every precedence branch, including present-empty and present-invalid override denial without
|
||||
fallback.
|
||||
- two valid roots.
|
||||
- container topology with a read-only internal registry and no host registry path.
|
||||
- no credential value accepted or emitted.
|
||||
- control proving the invalid fixture fails.
|
||||
|
||||
### TST-REQ-002: Capability catalog
|
||||
|
||||
- command and capability ID uniqueness.
|
||||
- every public command documented.
|
||||
- no orphan catalog record.
|
||||
- parser, policy, help, and docs consume the same definition.
|
||||
- unauthorized lane and scope denial.
|
||||
- unknown capability denial.
|
||||
- topology-selected mode never falls back when the required broker is unavailable.
|
||||
- shared, service, operator, and admin credential classes reject local mode.
|
||||
|
||||
### TST-REQ-003: Pilot
|
||||
|
||||
- issue list and view against a valid configured instance.
|
||||
- invalid instance and repository denial.
|
||||
- comment success with provider response-shape and body-digest confirmation.
|
||||
- comment denial before provider access.
|
||||
- post-request uncertainty without retry, plus provider-native same-key and
|
||||
`uncertain-no-retry` read-back reconciliation cases.
|
||||
- credential, cookie, token, comment-body, child-argv, captured-stderr, and shell-trace redaction.
|
||||
- local results prove `identityTrust: local-asserted` and `audit.recorded: false`.
|
||||
- broker-stub results prove derived-identity precedence and reject unconfirmed
|
||||
`audit.recorded: true`.
|
||||
- user-editable endpoint changes cannot redirect a shared or service credential.
|
||||
- local-adapter and broker-stub request/result seam parity at M3.
|
||||
- live local-adapter and `mosaicd` contract parity at M6.
|
||||
|
||||
### TST-REQ-004: Migration
|
||||
|
||||
- fresh randomized consumer-census plant detected independently on every affected host and site.
|
||||
- compatibility diagnostics in table and JSON modes.
|
||||
- systemd timer and restart behavior.
|
||||
- production M365/AD consumer probes.
|
||||
- fleet-doctor digest-state preservation.
|
||||
- clean install, upgrade, rollback, stale install, and greenfield operation.
|
||||
- representative container without legacy tools mounted.
|
||||
- representative container mounts no shared or fleet tmux socket, and any temporary tmux exception
|
||||
uses only the named adapter's dedicated per-seat socket.
|
||||
|
||||
### TST-REQ-005: Delivery gates
|
||||
|
||||
Every source card requires focused tests, repository quality gates, independent code review,
|
||||
security review for authorization, credentials, transport, or integrity surfaces, reviewed squash
|
||||
PR to `next`, terminal-green CI, and linked-issue closure.
|
||||
|
||||
## 14. Documentation requirements
|
||||
|
||||
The workstream updates in the same delivery sequence:
|
||||
|
||||
- official CLI help.
|
||||
- `docs/PRD.md` workstream pointer.
|
||||
- `docs/ROADMAP.md` parallel-track entry.
|
||||
- `docs/SITEMAP.md` requirements link.
|
||||
- user guide commands and deprecation behavior.
|
||||
- administrator configuration, migration, and recovery.
|
||||
- developer architecture, capability authoring, schemas, and adapter contracts.
|
||||
- API and machine-readable result schemas.
|
||||
- release notes.
|
||||
- T78 program-map and unified-roadmap records.
|
||||
|
||||
No command is public until its help, structured output, authorization behavior, and documentation
|
||||
are present.
|
||||
|
||||
## 15. Delivery stages
|
||||
|
||||
| Stage | Scope | Exit gate |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
|
||||
| M0 | Register mission, reconcile ownership, establish and review source baseline, publish requirements and tracking | Reviewed contract merged, dedicated milestone and task graph present |
|
||||
| M1 | Inventory consumers, normalize baseline, freeze registry resolver, capability catalog, policy, and runtime-integrity contracts | Typed interfaces and migration census reviewed |
|
||||
| M2 | Implement resolver, catalog, common result envelope, and adapter interface | Contract and two-root tests green |
|
||||
| M3 | Deliver pilot issue list, view, and comment | Allowed and denied controls, uncertainty behavior, docs, review, CI |
|
||||
| M4 | Migrate waves 1 through 3, prepare wave 4 private adapters without shared-credential cutover | Per-suite owner handoff and parity evidence |
|
||||
| M5 | Cut eligible consumers and generate harness policy | No new direct references, compatibility callers measured |
|
||||
| M6 | Prove representative container and `mosaicd` seam, then cut over shared-credential wave 4 and all wave 5 capabilities | Boundary, authorization, audit, and parity tests green |
|
||||
| M7 | Remove installed legacy script tree | Zero callers, migration and rollback evidence, docs and release gates complete |
|
||||
|
||||
## 16. Workstream acceptance
|
||||
|
||||
T78 completes only when:
|
||||
|
||||
1. the official TypeScript CLI exposes documented first-class capability groups.
|
||||
2. the central registry resolver and capability catalog are single typed authorities.
|
||||
3. two-root and container-topology tests prove no command-path hard-coding.
|
||||
4. authorization has allowed and denied situational evidence.
|
||||
5. agent-visible output, logs, and process arguments contain no credential values.
|
||||
6. local and `mosaicd` modes share one request and result contract and report their mode honestly.
|
||||
7. a representative rootless seat container performs granted operations without legacy tools,
|
||||
host credentials, or a container-runtime socket.
|
||||
8. tmux and fleet-comms can be replaced without changing public communications callers.
|
||||
9. the legacy consumer census reaches zero with a discriminating control.
|
||||
10. the installed `~/.config/mosaic/tools` script surface is removed.
|
||||
11. independent review passes for every source partition.
|
||||
12. all PRs are squash-merged to `next`, terminal CI is green, and linked issues are closed.
|
||||
|
||||
## 17. Contract-freeze status
|
||||
|
||||
The architecture inputs are frozen for independent review:
|
||||
|
||||
1. The central-registry resolver has joint C1, amended C2, and C3 approval.
|
||||
2. User-editable `config.json` is not authorization policy. Target grant authority belongs to
|
||||
`mosaicd`. Local mode is explicitly non-authoritative.
|
||||
3. Registry, capability, and adapter source boundaries are packaged TypeScript modules. Brain tools
|
||||
remain working source and temporary private adapters, not the final runtime contract.
|
||||
4. Issue `#758` remains an independent dependency and is not re-scoped into T78.
|
||||
|
||||
Provider tracking remains operationally blocked until the `orch-01` Mosaic Stack credential slot is
|
||||
minted. This does not weaken the contract or authorize implementation before reviewed publication.
|
||||
@@ -1,484 +0,0 @@
|
||||
# Hierarchy Schema Contract (D2)
|
||||
|
||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 1 of 9).
|
||||
Authority: PRD D2/D9/D13 (Part I §4) and the native-kanban SOT Amendment A1
|
||||
(`docs/requirements/native-kanban-sot.md` §8, ratified 2026-08-25). This
|
||||
document turns the ratified hierarchy into a concrete schema contract:
|
||||
tables, cardinalities, constraints, and ownership/transfer semantics. It is
|
||||
the prerequisite for the hierarchy command family and for the RBAC grant
|
||||
model (contract 2, `docs/requirements/rbac-grant-model.md`).
|
||||
|
||||
Revision 2 (independent review, GPT-5.6 terra): tenancy-FK exemption made
|
||||
explicit (§1.1); record class extended to include `hierarchy_grants`
|
||||
(§1.1); provenance corrections on legacy tables and the planning `projects`
|
||||
table (§1.3, §2 naming note); NOT NULL and `NULLS NOT DISTINCT` grant
|
||||
uniqueness (§2.6, §3.2); grant FK delete actions split cascade/restrict
|
||||
(§3.3); transfer transaction includes its audit write (§4.3); ownership
|
||||
invariant completed via contract 2 with the both-sides rule marked as new
|
||||
policy (§4.2, §4.4); hierarchy audit brought under REQ-AUD-001-equivalent
|
||||
guarantees with deletion-safe linkage (§5.2); roll-up never-a-write restored
|
||||
to full A1 strength (§5.4); §6 rebuilt with bounded observables for every
|
||||
MUST (allowlist, command surface, audit, corrected cardinality witness).
|
||||
|
||||
Revision 3 (terra re-review residuals): §4.3 transfer write inventory
|
||||
reconciled with §5.2 — the transaction's writes are the single class-row
|
||||
mutation plus that mutation's §5.2 audit writes (event + outbox record),
|
||||
not "exactly two writes"; §6.3 extended with a closed writer-coverage
|
||||
witness so an unregistered internal writer cannot pass a registered-route
|
||||
inventory. (Terra's finding-8 residual — a stale contract 2 §7.8 backlink
|
||||
to contract 1 §6.2 — was already fixed in contract 2 revision 2, which
|
||||
cites §6.5; measured against `origin/contract/rbac-grants` head
|
||||
`501112d2`.)
|
||||
|
||||
Revision 4 (terra r3 residual F7): the §6.3(b) writer-coverage assertion
|
||||
extended to raw SQL — it now also fails on class-table name literals
|
||||
inside SQL strings or tagged SQL templates outside the allowlist, so a
|
||||
raw-SQL writer that touches no schema symbol is still caught.
|
||||
|
||||
Revision 5 (terra r4 residual F7): §6.3(b) gains a third prong — any
|
||||
raw-SQL execution primitive outside the allowlist fails the assertion
|
||||
regardless of its SQL content, closing the evasion where a
|
||||
dynamically constructed table name carries neither a schema symbol nor
|
||||
a class-table literal. The detection claim is now coextensive with
|
||||
what the three prongs statically see.
|
||||
|
||||
Revision 6 (terra r5 residual F7 + new F8): the "two prongs" wording
|
||||
corrected to three (F8); §6.3(b) gains the allowlist composition rules
|
||||
(no generic raw-SQL helper is allowlisted; an allowlisted module may
|
||||
not export caller-supplied-SQL execution) and fails outright on
|
||||
runtime code-construction primitives; the detection claim is scoped
|
||||
honestly to the stated syntactic forms, with evasions beyond static
|
||||
reach assigned to §5.1 review/audit rather than claimed for CI.
|
||||
|
||||
Revision 7 (terra r6 new F9): the false-positive remedy no longer
|
||||
contradicts the composition rules — legitimate non-hierarchy raw
|
||||
execution (e.g. the db package's migration runner) is dispositioned
|
||||
onto a second closed enumerated list, the infrastructure register,
|
||||
exempt from prong (iii) only, still bound by prongs (i)/(ii), barred
|
||||
from the writer allowlist, and importable only by registered modules
|
||||
or the operational entry points.
|
||||
|
||||
Revision 8 (terra r7 residual F9): the register's import rule made
|
||||
satisfiable by the live tree — imports are checked re-export-aware
|
||||
(package barrels followed), and each registered module carries its own
|
||||
closed importer enumeration, which may name operational entry points
|
||||
such as the Gateway's startup migration hook; named importers stay
|
||||
subject to prongs (i)/(ii) and gain no writer standing.
|
||||
|
||||
Revision 9 (terra r8 F10): revision 8 called the Gateway database
|
||||
module the runner's "one live importer today". That was false — the
|
||||
measured production importer set has four members. The enumeration
|
||||
example now lists the complete measured set, and the import analysis
|
||||
is extended to resolve literal dynamic `import()` routes, which two of
|
||||
the four members use.
|
||||
|
||||
Amendment 1 (Ruling 4b, 2026-08-28): company visibility classes. The
|
||||
directory exists so one shared company can serve many users instead of
|
||||
each user creating a duplicate private company (Ruling 4b, webui-audit
|
||||
lane, ruled 2026-08-27). §2.1 gains a `visibility` column; §2.8 defines
|
||||
the two classes (`private`/`directory`), the directory's existence-only
|
||||
disclosure, and the pre-binding invariants for the deferred
|
||||
see-and-ask-to-join flow (no join-request surface is authorized here —
|
||||
its flow is a follow-up contract); §5.2's mutation
|
||||
enumeration gains the visibility change; §5.5 defines who may change
|
||||
visibility (platform admins, plus a company-CRUD capability whose
|
||||
definition is a follow-up amendment to contract 2 — until it ratifies,
|
||||
admin-only); §6.1 and §6.9 add the witnesses; §6.7's existence-oracle
|
||||
rule is scoped around the ratified directory carve-out. Top-level
|
||||
creation (contract 3 §5.2) is unchanged and always yields a private
|
||||
company. Upstream, SOT Amendment A2 (native-kanban-sot.md §9, this PR)
|
||||
expressly extends A1 §8.1.2 to admit the visibility column and A1
|
||||
§8.1.3 to admit the directory function — this contract relies on that
|
||||
amendment, not on a reinterpretation of A1.
|
||||
|
||||
Scope: the tenancy/authorization structure record class — companies,
|
||||
estates, platform-projects, workspaces, hierarchy grants, their parentage,
|
||||
and constraints. Out of scope: the RBAC grant vocabulary and evaluation
|
||||
semantics (contract 2), roll-up projection semantics (contract 8), kanban
|
||||
planning entities inside workspaces (SOT §5), migration or retirement of
|
||||
legacy flat data (future work; see §1.3).
|
||||
|
||||
## 1. Record class and placement
|
||||
|
||||
1. The **tenancy/authorization structure record class** defined by
|
||||
Amendment A1 §8.1.2 comprises five tables: the four node tables of §2
|
||||
AND `hierarchy_grants` (§3) — A1 includes hierarchy-level access grants
|
||||
in the class. Every rule addressed to "the class" in this contract
|
||||
(payload prohibition, mutation path, audit) binds all five tables. Class
|
||||
rows carry parentage, naming, grant, audit-linkage, and visibility-class
|
||||
data only — never task, plan, or any business/orchestration payload.
|
||||
Visibility (`companies.visibility`, §2.8) is admitted into that
|
||||
enumeration by SOT Amendment A2 §9.1.1, which expressly extends A1
|
||||
§8.1.2 for exactly this one column: it is disclosure data about the
|
||||
class's own nodes — not a payload field, carries no business content,
|
||||
and widens the payload prohibition for nothing else.
|
||||
References from business/orchestration rows into the class are limited
|
||||
to exactly one form: the canonical `workspace_id` tenancy column that
|
||||
REQ-TEN-001 requires on every canonical row, referencing
|
||||
`workspaces.id`. No business/orchestration row may reference a company,
|
||||
estate, platform-project, or grant id in any position, and no
|
||||
business/orchestration row may reference a workspace id in any
|
||||
non-tenancy position (dependency, claim target, work subject).
|
||||
2. Hierarchy records are NOT workspace-scoped rows: REQ-TEN-001's
|
||||
`workspace_id` obligation binds business/orchestration rows and does not
|
||||
apply to this class (A1 §8.1.2). The `workspaces` table itself is the
|
||||
anchor the obligation points at.
|
||||
3. The legacy flat tables (`teams`, and the Brain planning `projects` table
|
||||
in `packages/db/src/schema.ts`) are not part of this class. What A1
|
||||
§8.1.4 pins is narrower: the planning `projects` table and
|
||||
`platform_projects` stay distinct tables. This contract adds, as new
|
||||
policy ratified here: neither `teams` nor `projects` is repurposed as a
|
||||
hierarchy table. Their eventual migration or retirement is future work
|
||||
that no existing REQ assigns; it is out of scope here.
|
||||
|
||||
## 2. Tables and cardinalities
|
||||
|
||||
Naming: the level above workspaces is `platform_projects`, per A1 §8.1.4.
|
||||
The existing `projects` table is Brain planning data (so labeled in
|
||||
`packages/db/src/schema.ts`; it carries no `workspace_id`), and the schema
|
||||
MUST NOT merge the two. (A rename of either remains an implementation-PR
|
||||
decision under A1; this contract pins only that they stay distinct tables.)
|
||||
|
||||
1. `companies` — id (uuid pk), name, slug (unique per deployment),
|
||||
`visibility` (text NOT NULL, DEFAULT `private`, CHECK constrained to
|
||||
exactly `private` | `directory`; semantics §2.8), created_at,
|
||||
updated_at. N per deployment (D2).
|
||||
2. `estates` — id, name, slug, `company_id` NOT NULL →
|
||||
`companies.id` ON DELETE RESTRICT. Exactly one company per estate; a
|
||||
company holds any number of estates.
|
||||
3. `platform_projects` — id, name, slug, `estate_id` NOT NULL →
|
||||
`estates.id` ON DELETE RESTRICT. Exactly one estate per
|
||||
platform-project; an estate holds any number of platform-projects.
|
||||
4. `workspaces` — id, name, slug, `platform_project_id` NOT NULL →
|
||||
`platform_projects.id` ON DELETE RESTRICT. Exactly one platform-project
|
||||
per workspace. This table is the referent of every `workspace_id` column
|
||||
the SOT requires on canonical rows.
|
||||
5. **Chain resolution is by construction.** Because every parent FK is NOT
|
||||
NULL and single-valued (one FK column, no parentage edge tables, no
|
||||
multi-parent forms, no nullable "detached" states), each workspace
|
||||
resolves to exactly one platform-project → estate → company chain (A1
|
||||
§8.3 acceptance 1). One-parent-per-child is the constrained direction;
|
||||
many children per parent is valid data.
|
||||
6. **Slug scoping.** All `name` and `slug` columns are NOT NULL.
|
||||
`estates.slug` is unique within its company, `platform_projects.slug`
|
||||
within its estate, `workspaces.slug` within its platform-project
|
||||
(composite unique constraints). Display names are unconstrained beyond
|
||||
NOT NULL.
|
||||
7. No hierarchy table carries a `metadata` jsonb column or any
|
||||
free-form payload field. The columns declared in this section and §3
|
||||
are exhaustive: a class table's column set is exactly its declared set
|
||||
(verified per §6.2) — nothing else (A1 §8.1.2).
|
||||
8. **Company visibility classes (Ruling 4b).** Every company is exactly
|
||||
one of two classes, carried by `visibility`:
|
||||
- `private` (the default): the company is disclosed only to subjects
|
||||
holding a grant on it or on a descendant — the resting state every
|
||||
company is created in. Open creation under contract 3 §5.2
|
||||
(Ruling 4) survives unchanged: it creates private companies.
|
||||
- `directory`: the company is listed in the deployment-wide company
|
||||
directory. Directory listing discloses **existence, name, and slug
|
||||
to every authenticated user — nothing else**: no subtree structure,
|
||||
no roll-up aggregates, no workspace content, no grant or membership
|
||||
information.
|
||||
Visibility is disclosure, not authority. Content and structure access
|
||||
to a directory-listed company still require explicit grants —
|
||||
contract 2 §3.1 deny-by-default is unchanged, and the ownership model
|
||||
(§4.4, contract 2 §4.3) is unchanged. Ruling 4b decision 5 wants a
|
||||
see-and-ask-to-join flow for directory-listed companies. **This
|
||||
contract authorizes no join-request runtime surface**: the flow in
|
||||
its entirety — the ability to submit a request, its transport,
|
||||
storage, and request lifecycle — is a follow-up contract, and until
|
||||
that contract ratifies, the directory's only function is the
|
||||
read-only listing above (A2 §9.1.2 admits nothing more). Two
|
||||
invariants pre-bind that future contract now:
|
||||
a join request confers no authority of any kind, and approval is
|
||||
ordinary grant creation by an effective `owner` under contract 2 §4.1
|
||||
— there is no other acceptance path.
|
||||
|
||||
## 3. Grant attachment points
|
||||
|
||||
The grant vocabulary (which roles exist, what each permits, how evaluation
|
||||
and revocation work) is contract 2. This contract pins only the schema
|
||||
shape contract 2 attaches to:
|
||||
|
||||
1. `hierarchy_grants` — id, subject (exactly one of `user_id` → `users.id`,
|
||||
`team_id` → `teams.id`; CHECK-enforced exactly-one-of), target (exactly
|
||||
one of `company_id`, `estate_id`, `platform_project_id`;
|
||||
CHECK-enforced exactly-one-of), `role` (text NOT NULL; vocabulary and
|
||||
its CHECK constraint owned by contract 2 §2), `granted_by` NOT NULL →
|
||||
`users.id`, created_at.
|
||||
2. Uniqueness: at most one grant row per (subject, target, role). Because
|
||||
the subject and target columns are nullable by design, ordinary
|
||||
PostgreSQL composite uniqueness treats NULLs as distinct and would not
|
||||
enforce this. The implementation MUST use a single
|
||||
`UNIQUE NULLS NOT DISTINCT` constraint across (`user_id`, `team_id`,
|
||||
`company_id`, `estate_id`, `platform_project_id`, `role`) or six
|
||||
equivalent partial unique indexes (one per subject×target form). The
|
||||
pinned Drizzle ORM supports `nullsNotDistinct()`.
|
||||
3. Delete actions are split by column class:
|
||||
- Target FKs (`company_id`, `estate_id`, `platform_project_id`):
|
||||
ON DELETE CASCADE — the one permitted cascade in this class. A grant
|
||||
on a deleted node is meaningless and fail-open if retained. Cascaded
|
||||
grant deletions are audited per §5.2.
|
||||
- Principal FKs (`user_id`, `team_id`, `granted_by`): ON DELETE
|
||||
RESTRICT. The identity contract (§7.3) gates user deletion today and
|
||||
defines no team-deletion rule; this contract does not invent one.
|
||||
These FKs stay RESTRICT until an explicit deletion-and-retention
|
||||
contract ratifies otherwise.
|
||||
4. Workspace-level access is evaluated, not stored here: a grant at any of
|
||||
the three levels evaluates down the chain to workspace-scoped
|
||||
authorization (A1 §8.1.3). No `workspace_id` column exists on
|
||||
`hierarchy_grants` — workspace membership (REQ-ID-001) remains its own
|
||||
mechanism inside the SOT schema, and the chain adds where grants can be
|
||||
declared, never a bypass.
|
||||
|
||||
## 4. Ownership and transfer
|
||||
|
||||
"Assets are transferable subject to the structure" (PRD Part I §4):
|
||||
|
||||
1. A transfer changes exactly one parent FK on exactly one hierarchy row:
|
||||
workspace → new platform-project, platform-project → new estate, estate
|
||||
→ new company. Nothing else in the class or the SOT changes: business
|
||||
and orchestration rows inside affected workspaces are untouched, keep
|
||||
their `workspace_id`, and never cross a workspace boundary (A1 §8.1.3
|
||||
"chain maintenance").
|
||||
2. Transfer authorization requires authority over BOTH the source and the
|
||||
destination parent. This both-sides predicate is **new policy
|
||||
introduced by this contract pair** (D2/A1 do not state it); its
|
||||
evaluation semantics are contract 2 §5. The structural half — that the
|
||||
transfer command evaluates it before mutating — binds here.
|
||||
3. A transfer transaction mutates exactly one class-table row — the
|
||||
single-row parent-FK update — and contains, beyond that, only the
|
||||
§5.2 audit writes for that mutation (the audit event and its
|
||||
hierarchy-outbox record, committing in the same transaction). No other
|
||||
class, business, or orchestration row changes. There are no multi-row
|
||||
transfer batches at the schema level; bulk moves are N audited
|
||||
transfers.
|
||||
4. Hierarchy records have no `owner_id`. Ownership in the hierarchy IS the
|
||||
grant structure: a "company owner" is a subject with an `owner` grant
|
||||
on that company or an ancestor (contract 2 §2), not a column. The
|
||||
ownership invariant across the contract pair: a node may hold zero
|
||||
direct owner grants (authority can derive from an ancestor grant); node
|
||||
creation names the initial `owner` grant in the same audited operation
|
||||
and the wizard seeds the first company's owner the same way (contract 2
|
||||
§4.3); transfer and revocation semantics are contract 2 §§5–6. This
|
||||
avoids column-encoded authority of the kind the legacy schema carries
|
||||
(`teams.owner_id` and `teams.manager_id` are required user FKs, and
|
||||
`team_members.role` is a further authority field — none of them
|
||||
evaluable under a grant model).
|
||||
|
||||
## 5. Mutation path, audit, and deletion
|
||||
|
||||
1. All hierarchy mutations flow through the same sole-writable-SOT,
|
||||
fail-closed, audited Gateway command path as everything else (A1 §8.2.3,
|
||||
REQ-API-001). No direct-DB writers, no raw CRUD endpoints.
|
||||
2. **Audit parity.** A1 §8.2 leaves every pre-existing REQ binding, so
|
||||
hierarchy mutations get REQ-AUD-001's guarantees, not a weakened
|
||||
substitute. Concretely:
|
||||
- Every create, rename, transfer, visibility change (§5.5), grant
|
||||
create/change/revoke, and
|
||||
delete — including every grant deletion cascaded by a node delete —
|
||||
emits a semantic audit event carrying actor, verb, target, and (for
|
||||
transfers) source and destination parents, with the correlation,
|
||||
causation, idempotency, and per-target ordering guarantees REQ-AUD-001
|
||||
defines.
|
||||
- The state change and its audit event(s) commit in the same
|
||||
transaction, delivered through a transactional outbox. Hierarchy
|
||||
events are not workspace-scoped rows and do not ride the workspace
|
||||
outbox; they get an equivalent hierarchy outbox under the same
|
||||
append-only, same-transaction rules.
|
||||
- **Deletion-safe linkage:** audit events reference their target by an
|
||||
immutable snapshot (id, slug, and parent chain at event time), never
|
||||
by a foreign key into the class tables, so append-only events survive
|
||||
the deletion of their target.
|
||||
3. Deletion is fail-closed bottom-up: a hierarchy record with children
|
||||
cannot be deleted (RESTRICT FKs, §2). Deleting a workspace is a SOT-side
|
||||
operation subject to the kanban SOT's own rules and is not granted any
|
||||
new semantics by this contract.
|
||||
4. **Roll-up is never a write** (A1 §8.2.2, preserved at full strength). A
|
||||
roll-up read mutates nothing — not hierarchy state, and not business or
|
||||
orchestration state: it must not mutate, claim, order, or gate
|
||||
workspace work. Contract 8 owns projection details but cannot narrow
|
||||
this rule. This contract additionally guarantees the chain roll-ups
|
||||
aggregate over is unique and non-null (§2.5).
|
||||
5. **Visibility administration (Ruling 4b decisions 2–3).** Changing
|
||||
`companies.visibility` is a hierarchy mutation through the §5.1
|
||||
command path, audited per §5.2 (the event carries the old and new
|
||||
visibility values as its semantic content). It is authorized for
|
||||
exactly two actor classes: platform admins (`users.role = 'admin'`)
|
||||
and subjects holding the company-CRUD capability that a follow-up
|
||||
amendment to contract 2 will define — until that amendment ratifies,
|
||||
the capability class is empty and the command is admin-only.
|
||||
A company `owner` as such may NOT change visibility: standard users
|
||||
cannot publish a company into the directory. This is the one
|
||||
hierarchy mutation a platform admin performs without holding a
|
||||
hierarchy grant, and it is ratified here as instance administration
|
||||
(directory curation) in contract 2 §1.1's sense, not tenant access:
|
||||
the command mutates the single `visibility` column, reads no tenant
|
||||
content, and confers no grant — contract 2 §1.1's
|
||||
no-implicit-tenant-access rule is otherwise untouched. Top-level
|
||||
company creation (contract 3 §5.2) always creates
|
||||
`visibility = 'private'`; the creation command cannot set or change
|
||||
visibility.
|
||||
|
||||
## 6. Verification requirements
|
||||
|
||||
Binding on the implementing PRs (extends A1 §8.3):
|
||||
|
||||
1. Schema witnesses (real PostgreSQL, §6.8): chain construction — insert
|
||||
with a null parent FK refused; insert with one valid parent accepted;
|
||||
two siblings under one parent accepted (the control proving the
|
||||
constraint rejects only what §2.5 forbids); catalog assertion that each
|
||||
child table has exactly one parent-FK column and no parentage edge
|
||||
table exists. Composite slug uniqueness per parent (duplicate slug
|
||||
under same parent refused; same slug under different parents accepted).
|
||||
Grant CHECKs: exactly-one-of subject and exactly-one-of target each
|
||||
witnessed (zero and two set → refused). Grant uniqueness: a duplicate
|
||||
(subject, target, role) row refused for each of the six subject×target
|
||||
forms, proving NULLS-NOT-DISTINCT semantics; NOT NULL on `role`,
|
||||
`granted_by`, and all `name`/`slug` columns witnessed. Company
|
||||
visibility (§2.8): a value outside `private`/`directory` refused with
|
||||
both valid values accepted as the control; an insert omitting the
|
||||
column defaults to `private`.
|
||||
2. Column allowlist: an information_schema assertion that each class
|
||||
table's column set is exactly the set declared in §2/§3 — the bounded
|
||||
observable for no-payload (§2.7) and no-`owner_id` (§4.4).
|
||||
3. Command surface: two witnesses, both required (§5.1). (a) Route
|
||||
inventory: an assertion over the Gateway's registered hierarchy
|
||||
routes/commands proving the registered mutation surface is exactly the
|
||||
declared hierarchy command family — no generic CRUD endpoint. (b)
|
||||
Writer coverage — the closed allowlist a route inventory cannot
|
||||
provide: a static CI assertion over the Gateway and package sources
|
||||
with three prongs, each bound to one explicitly enumerated allowlist
|
||||
of hierarchy command/repository modules. (i) Symbol prong: write
|
||||
references to the class-table schema symbols (insert, update, delete)
|
||||
occur only in allowlisted modules. (ii) Literal prong: a class-table
|
||||
name appearing inside a SQL string or tagged SQL template outside the
|
||||
allowlist fails the assertion — this is what catches a raw-SQL writer
|
||||
that references no schema symbol. (iii) Raw-execution prong: any call
|
||||
to a raw-SQL execution primitive (the ORM's raw/unsafe constructors,
|
||||
driver-level query/execute) outside the allowlist fails the
|
||||
assertion, regardless of what the SQL string contains or how it is
|
||||
constructed — the call site is statically detectable even when a
|
||||
dynamically assembled table name is not, so a raw writer with a
|
||||
runtime-built identifier is caught by its primitive, not its
|
||||
payload. Two composition rules keep prong (iii) meaningful: the
|
||||
allowlist names hierarchy command/repository modules only — a
|
||||
generic raw-SQL helper or database-utility module is never
|
||||
allowlisted; and an allowlisted module MUST NOT export a function
|
||||
that executes caller-supplied SQL (such an export is itself a
|
||||
raw-execution primitive, and the exporting module is treated as
|
||||
unallowlisted for prong (iii) if it does). Legitimate raw execution
|
||||
that is not a hierarchy writer — e.g. the migration runner in the
|
||||
db package — lives on a second, separately enumerated
|
||||
**infrastructure register**, distinct from the writer allowlist and
|
||||
equally closed. A registered module is exempt from prong (iii) only:
|
||||
prongs (i) and (ii) apply to it with no exemption, so it can hold no
|
||||
class-table schema symbol or class-table SQL literal, and it can
|
||||
never appear on the writer allowlist. To close the laundering path,
|
||||
the same assertion checks imports, and the import analysis is
|
||||
**re-export-aware**: it follows package barrels and re-exports, so a
|
||||
route hidden behind an index module is still a route — and it
|
||||
resolves literal dynamic imports the same way: an
|
||||
`await import('<literal specifier>')` is an import edge like any
|
||||
static import, not an evasion of the analysis (a dynamic import of
|
||||
the db package whose specifier is not a literal fails the assertion
|
||||
outright, because it makes the import graph unanalyzable). A
|
||||
registered module may be imported only by other registered modules
|
||||
or by importers named on that module's own closed importer
|
||||
enumeration in the register — operational entry points such as the
|
||||
migration/bootstrap CLI or the Gateway's startup migration hook.
|
||||
The enumeration names the complete permitted production consumer
|
||||
set, and completeness is measured, not asserted: the migration
|
||||
runner's measured production importer set today has four members —
|
||||
the Gateway database module (reached through the db package
|
||||
barrel), the storage package's Postgres adapter, and two mosaic CLI
|
||||
commands, the fleet-backlog command and the gateway verify command,
|
||||
both routed through literal dynamic imports of the db package — so
|
||||
its enumeration names those four. A module that only receives the
|
||||
runner's functions by parameter injection (the gateway schema-check
|
||||
module takes them as arguments from the verify command) has no
|
||||
import edge of its own and is not enumerated. Any import route
|
||||
outside the enumeration fails the assertion. Being a
|
||||
named importer confers nothing else: the importer stays fully
|
||||
subject to prongs (i) and (ii), gains no writer-allowlist standing,
|
||||
and whether it uses the registered module beyond its operational
|
||||
purpose is a §5.1 review question, not a static claim. Runtime code-construction
|
||||
primitives (`eval`, `new Function`) anywhere in the scanned sources
|
||||
fail the assertion outright, allowlist or not. Schema definitions
|
||||
and generated migrations are excluded from the literal prong; a
|
||||
false positive is resolved in the same PR by adding the module to
|
||||
the one enumerated list its role permits — the writer allowlist for
|
||||
a hierarchy command/repository module, the infrastructure register
|
||||
for non-hierarchy raw execution — never by weakening the assertion,
|
||||
and neither list may take a module the composition rules bar from
|
||||
it. Both lists are closed, and the assertion's detection
|
||||
claim is exactly its prongs: it statically surfaces every writer
|
||||
expressed as a schema-symbol reference, a class-table SQL literal, a
|
||||
raw-execution call site, or runtime code construction. An evasion
|
||||
engineered outside those syntactic forms is a §5.1 violation that
|
||||
review and audit own — the witness does not claim to catch what
|
||||
static analysis cannot see, and any such evasion found later is
|
||||
corrected as a conformance defect, not grandfathered.
|
||||
4. Audit witnesses: for each mutation class (create, rename, transfer,
|
||||
visibility change, grant create/change/revoke, delete) — the event
|
||||
exists after commit
|
||||
with actor/verb/target and same-transaction atomicity, and the
|
||||
event's outbox record exists after the same commit — state row,
|
||||
audit event, and outbox record are witnessed as one transaction
|
||||
(REQ-AUD-001); a rolled-back
|
||||
mutation leaves no event, no outbox record, AND no state effect —
|
||||
a rolled-back create leaves no row, a rolled-back rename, transfer,
|
||||
or visibility change leaves the prior values in place, and a
|
||||
rolled-back delete or grant revoke leaves the row present
|
||||
(rollback witness on all three legs, per REQ-AUD-001's
|
||||
commit-or-roll-back-together acceptance); a
|
||||
node delete's cascaded
|
||||
grant deletions are each covered by events; events survive deletion of
|
||||
their target (query the events of a deleted node).
|
||||
5. Transfer tests: parent-FK update moves the subtree resolution and
|
||||
modifies zero business/orchestration rows (row-count and content
|
||||
assertions on workspace contents before/after); transfer without
|
||||
authority on the source or on the destination side is refused (with
|
||||
contract 2 §7.8).
|
||||
6. Deletion tests: delete with children refused at the database level;
|
||||
delete of a leaf cascades its grants and nothing else; deleting a user
|
||||
or team that is a grant subject (or `granted_by` referent) is refused
|
||||
(RESTRICT witnesses for §3.3).
|
||||
7. Negative tests: no business/orchestration table accepts a company,
|
||||
estate, platform-project, or grant id in any reference position, and
|
||||
none accepts a workspace id in any non-tenancy position; the canonical
|
||||
tenancy FK control — a business row inserted with a valid
|
||||
`workspace_id` succeeds, with an invalid one is refused; roll-up
|
||||
endpoints mutate no canonical state anywhere (assert zero writes across
|
||||
hierarchy AND workspace tables, not hierarchy only); readers see
|
||||
aggregates only over workspaces they are authorized on, with no
|
||||
cross-tenant existence oracles (A1 §8.3 acceptance 3, as narrowed by
|
||||
A2 §9.1.2) beyond the one
|
||||
ratified carve-out — the §2.8 company directory, witnessed in §6.9.
|
||||
8. Real-PostgreSQL coverage for every constraint witness (unique/CHECK/
|
||||
RESTRICT/NULLS NOT DISTINCT behavior), using the `ci-postgres` service
|
||||
in the `test` CI step; mocked specs cannot witness database constraints.
|
||||
9. Visibility witnesses (§2.8, §5.5): the directory read returns exactly
|
||||
the `visibility = 'directory'` companies to any authenticated user,
|
||||
disclosing existence, name, and slug only (closed-field assertion on
|
||||
the response shape); a private company never appears in the directory
|
||||
for a reader without a grant on it (with the control: it appears in
|
||||
that reader's granted-structure reads); a directory-listed company's
|
||||
subtree, aggregates, and content remain refused for a non-granted
|
||||
reader (disclosure ≠ authority); the visibility command is refused
|
||||
for a non-admin actor — including an effective `owner` of the target
|
||||
company — with the platform-admin accept control; top-level creation
|
||||
yields `visibility = 'private'` and accepts no visibility argument;
|
||||
each visibility change emits its §5.2 audit event carrying old and
|
||||
new values — the full audit pattern for the mutation class
|
||||
(same-transaction atomicity of state row, audit event, and outbox
|
||||
record; rollback leaving no state effect, no event, and no outbox
|
||||
record; actor/verb/target) is §6.4's, which enumerates
|
||||
visibility change; this item adds only the old/new-value payload
|
||||
assertion.
|
||||
|
||||
## Ruling request
|
||||
|
||||
Ratify sections 1–6 as written, with one decision embedded: hierarchy
|
||||
records carry no owner column — ownership is expressed solely through
|
||||
grants (§4.4) — say "agreed" or name the ownership model you want.
|
||||
@@ -456,61 +456,3 @@ this line is weakened.
|
||||
- Negative tests prove roll-up endpoints cannot mutate state and that a
|
||||
reader sees aggregates only over workspaces they are authorized on
|
||||
(no cross-tenant existence oracles).
|
||||
|
||||
## 9. Amendment A2 — company visibility classes and the company directory
|
||||
|
||||
**Status:** amendment to Amendment A1, added by reviewed PR under Ruling 4b
|
||||
(operator ruling, 2026-08-27; decision owner Jason; recorded in the webui-audit
|
||||
lane RULINGS.md). Everything in §§1–8 remains binding verbatim, with exactly
|
||||
the two express modifications below. Nothing else is weakened. The detailed
|
||||
contract text lives in the hierarchy schema contract
|
||||
(`hierarchy-schema.md` §2.8, §5.5, §6.9); this amendment changes only what A1
|
||||
itself permits, so that contract does not stretch A1 by interpretation.
|
||||
|
||||
### 9.1 What A2 modifies in A1
|
||||
|
||||
1. **Class data (extends §8.1.2's first constraint).** The tenancy/authorization
|
||||
structure record class additionally carries **visibility-class data**: the
|
||||
single column `companies.visibility`, values `private` | `directory`
|
||||
(hierarchy schema §2.8). Visibility is disclosure data about the class's own
|
||||
nodes — what a company row reveals about its own existence — and is part of
|
||||
the class's tenancy/authorization purpose. It is not business or
|
||||
orchestration payload. §8.1.2's payload prohibition is widened for nothing
|
||||
else: hierarchy tables still MUST NOT carry task, plan, or any other
|
||||
business/orchestration payload, and this amendment admits exactly this one
|
||||
column.
|
||||
2. **The company directory (extends §8.1.3's function enumeration).** The
|
||||
hierarchy serves one additional, express, narrow runtime function: the
|
||||
**company directory** — a read-only disclosure listing of exactly the
|
||||
companies whose `visibility = 'directory'`, revealing existence, name, and
|
||||
slug to every authenticated user of the deployment and nothing else. It
|
||||
mutates nothing, confers no authority, evaluates no grant down the chain,
|
||||
and aggregates nothing (it is not a roll-up). §8.3's
|
||||
no-cross-tenant-existence-oracle acceptance is narrowed by exactly this one
|
||||
ratified carve-out: the directory is the sole permitted existence
|
||||
disclosure, and it discloses only directory-class companies (witnessed in
|
||||
hierarchy schema §6.7 and §6.9). Private companies remain undisclosed to
|
||||
non-granted subjects everywhere, including the directory.
|
||||
|
||||
### 9.2 What A2 explicitly does not change
|
||||
|
||||
1. Content access stays grant-only under the RBAC grant model contract:
|
||||
directory listing discloses existence, never content, membership, or any
|
||||
authority (Ruling 3 unchanged; hierarchy schema §2.8).
|
||||
2. **No join-request surface is authorized.** Ruling 4b decision 5's
|
||||
see-and-ask-to-join flow is a follow-up contract in its entirety —
|
||||
including the ability to submit a request. A2 admits exactly the
|
||||
read-only listing of §9.1.2 and nothing more; hierarchy schema §2.8
|
||||
states the invariants that pre-bind the future flow contract, and that
|
||||
contract must itself amend this enumeration before any join-request
|
||||
runtime surface exists.
|
||||
3. Visibility changes are hierarchy mutations on the existing §8.2.3 audited
|
||||
mutation path — audited maintenance of the class's own structure in
|
||||
§8.1.3's sense, not a further runtime function. Authorization for them is
|
||||
defined in hierarchy schema §5.5 (platform admins plus the future
|
||||
company-CRUD capability; owner-as-such cannot publish).
|
||||
4. Company creation is unchanged and always yields `visibility = 'private'`
|
||||
(onboarding wizard §5.2); this amendment adds no creation path and no
|
||||
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.
|
||||
|
||||
@@ -397,14 +397,6 @@ seed-workspace-scoped mutant, correctly refusing outside the seed
|
||||
set, passes branch (c), so the two branches detect distinct
|
||||
mutants. No other change.
|
||||
|
||||
Amendment 1 (Ruling 4b, 2026-08-28): §5.2's embedded decision was RULED
|
||||
AGREED (Jason, 2026-08-27), and Ruling 4b adds company visibility
|
||||
classes (hierarchy schema §2.8): open top-level creation always yields
|
||||
a **private** company; publishing a company into the deployment-wide
|
||||
directory is a separate, gated visibility mutation (hierarchy schema
|
||||
§5.5) that is never part of the creation command. §5.2 is amended to
|
||||
state both.
|
||||
|
||||
Scope: the Gateway-backed product onboarding wizard. Out of scope: the
|
||||
host-local install wizard (`mosaic wizard`, which drives host install and
|
||||
gateway bootstrap and is not this artifact — audit REPORT.md layer 3);
|
||||
@@ -1177,18 +1169,15 @@ collects no sensitive category, so v1 ships no custody surface.
|
||||
party, service actor, or wizard-privileged writer exists in this
|
||||
flow.
|
||||
2. **Post-bootstrap top-level company creation** — the "N companies" flow
|
||||
— RULED AGREED (Jason, 2026-08-27): any **eligible platform user** MAY
|
||||
— is decided by the ruling below: any **eligible platform user** MAY
|
||||
create a top-level company and MUST name an initial `owner` grant in
|
||||
the same audited operation (contract 2 §4.3); the creator naming
|
||||
themselves is the default. Eligible means, in identity-contract
|
||||
terms: an authenticated account (identity §2) that is not banned
|
||||
(identity §7.1 — deactivation on this platform IS the better-auth
|
||||
ban; no separate deactivated state exists). No further role or grant
|
||||
is required. Creation always yields a **private** company
|
||||
(`visibility = 'private'`, hierarchy schema §2.8, Ruling 4b): the
|
||||
creation command accepts no visibility argument, and publishing into
|
||||
the deployment-wide directory is a separate, gated mutation
|
||||
(hierarchy schema §5.5) that standard users cannot perform.
|
||||
is required. Until that ruling, deny-by-default holds (contract 2
|
||||
§3.1): no implicit creation authority exists.
|
||||
3. Child-node creation inside the wizard (estate, project, workspace
|
||||
under the seeded company) follows contract 2 §4.3: parent
|
||||
`owner` authority, no automatic grant needed — for canonical seed
|
||||
@@ -1943,13 +1932,7 @@ contracts and are not additions:
|
||||
suffix at all — each contradicting PRD D4's no-lock-in
|
||||
requirement (§4.4).
|
||||
|
||||
## Ruling request — RULED AGREED (Jason, 2026-08-27; Amendment 1)
|
||||
|
||||
The §5.2 decision below was ruled agreed: open eligible-user creation
|
||||
stands (yielding private companies per Amendment 1), and the
|
||||
"alternative if rejected" did not take effect. The request is retained
|
||||
below as historical record of what was put to ruling; it is no longer
|
||||
live.
|
||||
## Ruling request
|
||||
|
||||
Ratify sections 1–7 as written, with one decision embedded:
|
||||
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
# RBAC Grant Model Contract
|
||||
|
||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 2 of 9).
|
||||
Authority: PRD Part I §4 ("Granular RBAC: admins restrict access per company,
|
||||
estate, and project; grants are evaluated down the chain") and the
|
||||
native-kanban SOT Amendment A1 (§8.1.3 RBAC evaluation, §8.3 acceptance 2).
|
||||
This document defines the grant vocabulary, evaluation semantics, and
|
||||
revocation propagation that the hierarchy schema contract
|
||||
(`docs/requirements/hierarchy-schema.md`, contract 1) attaches to. Contract 1
|
||||
pins the `hierarchy_grants` table shape and defers the `role` vocabulary and
|
||||
the meaning of "authority" here; the identity contract
|
||||
(`docs/requirements/identity-lifecycle.md` §1.4) pins that account creation
|
||||
grants nothing.
|
||||
|
||||
Revision 2 (independent review, GLM 5.3): §1.1 consequence analysis
|
||||
completed — the two existing platform-admin bypass code paths are named as
|
||||
non-conformant and §7.4 retires them; team grant subjects suspended pending
|
||||
a team contract (§1.4, §3.3–3.4, §7.5); no-self-escalation restated with
|
||||
its true rationale and a constructible observable (§4.2, §7.7);
|
||||
node-creation seeding scoped to the bootstrap path, resolving the §7.7/§4.3
|
||||
contradiction; A1 quotation corrected; audit-field provenance corrected;
|
||||
principal-position consequence named (§1.3); membership-row,
|
||||
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".
|
||||
|
||||
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
|
||||
tables themselves (contract 1), workspace-internal membership and its
|
||||
role/capability vocabulary (native-kanban SOT REQ-ID-001 and its implementing
|
||||
schema), roll-up projection semantics (contract 8), wizard seeding
|
||||
(contract 3), the team model (suspended here; see §1.4).
|
||||
|
||||
## 1. Three authority layers, none substitutable
|
||||
|
||||
1. **Platform role** (`users.role`, better-auth: `member` | `admin`) governs
|
||||
instance administration — user management, system settings, provider
|
||||
configuration. It is not tenancy authority: holding platform `admin`
|
||||
confers **no implicit hierarchy grant and no workspace authorization**.
|
||||
An operator who should see tenant content holds an explicit, audited
|
||||
grant like anyone else. This is the deny-by-default consequence of A1
|
||||
§8.1.3 ("not a bypass of workspace authorization"). `AdminGuard`'s
|
||||
`role === 'admin'` check on admin endpoints stays the platform role's
|
||||
only meaning. **Two shipped code paths violate this rule today and are
|
||||
implementation defects this contract makes non-conformant:** (a) the
|
||||
command authorization service short-circuits every command scope to
|
||||
allowed for platform admins
|
||||
(`apps/gateway/src/commands/command-authorization.service.ts`,
|
||||
`hasScope` returning true when `role === 'admin'`), and (b) the MCP
|
||||
scope derivation maps platform `admin` to tenant-admin MCP scopes
|
||||
including task create/update
|
||||
(`apps/gateway/src/mcp/mcp.service.ts`,
|
||||
`deriveMcpToolScopesForUser`). Ratifying this contract revokes both;
|
||||
§7.4 names them as the surfaces the deny-by-default test retires.
|
||||
2. **Hierarchy grants** (`hierarchy_grants`, contract 1 §3) declare tenancy
|
||||
authority at company, estate, or platform-project scope and evaluate down
|
||||
the chain to workspace-scoped authorization (§3 below).
|
||||
3. **Workspace membership** (SOT REQ-ID-001) remains its own mechanism.
|
||||
A chain grant confers command authorization over descendant workspaces;
|
||||
it does not create membership rows, and row-level principal positions
|
||||
(task owner, proposer, decision actor) still require ACTIVE workspace
|
||||
membership exactly as REQ-TEN-001/REQ-ID-001 acceptance states.
|
||||
Consequence, stated so implementing PRs do not weaken REQ-TEN-001 to
|
||||
remove the friction: a chain-granted actor who is not a workspace member
|
||||
may issue the write commands their role implies but cannot occupy a
|
||||
principal position — any command taking a principal argument must name
|
||||
an ACTIVE member of the target workspace (§7.2 enumerates this cell).
|
||||
4. **Team grant subjects are suspended.** Contract 1 §3.1 reserves a
|
||||
`team_id` attachment point, but no ratified contract yet defines the
|
||||
team it would bind: the only existing `teams` table is the legacy global
|
||||
Brain table (own authority columns, no workspace binding, not
|
||||
repurposed per contract 1 §1.3), while the SOT's teams are
|
||||
workspace-bound (REQ-ID-001) — and a workspace-bound team holding a
|
||||
company-level grant would be a cross-workspace authority group nothing
|
||||
has ratified. Until a team contract defines the subject (which table,
|
||||
which membership rows, and its relation to D2/REQ-ID-001), creating a
|
||||
grant with a team subject MUST be refused at the command surface (the
|
||||
schema column remains, per contract 1). §3's evaluation semantics for
|
||||
team-conferred grants are specified now so the team contract activates
|
||||
them without amending this one.
|
||||
|
||||
## 2. Role vocabulary
|
||||
|
||||
One vocabulary at every hierarchy level, totally ordered — a higher role
|
||||
includes everything below it:
|
||||
|
||||
1. `viewer` — read: sees the node, its subtree structure, and the roll-up
|
||||
aggregates over descendant workspaces (within contract 8's carve-out
|
||||
bounds); read access to descendant workspace content per the SOT's read
|
||||
command families. No mutation of anything.
|
||||
2. `member` — work: everything `viewer` has, plus write authorization for
|
||||
business/orchestration command families in descendant workspaces (the
|
||||
concrete command-family mapping is implementation work under SOT
|
||||
REQ-ID-001; this contract pins that `member` maps to the workspace write
|
||||
families and nothing structural).
|
||||
3. `owner` — structure: everything `member` has, plus hierarchy mutations on
|
||||
the subtree (create/rename/delete child nodes, transfers per §5), and
|
||||
grant management on the node and its subtree (§4).
|
||||
|
||||
No other value is valid in `hierarchy_grants.role`; the column is
|
||||
constraint-checked against exactly these three. Extending the vocabulary is a
|
||||
contract amendment, not an implementation decision.
|
||||
|
||||
## 3. Evaluation semantics
|
||||
|
||||
1. **Deny by default.** No grant on any ancestor → no authority. There are
|
||||
no implicit grants: not from platform role (§1.1), not from creating a
|
||||
node (§4.3), not from workspace membership (membership without a chain
|
||||
grant confers exactly what the SOT's own membership rules confer inside
|
||||
that workspace, nothing up the chain).
|
||||
2. **Down-the-chain only.** A grant on a node applies to that node and its
|
||||
entire descendant subtree. Nothing evaluates upward or sideways: a grant
|
||||
on an estate says nothing about the parent company or sibling estates.
|
||||
3. **Effective role = maximum.** A subject's effective role at any node is
|
||||
the highest role among grants held directly by the subject's user on
|
||||
that node or any ancestor — and, once the team contract activates team
|
||||
subjects (§1.4), grants held by any team the user is a member of on that
|
||||
node or any ancestor. Roles never subtract — there is no negative/deny
|
||||
grant in this model; revocation is deletion (§6).
|
||||
4. **Team grants follow live membership** (specified now, active only per
|
||||
§1.4). A team grant confers its role on the team's current members,
|
||||
evaluated at decision time. Leaving the team is loss of the grant with
|
||||
§6's propagation bound.
|
||||
5. **Live evaluation, fail closed.** Authorization decisions derive from the
|
||||
live grant and team-membership rows (or from a cache that is invalidated
|
||||
in the same transaction as any grant/membership/hierarchy mutation). A
|
||||
decision path that cannot read grant state denies. No materialized ACL is
|
||||
ever authoritative.
|
||||
6. **Tenant context stays derived from authenticated authority**
|
||||
(REQ-TEN-001). The chain adds where grants can be declared; a workspace
|
||||
request is still authorized against that workspace, with the chain
|
||||
contributing the effective role — never letting the chain become what A1
|
||||
§8.1.3 forbids: "a bypass of workspace authorization".
|
||||
|
||||
## 4. Grant management
|
||||
|
||||
1. Creating, changing, or revoking a grant on a node requires effective
|
||||
`owner` on that node (directly or via any ancestor).
|
||||
2. **No self-escalation.** A grant manager cannot create a grant with a role
|
||||
higher than their own effective role on the target node. Under the §2
|
||||
vocabulary this rule is currently implied by §4.1 (managers are `owner`,
|
||||
the top role — no constructible grant exceeds it); it is stated
|
||||
explicitly so it survives any future amendment that decouples
|
||||
grant-management authority from role height. Its observable is the §7.7
|
||||
audit invariant, not a refusal test.
|
||||
3. **Bootstrap of authority is explicit; inheritance covers the rest.**
|
||||
Creating the first company (the wizard path, contract 3) and any
|
||||
top-level company creation MUST name the initial `owner` grant in the
|
||||
same audited operation — a top-level node has no ancestor to inherit
|
||||
from, so without this the node would be unownable. Creating a child node
|
||||
(estate, platform-project, workspace) requires effective `owner` on the
|
||||
parent (§2.3) and confers no automatic grant; the creator's authority
|
||||
over the new node already follows from §3.2 down-the-chain evaluation.
|
||||
The creating command MAY additionally name an explicit initial grant for
|
||||
a child node; it is not required to.
|
||||
4. Every grant mutation is a semantic audit event under contract 1 §5.2's
|
||||
guarantees, extended by this contract with two further fields: the event
|
||||
carries actor, verb, target, **subject, and role** (subject and role are
|
||||
this contract's addition; contract 1 §5.2 does not enumerate them).
|
||||
5. **Role strings are namespaced.** `viewer`/`member` exist at hierarchy
|
||||
level, `member`/`admin` on `users.role`, and the current command layer
|
||||
uses a third `viewer|member|admin` vocabulary — same strings, different
|
||||
meanings. Any serialized role string (audit events per §4.4, API
|
||||
responses, logs) MUST identify its layer (e.g. `hierarchy:owner`,
|
||||
`platform:admin`); a bare role string in a serialized artifact is
|
||||
non-conformant.
|
||||
|
||||
## 5. Transfer authority (completes contract 1 §4.2)
|
||||
|
||||
"Authority over BOTH the source and the destination parent" means: effective
|
||||
`owner` on the current parent node (or an ancestor) AND effective `owner` on
|
||||
the destination parent node (or an ancestor), evaluated at transfer time in
|
||||
the transfer's own transaction. One subject must hold both; two cooperating
|
||||
half-authorized subjects are not a transfer protocol this contract defines.
|
||||
|
||||
## 6. Revocation propagation
|
||||
|
||||
1. Revoking a grant (deleting the row), removing a user from a team that
|
||||
carries a grant (once team subjects activate, §1.4), or the cascade
|
||||
deletion of a node's grants during node deletion (contract 1 §3.3) all
|
||||
propagate identically: the authority derived from that grant is gone for
|
||||
every descendant workspace.
|
||||
2. **Bound:** the next authorization decision on any affected transport
|
||||
decides against the revoked grant. Concretely: no new HTTP/MCP command
|
||||
authorized by the revoked grant after the revoking transaction commits;
|
||||
an open Socket.IO connection whose subscriptions depend on the revoked
|
||||
grant is re-evaluated within 30 seconds or at its next inbound message,
|
||||
whichever comes first (same bound as the identity contract's §7.1
|
||||
deactivation rule; same mechanism may serve both).
|
||||
3. Revocation is subtractive only in effect, not in representation: the
|
||||
evaluator never needs tombstones; deletion of the row is the revocation.
|
||||
|
||||
## 7. Verification requirements
|
||||
|
||||
Binding on the implementing PRs (extends A1 §8.3 acceptance 2–3 and
|
||||
contract 1 §6):
|
||||
|
||||
1. Vocabulary: the role CHECK constraint rejects any value outside
|
||||
`viewer|member|owner` (real-PostgreSQL witness, `ci-postgres` service in
|
||||
the `test` CI step).
|
||||
2. Per-level conferral: for each of the three levels × three roles, a grant
|
||||
yields exactly the implied workspace authorization in a descendant
|
||||
workspace and nothing in a non-descendant workspace (the A1 §8.3
|
||||
"exactly the permissions the chain implies" matrix, enumerated). The
|
||||
matrix includes: a chain grant creates zero workspace-membership rows
|
||||
(assert row counts); a chain-granted non-member is refused as the
|
||||
principal argument of any principal-taking command while their
|
||||
non-principal writes succeed (§1.3); structure reads leak no existence
|
||||
of nodes the reader holds no grant on (no cross-tenant existence
|
||||
oracle, A1 §8.3 acceptance 3).
|
||||
3. Ordering: `owner` ⊇ `member` ⊇ `viewer` behaviorally — each higher role
|
||||
passes every lower role's positive cases.
|
||||
4. Deny-by-default: platform `admin` with no grant reaches no tenant
|
||||
content — asserted against the two §1.1 non-conformant surfaces after
|
||||
their retirement: the command-authorization admin short-circuit and the
|
||||
MCP tenant-admin scope derivation both gone (a platform admin with no
|
||||
grant is refused workspace commands and receives no tenant MCP scopes);
|
||||
workspace member with no chain grant gains nothing outside SOT
|
||||
membership semantics; fresh account reaches nothing (identity contract
|
||||
§1.4 cross-check).
|
||||
5. Team subjects: while suspended (§1.4), creating a team-subject grant is
|
||||
refused at the command surface. On activation by the team contract:
|
||||
user-direct and team-conferred grants combine to the maximum; team-leave
|
||||
drops authority within the §6.2 bound; decision-time evaluation
|
||||
witnessed (grant added → next decision allows; no restart or re-login
|
||||
required).
|
||||
6. Revocation: each revocation path in §6.1 denies the next command on
|
||||
every transport; the socket bound is measured; a cached-authorization
|
||||
implementation proves transactional invalidation (grant revoked and
|
||||
decision made on two distinct physical connections). Fail-closed fault
|
||||
witness for §3.5: with grant state unreadable (fault injection), the
|
||||
decision denies.
|
||||
7. Grant management: non-`owner` cannot mutate grants; top-level company
|
||||
creation without the named initial `owner` grant is refused, while child
|
||||
node creation under ancestor authority succeeds without one (§4.3 both
|
||||
directions); every mutation produces its audit event with the §4.4
|
||||
fields. Self-escalation observable: over the audit event stream, every
|
||||
grant-create/change event's role is ≤ the acting user's effective role
|
||||
on the target at event time (reconstructable invariant, not a refusal
|
||||
test — see §4.2).
|
||||
8. Transfer: both-sides `owner` accepted, each single-side case refused
|
||||
(completing contract 1 §6.5).
|
||||
|
||||
## Ruling request
|
||||
|
||||
Ratify sections 1–7 as written, with one decision embedded and one
|
||||
interpretive resolution named:
|
||||
|
||||
- Decision: platform `admin` confers no implicit tenant access — operators
|
||||
see tenant content only through explicit, audited grants (§1.1), which
|
||||
retires the two existing admin bypass paths named there. Say "agreed" or
|
||||
name the implicit access you want platform admins to keep.
|
||||
- Interpretive resolution (for visibility, not a separate question): PRD
|
||||
Part I §4 says "admins restrict access per company, estate, and project";
|
||||
this contract resolves "admins" as hierarchy `owner`s (§4.1), not
|
||||
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.
|
||||
@@ -1,63 +0,0 @@
|
||||
CREATE TABLE "companies" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "companies_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "estates" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
CONSTRAINT "estates_company_slug_uniq" UNIQUE("company_id","slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hierarchy_grants" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text,
|
||||
"team_id" uuid,
|
||||
"company_id" uuid,
|
||||
"estate_id" uuid,
|
||||
"platform_project_id" uuid,
|
||||
"role" text NOT NULL,
|
||||
"granted_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hierarchy_grants_subject_target_role_uniq" UNIQUE NULLS NOT DISTINCT("user_id","team_id","company_id","estate_id","platform_project_id","role"),
|
||||
CONSTRAINT "hierarchy_grants_subject_check" CHECK (num_nonnulls(user_id, team_id) = 1),
|
||||
CONSTRAINT "hierarchy_grants_target_check" CHECK (num_nonnulls(company_id, estate_id, platform_project_id) = 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "platform_projects" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"estate_id" uuid NOT NULL,
|
||||
CONSTRAINT "platform_projects_estate_slug_uniq" UNIQUE("estate_id","slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "workspaces" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"platform_project_id" uuid NOT NULL,
|
||||
CONSTRAINT "workspaces_platform_project_slug_uniq" UNIQUE("platform_project_id","slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "estates" ADD CONSTRAINT "estates_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_estate_id_estates_id_fk" FOREIGN KEY ("estate_id") REFERENCES "public"."estates"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_platform_project_id_platform_projects_id_fk" FOREIGN KEY ("platform_project_id") REFERENCES "public"."platform_projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "platform_projects" ADD CONSTRAINT "platform_projects_estate_id_estates_id_fk" FOREIGN KEY ("estate_id") REFERENCES "public"."estates"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_platform_project_id_platform_projects_id_fk" FOREIGN KEY ("platform_project_id") REFERENCES "public"."platform_projects"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "hierarchy_grants_company_id_idx" ON "hierarchy_grants" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "hierarchy_grants_estate_id_idx" ON "hierarchy_grants" USING btree ("estate_id");--> statement-breakpoint
|
||||
CREATE INDEX "hierarchy_grants_platform_project_id_idx" ON "hierarchy_grants" USING btree ("platform_project_id");--> statement-breakpoint
|
||||
CREATE INDEX "hierarchy_grants_user_id_idx" ON "hierarchy_grants" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "hierarchy_grants_team_id_idx" ON "hierarchy_grants" USING btree ("team_id");--> statement-breakpoint
|
||||
CREATE INDEX "hierarchy_grants_granted_by_idx" ON "hierarchy_grants" USING btree ("granted_by");
|
||||
@@ -1,40 +0,0 @@
|
||||
CREATE TYPE "public"."hierarchy_outbox_status" AS ENUM('pending', 'processing', 'delivered');--> statement-breakpoint
|
||||
CREATE TABLE "hierarchy_audit_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"seq" bigint GENERATED ALWAYS AS IDENTITY (sequence name "hierarchy_audit_events_seq_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"actor_id" text NOT NULL,
|
||||
"verb" text NOT NULL,
|
||||
"target_kind" text NOT NULL,
|
||||
"target_id" uuid NOT NULL,
|
||||
"target_snapshot" jsonb NOT NULL,
|
||||
"transfer_from" jsonb,
|
||||
"transfer_to" jsonb,
|
||||
"correlation_id" text NOT NULL,
|
||||
"causation_id" uuid,
|
||||
"idempotency_key" text NOT NULL,
|
||||
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "hierarchy_audit_events_verb_check" CHECK (verb IN ('create', 'rename', 'transfer', 'delete', 'grant_create', 'grant_change', 'grant_revoke')),
|
||||
CONSTRAINT "hierarchy_audit_events_target_kind_check" CHECK (target_kind IN ('company', 'estate', 'platform_project', 'grant')),
|
||||
CONSTRAINT "hierarchy_audit_events_transfer_check" CHECK ((verb = 'transfer') = (transfer_from IS NOT NULL AND transfer_to IS NOT NULL))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "hierarchy_outbox" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"event_id" uuid NOT NULL,
|
||||
"idempotency_key" text NOT NULL,
|
||||
"correlation_id" text NOT NULL,
|
||||
"status" "hierarchy_outbox_status" DEFAULT 'pending' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"delivered_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_audit_events" ADD CONSTRAINT "hierarchy_audit_events_causation_id_hierarchy_audit_events_id_fk" FOREIGN KEY ("causation_id") REFERENCES "public"."hierarchy_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "hierarchy_outbox" ADD CONSTRAINT "hierarchy_outbox_event_id_hierarchy_audit_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."hierarchy_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "hierarchy_audit_events_idempotency_idx" ON "hierarchy_audit_events" USING btree ("idempotency_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "hierarchy_audit_events_seq_idx" ON "hierarchy_audit_events" USING btree ("seq");--> statement-breakpoint
|
||||
CREATE INDEX "hierarchy_audit_events_target_seq_idx" ON "hierarchy_audit_events" USING btree ("target_id","seq");--> statement-breakpoint
|
||||
CREATE INDEX "hierarchy_audit_events_correlation_idx" ON "hierarchy_audit_events" USING btree ("correlation_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "hierarchy_outbox_event_idx" ON "hierarchy_outbox" USING btree ("event_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "hierarchy_outbox_idempotency_idx" ON "hierarchy_outbox" USING btree ("idempotency_key");--> statement-breakpoint
|
||||
CREATE INDEX "hierarchy_outbox_status_created_idx" ON "hierarchy_outbox" USING btree ("status","created_at");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -127,20 +127,6 @@
|
||||
"when": 1787609223282,
|
||||
"tag": "0017_accounts_issuer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1787862158838,
|
||||
"tag": "0018_clean_cobalt_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1787880918208,
|
||||
"tag": "0019_volatile_killraven",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
/**
|
||||
* Hierarchy audit event + outbox schema witnesses — contract 1 §5.2 and the
|
||||
* schema-level half of §6.4.
|
||||
*
|
||||
* Witnesses the guarantees the tables themselves carry: verb/target-kind/
|
||||
* transfer CHECK constraints, idempotency uniqueness (REQ-AUD-001 duplicate
|
||||
* suppression at the database level), monotonic append order (`seq`),
|
||||
* deletion-safe linkage (no foreign key from the events table into any class
|
||||
* table — events survive the deletion of their target), the causation
|
||||
* self-FK, and the outbox's FK/uniqueness/status shape. The repository-level
|
||||
* half of §6.4 (same-transaction atomicity, rollback, replay) is witnessed in
|
||||
* apps/gateway/src/hierarchy/hierarchy-audit.integration.test.ts.
|
||||
*
|
||||
* Two legs run the same witness body:
|
||||
* - PGlite (WASM Postgres): always runs.
|
||||
* - Real PostgreSQL (§6.8): runs when DATABASE_URL is set — the binding
|
||||
* witness; CI migrates ci-postgres before `pnpm test`.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createDb } from './client.js';
|
||||
import { createPgliteDb } from './client-pglite.js';
|
||||
import { runPgliteMigrations } from './migrate.js';
|
||||
import { companies, hierarchyAuditEvents, hierarchyOutbox } from './schema.js';
|
||||
|
||||
type AnyDb = {
|
||||
db: {
|
||||
insert: (t: unknown) => { values: (v: unknown) => Promise<unknown> };
|
||||
execute: (q: unknown) => Promise<{ rows?: unknown[] } | unknown[]>;
|
||||
};
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Match a constraint failure anywhere along drizzle's cause chain. */
|
||||
async function expectViolation(p: Promise<unknown>, re: RegExp, label = ''): Promise<void> {
|
||||
let err: unknown;
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err, label || 'expected the statement to be refused').toBeDefined();
|
||||
const messages: string[] = [];
|
||||
let cur: unknown = err;
|
||||
while (cur instanceof Error) {
|
||||
messages.push(cur.message);
|
||||
cur = (cur as { cause?: unknown }).cause;
|
||||
}
|
||||
expect(messages.join(' | '), label).toMatch(re);
|
||||
}
|
||||
|
||||
function rows(res: { rows?: unknown[] } | unknown[]): Record<string, unknown>[] {
|
||||
return (Array.isArray(res) ? res : (res.rows ?? [])) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/** Unique per-run prefix so real-PG runs never collide and clean up safely. */
|
||||
const T = `hier-a-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
/** Minimal valid event row; overrides compose the negative cases. */
|
||||
type EventInsert = typeof hierarchyAuditEvents.$inferInsert;
|
||||
|
||||
function eventRow(overrides: Partial<EventInsert> = {}): EventInsert {
|
||||
return {
|
||||
actorId: `${T}-actor`,
|
||||
verb: 'create',
|
||||
targetKind: 'company',
|
||||
targetId: randomUUID(),
|
||||
targetSnapshot: { id: 'x', slug: 'x', name: 'x', parentChain: [] },
|
||||
correlationId: `${T}-corr`,
|
||||
idempotencyKey: `${T}-${randomUUID()}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function witnessSuite(getHandle: () => AnyDb): void {
|
||||
const db = () => getHandle().db as unknown as ReturnType<typeof createDb>['db'];
|
||||
|
||||
afterAll(async () => {
|
||||
const d = db();
|
||||
await d.execute(sql`DELETE FROM hierarchy_outbox WHERE idempotency_key LIKE ${T + '%'}`);
|
||||
// Caused events first: the causation self-FK is RESTRICT.
|
||||
await d.execute(
|
||||
sql`DELETE FROM hierarchy_audit_events WHERE idempotency_key LIKE ${T + '%'} AND causation_id IS NOT NULL`,
|
||||
);
|
||||
await d.execute(sql`DELETE FROM hierarchy_audit_events WHERE idempotency_key LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM companies WHERE slug LIKE ${T + '%'}`);
|
||||
});
|
||||
|
||||
// ── CHECK constraints ──────────────────────────────────────────────────────
|
||||
|
||||
it('accepts every declared verb and refuses an undeclared one', async () => {
|
||||
for (const verb of [
|
||||
'create',
|
||||
'rename',
|
||||
'delete',
|
||||
'grant_create',
|
||||
'grant_change',
|
||||
'grant_revoke',
|
||||
]) {
|
||||
await db().insert(hierarchyAuditEvents).values(eventRow({ verb }));
|
||||
}
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ verb: 'update' })),
|
||||
/verb_check|violates check/i,
|
||||
'undeclared verb must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses an undeclared target kind', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ targetKind: 'workspace' })),
|
||||
/target_kind_check|violates check/i,
|
||||
'workspace is not an audited target kind (workspace mutation is SOT-side)',
|
||||
);
|
||||
});
|
||||
|
||||
it('requires transfer snapshots exactly on transfers', async () => {
|
||||
const parent = { kind: 'company', id: randomUUID(), slug: 'p' };
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(
|
||||
eventRow({
|
||||
verb: 'transfer',
|
||||
targetKind: 'estate',
|
||||
transferFrom: parent,
|
||||
transferTo: { ...parent, id: randomUUID() },
|
||||
}),
|
||||
);
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ verb: 'transfer' })),
|
||||
/transfer_check|violates check/i,
|
||||
'transfer without source/destination snapshots must be refused',
|
||||
);
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ verb: 'transfer', transferFrom: parent })),
|
||||
/transfer_check|violates check/i,
|
||||
'transfer with only the source snapshot must be refused',
|
||||
);
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ verb: 'create', transferFrom: parent, transferTo: parent })),
|
||||
/transfer_check|violates check/i,
|
||||
'non-transfer with transfer snapshots must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Idempotency and ordering ───────────────────────────────────────────────
|
||||
|
||||
it('refuses a duplicate idempotency key', async () => {
|
||||
const key = `${T}-dup-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ idempotencyKey: key }));
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ idempotencyKey: key })),
|
||||
/duplicate key|unique/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('assigns strictly increasing seq in insert order for one target', async () => {
|
||||
const targetId = randomUUID();
|
||||
const k1 = `${T}-seq-1-${randomUUID()}`;
|
||||
const k2 = `${T}-seq-2-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ targetId, idempotencyKey: k1 }));
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ targetId, verb: 'rename', idempotencyKey: k2 }));
|
||||
const res = rows(
|
||||
await db().execute(
|
||||
sql`SELECT idempotency_key, seq FROM hierarchy_audit_events WHERE target_id = ${targetId} ORDER BY seq ASC`,
|
||||
),
|
||||
);
|
||||
expect(res.map((r) => r['idempotency_key'])).toEqual([k1, k2]);
|
||||
expect(Number(res[1]!['seq'])).toBeGreaterThan(Number(res[0]!['seq']));
|
||||
});
|
||||
|
||||
// ── Deletion-safe linkage (§5.2) ───────────────────────────────────────────
|
||||
|
||||
it('has no foreign key into any class table, and events survive target deletion', async () => {
|
||||
const fks = rows(
|
||||
await db().execute(sql`
|
||||
SELECT ccu.table_name AS referenced_table
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'hierarchy_audit_events'
|
||||
`),
|
||||
);
|
||||
// The causation self-FK is the ONLY foreign key on the events table.
|
||||
expect([...new Set(fks.map((r) => r['referenced_table']))]).toEqual(['hierarchy_audit_events']);
|
||||
|
||||
const companyId = randomUUID();
|
||||
await db()
|
||||
.insert(companies)
|
||||
.values({ id: companyId, name: 'Doomed', slug: `${T}-doomed` });
|
||||
const key = `${T}-survive-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(
|
||||
eventRow({
|
||||
verb: 'delete',
|
||||
targetId: companyId,
|
||||
targetSnapshot: { id: companyId, slug: `${T}-doomed`, name: 'Doomed', parentChain: [] },
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
await db().execute(sql`DELETE FROM companies WHERE id = ${companyId}`);
|
||||
const after = rows(
|
||||
await db().execute(
|
||||
sql`SELECT target_snapshot FROM hierarchy_audit_events WHERE idempotency_key = ${key}`,
|
||||
),
|
||||
);
|
||||
expect(after).toHaveLength(1);
|
||||
expect((after[0]!['target_snapshot'] as { id: string }).id).toBe(companyId);
|
||||
});
|
||||
|
||||
it('enforces the causation self-FK and RESTRICTs deleting a cause', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ causationId: randomUUID() })),
|
||||
/foreign key/i,
|
||||
'causation must reference an existing event',
|
||||
);
|
||||
const causeKey = `${T}-cause-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ verb: 'delete', idempotencyKey: causeKey }));
|
||||
const cause = rows(
|
||||
await db().execute(
|
||||
sql`SELECT id FROM hierarchy_audit_events WHERE idempotency_key = ${causeKey}`,
|
||||
),
|
||||
)[0]!;
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(
|
||||
eventRow({ verb: 'grant_revoke', targetKind: 'grant', causationId: cause['id'] as string }),
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM hierarchy_audit_events WHERE id = ${cause['id'] as string}`),
|
||||
/foreign key/i,
|
||||
'a cause with dependent events must not be deletable',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Outbox shape ───────────────────────────────────────────────────────────
|
||||
|
||||
it('outbox rows require an existing event, one outbox row per event, unique idempotency', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyOutbox)
|
||||
.values({
|
||||
eventId: randomUUID(),
|
||||
idempotencyKey: `${T}-ob-${randomUUID()}`,
|
||||
correlationId: `${T}-corr`,
|
||||
}),
|
||||
/foreign key/i,
|
||||
'outbox must reference an existing event',
|
||||
);
|
||||
const key = `${T}-ob-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ idempotencyKey: key }));
|
||||
const event = rows(
|
||||
await db().execute(sql`SELECT id FROM hierarchy_audit_events WHERE idempotency_key = ${key}`),
|
||||
)[0]!;
|
||||
const eventId = event['id'] as string;
|
||||
await db()
|
||||
.insert(hierarchyOutbox)
|
||||
.values({ eventId, idempotencyKey: key, correlationId: `${T}-corr` });
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyOutbox)
|
||||
.values({ eventId, idempotencyKey: `${T}-ob2-${randomUUID()}`, correlationId: `${T}-c` }),
|
||||
/duplicate key|unique/i,
|
||||
'one outbox record per event',
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO hierarchy_outbox (event_id, idempotency_key, correlation_id, status)
|
||||
VALUES (${eventId}, ${`${T}-ob3-${randomUUID()}`}, 'c', 'failed')`,
|
||||
),
|
||||
/invalid input value for enum|22P02/i,
|
||||
'status outside pending/processing/delivered must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
it('outbox FK RESTRICTs event deletion while the outbox row exists', async () => {
|
||||
const key = `${T}-obr-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ idempotencyKey: key }));
|
||||
const event = rows(
|
||||
await db().execute(sql`SELECT id FROM hierarchy_audit_events WHERE idempotency_key = ${key}`),
|
||||
)[0]!;
|
||||
await db()
|
||||
.insert(hierarchyOutbox)
|
||||
.values({
|
||||
eventId: event['id'] as string,
|
||||
idempotencyKey: key,
|
||||
correlationId: `${T}-corr`,
|
||||
});
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM hierarchy_audit_events WHERE id = ${event['id'] as string}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Leg 1: PGlite (always runs — local witness signal) ───────────────────────
|
||||
|
||||
describe('hierarchy audit witnesses — PGlite', () => {
|
||||
let dir: string;
|
||||
let handle: ReturnType<typeof createPgliteDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'hier-audit-witness-'));
|
||||
handle = createPgliteDb(dir);
|
||||
await runPgliteMigrations(handle);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
|
||||
// ── Leg 2: real PostgreSQL (§6.8 — binding witness, ci-postgres in CI) ───────
|
||||
|
||||
const hasPostgres = Boolean(process.env['DATABASE_URL']);
|
||||
|
||||
describe.skipIf(!hasPostgres)('hierarchy audit witnesses — real PostgreSQL', () => {
|
||||
let handle: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(() => {
|
||||
handle = createDb(process.env['DATABASE_URL']!);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
@@ -1,501 +0,0 @@
|
||||
/**
|
||||
* Hierarchy schema witnesses — contract 1 (docs/requirements/hierarchy-schema.md) §6.
|
||||
*
|
||||
* Witnesses §6.1 (chain construction, slug scoping, grant CHECKs, grant
|
||||
* uniqueness, NOT NULLs), §6.2 (column allowlist), the database-level parts of
|
||||
* §6.6 (RESTRICT/cascade deletion behavior), and §6.7's catalog half (no
|
||||
* foreign keys from outside the class into class tables).
|
||||
*
|
||||
* Two legs run the same witness body:
|
||||
* - PGlite (WASM Postgres): always runs, so the witnesses execute locally
|
||||
* with no database configured.
|
||||
* - Real PostgreSQL (§6.8): runs when DATABASE_URL is set — in CI that is
|
||||
* the ci-postgres service, migrated by the pipeline before `pnpm test`.
|
||||
* This leg is the contract's binding witness; the PGlite leg is the local
|
||||
* development signal.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createDb } from './client.js';
|
||||
import { createPgliteDb } from './client-pglite.js';
|
||||
import { runPgliteMigrations } from './migrate.js';
|
||||
import {
|
||||
companies,
|
||||
estates,
|
||||
hierarchyGrants,
|
||||
platformProjects,
|
||||
workspaces,
|
||||
teams,
|
||||
users,
|
||||
} from './schema.js';
|
||||
|
||||
type AnyDb = {
|
||||
db: {
|
||||
insert: (t: unknown) => { values: (v: unknown) => Promise<unknown> };
|
||||
delete: (t: unknown) => { where?: unknown } & PromiseLike<unknown>;
|
||||
execute: (q: unknown) => Promise<{ rows?: unknown[] } | unknown[]>;
|
||||
};
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Column allowlist — the exact declared sets of §2/§3. Nothing else. */
|
||||
const COLUMN_ALLOWLIST: Record<string, string[]> = {
|
||||
companies: ['id', 'name', 'slug', 'created_at', 'updated_at'],
|
||||
estates: ['id', 'name', 'slug', 'company_id'],
|
||||
platform_projects: ['id', 'name', 'slug', 'estate_id'],
|
||||
workspaces: ['id', 'name', 'slug', 'platform_project_id'],
|
||||
hierarchy_grants: [
|
||||
'id',
|
||||
'user_id',
|
||||
'team_id',
|
||||
'company_id',
|
||||
'estate_id',
|
||||
'platform_project_id',
|
||||
'role',
|
||||
'granted_by',
|
||||
'created_at',
|
||||
],
|
||||
};
|
||||
|
||||
const NODE_TABLES = ['companies', 'estates', 'platform_projects', 'workspaces'];
|
||||
const CLASS_TABLES = [...NODE_TABLES, 'hierarchy_grants'];
|
||||
|
||||
/**
|
||||
* Drizzle wraps constraint failures ("Failed query: ...") with the driver
|
||||
* error attached as `cause`. Match the pattern anywhere along the cause chain.
|
||||
*/
|
||||
async function expectViolation(p: Promise<unknown>, re: RegExp, label = ''): Promise<void> {
|
||||
let err: unknown;
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err, label || 'expected the statement to be refused').toBeDefined();
|
||||
const messages: string[] = [];
|
||||
let cur: unknown = err;
|
||||
while (cur instanceof Error) {
|
||||
messages.push(cur.message);
|
||||
cur = (cur as { cause?: unknown }).cause;
|
||||
}
|
||||
expect(messages.join(' | '), label).toMatch(re);
|
||||
}
|
||||
|
||||
function rows(res: { rows?: unknown[] } | unknown[]): Record<string, unknown>[] {
|
||||
return (Array.isArray(res) ? res : (res.rows ?? [])) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/** Unique per-run prefix so real-PG runs never collide and clean up safely. */
|
||||
const T = `hier-w-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
function witnessSuite(getHandle: () => AnyDb): void {
|
||||
const db = () => getHandle().db as unknown as ReturnType<typeof createDb>['db'];
|
||||
|
||||
const userA = `${T}-user-a`;
|
||||
const userB = `${T}-user-b`;
|
||||
let teamId: string;
|
||||
let companyId: string;
|
||||
let company2Id: string;
|
||||
let estateId: string;
|
||||
let estate2Id: string;
|
||||
let ppId: string;
|
||||
let workspaceId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await db()
|
||||
.insert(users)
|
||||
.values([
|
||||
{ id: userA, name: 'Witness A', email: `${userA}@example.com` },
|
||||
{ id: userB, name: 'Witness B', email: `${userB}@example.com` },
|
||||
]);
|
||||
teamId = randomUUID();
|
||||
await db()
|
||||
.insert(teams)
|
||||
.values({
|
||||
id: teamId,
|
||||
name: `${T}-team`,
|
||||
slug: `${T}-team`,
|
||||
ownerId: userA,
|
||||
managerId: userA,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Bottom-up, fail-closed order; grants cascade with their targets.
|
||||
const d = db();
|
||||
await d.execute(sql`DELETE FROM hierarchy_grants WHERE granted_by LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM workspaces WHERE slug LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM platform_projects WHERE slug LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM estates WHERE slug LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM companies WHERE slug LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM teams WHERE slug LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM users WHERE id LIKE ${T + '%'}`);
|
||||
});
|
||||
|
||||
// ── §6.1 chain construction ────────────────────────────────────────────────
|
||||
|
||||
it('accepts a full valid chain: company → estate → platform-project → workspace', async () => {
|
||||
companyId = randomUUID();
|
||||
estateId = randomUUID();
|
||||
ppId = randomUUID();
|
||||
workspaceId = randomUUID();
|
||||
await db()
|
||||
.insert(companies)
|
||||
.values({ id: companyId, name: 'Acme', slug: `${T}-acme` });
|
||||
await db()
|
||||
.insert(estates)
|
||||
.values({ id: estateId, name: 'Estate 1', slug: `${T}-e1`, companyId });
|
||||
await db()
|
||||
.insert(platformProjects)
|
||||
.values({ id: ppId, name: 'PP 1', slug: `${T}-pp1`, estateId });
|
||||
await db()
|
||||
.insert(workspaces)
|
||||
.values({ id: workspaceId, name: 'WS 1', slug: `${T}-ws1`, platformProjectId: ppId });
|
||||
});
|
||||
|
||||
it('accepts two siblings under one parent (the §2.5 control)', async () => {
|
||||
estate2Id = randomUUID();
|
||||
await db()
|
||||
.insert(estates)
|
||||
.values({ id: estate2Id, name: 'Estate 2', slug: `${T}-e2`, companyId });
|
||||
});
|
||||
|
||||
it('refuses inserts with a null parent FK', async () => {
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO estates (id, name, slug, company_id) VALUES (${randomUUID()}, 'x', ${T + '-null-e'}, NULL)`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO platform_projects (id, name, slug, estate_id) VALUES (${randomUUID()}, 'x', ${T + '-null-p'}, NULL)`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO workspaces (id, name, slug, platform_project_id) VALUES (${randomUUID()}, 'x', ${T + '-null-w'}, NULL)`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses inserts with a dangling parent FK', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(estates)
|
||||
.values({ id: randomUUID(), name: 'x', slug: `${T}-dangle`, companyId: randomUUID() }),
|
||||
/foreign key/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('catalog: each child table has exactly one parent-FK column and no parentage edge table exists', async () => {
|
||||
const res = rows(
|
||||
await db().execute(sql`
|
||||
SELECT tc.table_name, kcu.column_name, ccu.table_name AS ref_table
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'
|
||||
`),
|
||||
);
|
||||
const nodeSet = new Set(NODE_TABLES);
|
||||
// Exactly one parent FK per child node table.
|
||||
for (const [child, parent] of [
|
||||
['estates', 'companies'],
|
||||
['platform_projects', 'estates'],
|
||||
['workspaces', 'platform_projects'],
|
||||
] as const) {
|
||||
const parentFks = res.filter(
|
||||
(r) => r['table_name'] === child && nodeSet.has(String(r['ref_table'])),
|
||||
);
|
||||
expect(parentFks.map((r) => `${r['column_name']}->${r['ref_table']}`)).toEqual([
|
||||
`${{ estates: 'company_id', platform_projects: 'estate_id', workspaces: 'platform_project_id' }[child]}->${parent}`,
|
||||
]);
|
||||
}
|
||||
// No table outside the class references a node table (also §6.7's catalog
|
||||
// half for companies/estates/platform_projects/workspaces), and the only
|
||||
// multi-FK referencer is hierarchy_grants (grant attachment, not
|
||||
// parentage).
|
||||
const referencers = new Map<string, number>();
|
||||
for (const r of res) {
|
||||
if (nodeSet.has(String(r['ref_table']))) {
|
||||
const t = String(r['table_name']);
|
||||
referencers.set(t, (referencers.get(t) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
for (const [table, count] of referencers) {
|
||||
expect(CLASS_TABLES, `unexpected referencer of a node table: ${table}`).toContain(table);
|
||||
if (count > 1) expect(table).toBe('hierarchy_grants');
|
||||
}
|
||||
// No FK anywhere references hierarchy_grants.
|
||||
expect(res.filter((r) => r['ref_table'] === 'hierarchy_grants')).toEqual([]);
|
||||
});
|
||||
|
||||
// ── §6.1 slug scoping ──────────────────────────────────────────────────────
|
||||
|
||||
it('refuses a duplicate slug under the same parent, accepts it under another parent', async () => {
|
||||
company2Id = randomUUID();
|
||||
await db()
|
||||
.insert(companies)
|
||||
.values({ id: company2Id, name: 'Beta', slug: `${T}-beta` });
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(estates)
|
||||
.values({ id: randomUUID(), name: 'dup', slug: `${T}-e1`, companyId }),
|
||||
/duplicate key|unique/i,
|
||||
);
|
||||
// Same slug, different company — accepted.
|
||||
await db()
|
||||
.insert(estates)
|
||||
.values({ id: randomUUID(), name: 'ok', slug: `${T}-e1`, companyId: company2Id });
|
||||
// companies.slug is unique per deployment.
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(companies)
|
||||
.values({ id: randomUUID(), name: 'dup', slug: `${T}-acme` }),
|
||||
/duplicate key|unique/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('scopes platform_projects and workspaces slugs per parent (refuse same-parent duplicate, accept cross-parent)', async () => {
|
||||
// Dedicated parent estate so this test leaves estate2 a leaf (the §3.4
|
||||
// cascade witness depends on that).
|
||||
const estate3Id = randomUUID();
|
||||
await db()
|
||||
.insert(estates)
|
||||
.values({ id: estate3Id, name: 'Estate 3', slug: `${T}-e3`, companyId });
|
||||
// platform_projects: (estate_id, slug) unique.
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(platformProjects)
|
||||
.values({ id: randomUUID(), name: 'dup', slug: `${T}-pp1`, estateId }),
|
||||
/duplicate key|unique/i,
|
||||
);
|
||||
const pp2Id = randomUUID();
|
||||
await db()
|
||||
.insert(platformProjects)
|
||||
.values({ id: pp2Id, name: 'ok', slug: `${T}-pp1`, estateId: estate3Id });
|
||||
// workspaces: (platform_project_id, slug) unique.
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(workspaces)
|
||||
.values({ id: randomUUID(), name: 'dup', slug: `${T}-ws1`, platformProjectId: ppId }),
|
||||
/duplicate key|unique/i,
|
||||
);
|
||||
await db()
|
||||
.insert(workspaces)
|
||||
.values({ id: randomUUID(), name: 'ok', slug: `${T}-ws1`, platformProjectId: pp2Id });
|
||||
});
|
||||
|
||||
// ── §6.2 column allowlist ──────────────────────────────────────────────────
|
||||
|
||||
it('column allowlist: each class table has exactly its declared columns (no payload, no owner_id)', async () => {
|
||||
for (const [table, allow] of Object.entries(COLUMN_ALLOWLIST)) {
|
||||
const res = rows(
|
||||
await db().execute(
|
||||
sql`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ${table}`,
|
||||
),
|
||||
);
|
||||
const actual = res.map((r) => String(r['column_name'])).sort();
|
||||
expect(actual, `column set of ${table}`).toEqual([...allow].sort());
|
||||
}
|
||||
});
|
||||
|
||||
// ── §6.1 grant CHECKs ──────────────────────────────────────────────────────
|
||||
|
||||
it('accepts one valid grant per subject×target form', async () => {
|
||||
// All six forms; also the base rows for the §6.1 uniqueness witness below.
|
||||
const forms = [
|
||||
{ userId: userA, companyId },
|
||||
{ userId: userA, estateId },
|
||||
{ userId: userA, platformProjectId: ppId },
|
||||
{ teamId, companyId },
|
||||
{ teamId, estateId },
|
||||
{ teamId, platformProjectId: ppId },
|
||||
];
|
||||
for (const form of forms) {
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ ...form, role: 'owner', grantedBy: userA });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a grant with zero or two subjects (exactly-one-of CHECK)', async () => {
|
||||
await expectViolation(
|
||||
db().insert(hierarchyGrants).values({ companyId, role: 'viewer', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userA, teamId, companyId, role: 'viewer', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a grant with zero or two targets (exactly-one-of CHECK)', async () => {
|
||||
await expectViolation(
|
||||
db().insert(hierarchyGrants).values({ userId: userA, role: 'viewer', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userA, companyId, estateId, role: 'viewer', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
});
|
||||
|
||||
// ── §6.1 grant uniqueness (NULLS NOT DISTINCT) ─────────────────────────────
|
||||
|
||||
it('refuses a duplicate (subject, target, role) for each of the six forms', async () => {
|
||||
const forms = [
|
||||
{ userId: userA, companyId },
|
||||
{ userId: userA, estateId },
|
||||
{ userId: userA, platformProjectId: ppId },
|
||||
{ teamId, companyId },
|
||||
{ teamId, estateId },
|
||||
{ teamId, platformProjectId: ppId },
|
||||
];
|
||||
for (const form of forms) {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ ...form, role: 'owner', grantedBy: userB }),
|
||||
/duplicate key|unique/i,
|
||||
`duplicate form ${JSON.stringify(form)} must be refused`,
|
||||
);
|
||||
}
|
||||
// Control: same subject and target with a different role is a new grant.
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userA, companyId, role: `${T}-other-role`, grantedBy: userA });
|
||||
});
|
||||
|
||||
// ── §6.1 NOT NULLs ─────────────────────────────────────────────────────────
|
||||
|
||||
it('refuses null role, granted_by, and null name/slug columns', async () => {
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, NULL, ${userA})`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, 'x', NULL)`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`INSERT INTO companies (name, slug) VALUES (NULL, ${T + '-nn'})`),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`INSERT INTO companies (name, slug) VALUES ('x', NULL)`),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO estates (name, slug, company_id) VALUES ('x', NULL, ${companyId})`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
});
|
||||
|
||||
// ── §6.6 deletion (database-level witnesses) ───────────────────────────────
|
||||
|
||||
it('refuses deleting a node with children (fail-closed bottom-up)', async () => {
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM companies WHERE id = ${companyId}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM estates WHERE id = ${estateId}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM platform_projects WHERE id = ${ppId}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('cascades a deleted leaf node’s grants and nothing else', async () => {
|
||||
// estate2 is a leaf (no platform-projects). Attach one grant to it.
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userB, estateId: estate2Id, role: 'viewer', grantedBy: userA });
|
||||
const grantCount = async () =>
|
||||
Number(
|
||||
rows(
|
||||
await db().execute(
|
||||
sql`SELECT count(*)::int AS n FROM hierarchy_grants WHERE granted_by LIKE ${T + '%'}`,
|
||||
),
|
||||
)[0]!['n'],
|
||||
);
|
||||
const before = await grantCount();
|
||||
await db().execute(sql`DELETE FROM estates WHERE id = ${estate2Id}`);
|
||||
// Exactly the one grant on the deleted estate is gone.
|
||||
expect(await grantCount()).toBe(before - 1);
|
||||
});
|
||||
|
||||
it('refuses deleting a user or team that is a grant subject or granted_by referent (RESTRICT)', async () => {
|
||||
await expectViolation(db().execute(sql`DELETE FROM users WHERE id = ${userA}`), /foreign key/i);
|
||||
// userB is only a subject (its estate2 grant cascaded away above, but it
|
||||
// still holds no grants — re-create one to witness subject RESTRICT).
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userB, companyId: company2Id, role: 'viewer', grantedBy: userA });
|
||||
await expectViolation(db().execute(sql`DELETE FROM users WHERE id = ${userB}`), /foreign key/i);
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM teams WHERE id = ${teamId}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Leg 1: PGlite (always runs — local witness signal) ───────────────────────
|
||||
|
||||
describe('hierarchy schema witnesses — PGlite', () => {
|
||||
let dir: string;
|
||||
let handle: ReturnType<typeof createPgliteDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'hier-witness-'));
|
||||
handle = createPgliteDb(dir);
|
||||
await runPgliteMigrations(handle);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
|
||||
// ── Leg 2: real PostgreSQL (§6.8 — binding witness, ci-postgres in CI) ───────
|
||||
|
||||
const hasPostgres = Boolean(process.env['DATABASE_URL']);
|
||||
|
||||
describe.skipIf(!hasPostgres)('hierarchy schema witnesses — real PostgreSQL', () => {
|
||||
let handle: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(() => {
|
||||
handle = createDb(process.env['DATABASE_URL']!);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,6 @@
|
||||
* drizzle-kit reads this file directly (avoids CJS/ESM extension issues).
|
||||
*/
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
pgTable,
|
||||
pgEnum,
|
||||
@@ -15,8 +13,6 @@ import {
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
unique,
|
||||
check,
|
||||
real,
|
||||
integer,
|
||||
bigint,
|
||||
@@ -1052,212 +1048,3 @@ export const federationEnrollmentTokens = pgTable('federation_enrollment_tokens'
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// ─── Hierarchy (tenancy/authorization structure record class) ────────────────
|
||||
// Contract: docs/requirements/hierarchy-schema.md (D2, ratified 2026-08-27).
|
||||
// Five tables: companies → estates → platform_projects → workspaces, plus
|
||||
// hierarchy_grants. Class rows carry parentage, naming, grant, and
|
||||
// audit-linkage data only — the column sets below are exhaustive (§2.7) and
|
||||
// witnessed against information_schema (§6.2). No owner_id: ownership is the
|
||||
// grant structure (§4.4). All writes flow through the Gateway hierarchy
|
||||
// command family only (§5.1), enforced by the writer-coverage assertion
|
||||
// (§6.3b) — do not add writers outside that allowlist.
|
||||
|
||||
export const companies = pgTable('companies', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const estates = pgTable(
|
||||
'estates',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'restrict' }),
|
||||
},
|
||||
(t) => [unique('estates_company_slug_uniq').on(t.companyId, t.slug)],
|
||||
);
|
||||
|
||||
export const platformProjects = pgTable(
|
||||
'platform_projects',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
estateId: uuid('estate_id')
|
||||
.notNull()
|
||||
.references(() => estates.id, { onDelete: 'restrict' }),
|
||||
},
|
||||
(t) => [unique('platform_projects_estate_slug_uniq').on(t.estateId, t.slug)],
|
||||
);
|
||||
|
||||
export const workspaces = pgTable(
|
||||
'workspaces',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
platformProjectId: uuid('platform_project_id')
|
||||
.notNull()
|
||||
.references(() => platformProjects.id, { onDelete: 'restrict' }),
|
||||
},
|
||||
(t) => [unique('workspaces_platform_project_slug_uniq').on(t.platformProjectId, t.slug)],
|
||||
);
|
||||
|
||||
export const hierarchyGrants = pgTable(
|
||||
'hierarchy_grants',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
// Subject: exactly one of user/team (CHECK below). Principal FKs are
|
||||
// RESTRICT until a deletion-and-retention contract rules otherwise (§3.3).
|
||||
userId: text('user_id').references(() => users.id, { onDelete: 'restrict' }),
|
||||
teamId: uuid('team_id').references(() => teams.id, { onDelete: 'restrict' }),
|
||||
// Target: exactly one of the three grantable levels (CHECK below).
|
||||
// Target FKs CASCADE — the one permitted cascade in the class (§3.3);
|
||||
// cascaded grant deletions are audited by the command family (§5.2).
|
||||
companyId: uuid('company_id').references(() => companies.id, { onDelete: 'cascade' }),
|
||||
estateId: uuid('estate_id').references(() => estates.id, { onDelete: 'cascade' }),
|
||||
platformProjectId: uuid('platform_project_id').references(() => platformProjects.id, {
|
||||
onDelete: 'cascade',
|
||||
}),
|
||||
// Role vocabulary and its CHECK constraint are contract 2 §2 (M4-2).
|
||||
role: text('role').notNull(),
|
||||
grantedBy: text('granted_by')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'restrict' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
check('hierarchy_grants_subject_check', sql`num_nonnulls(user_id, team_id) = 1`),
|
||||
check(
|
||||
'hierarchy_grants_target_check',
|
||||
sql`num_nonnulls(company_id, estate_id, platform_project_id) = 1`,
|
||||
),
|
||||
// At most one grant per (subject, target, role) across all six
|
||||
// subject×target forms — NULLS NOT DISTINCT so nullable columns
|
||||
// participate (§3.2).
|
||||
unique('hierarchy_grants_subject_target_role_uniq')
|
||||
.on(t.userId, t.teamId, t.companyId, t.estateId, t.platformProjectId, t.role)
|
||||
.nullsNotDistinct(),
|
||||
index('hierarchy_grants_company_id_idx').on(t.companyId),
|
||||
index('hierarchy_grants_estate_id_idx').on(t.estateId),
|
||||
index('hierarchy_grants_platform_project_id_idx').on(t.platformProjectId),
|
||||
index('hierarchy_grants_user_id_idx').on(t.userId),
|
||||
index('hierarchy_grants_team_id_idx').on(t.teamId),
|
||||
index('hierarchy_grants_granted_by_idx').on(t.grantedBy),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Hierarchy audit events + outbox (contract 1 §5.2) ──────────────────────
|
||||
// NOT part of the record class (the class is exactly the five tables above).
|
||||
// Append-only semantic audit log for hierarchy mutations, with a dedicated
|
||||
// transactional outbox — hierarchy events are not workspace-scoped rows and
|
||||
// do not ride the workspace outbox. Deletion-safe linkage: events reference
|
||||
// their target by an immutable snapshot (id, slug, parent chain at event
|
||||
// time), never by a foreign key into the class tables, so append-only events
|
||||
// survive the deletion of their target. Append-only is enforced at the
|
||||
// application layer (the hierarchy audit repository exposes no update/delete
|
||||
// path for events); REQ-AUD-001's INSERT/SELECT-only database role is a
|
||||
// deployment concern outside this schema.
|
||||
|
||||
export const HIERARCHY_AUDIT_VERBS = [
|
||||
'create',
|
||||
'rename',
|
||||
'transfer',
|
||||
'delete',
|
||||
'grant_create',
|
||||
'grant_change',
|
||||
'grant_revoke',
|
||||
] as const;
|
||||
|
||||
export const HIERARCHY_AUDIT_TARGET_KINDS = [
|
||||
'company',
|
||||
'estate',
|
||||
'platform_project',
|
||||
'grant',
|
||||
] as const;
|
||||
|
||||
export const hierarchyAuditEvents = pgTable(
|
||||
'hierarchy_audit_events',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
// Global append order; per-target ordering (REQ-AUD-001) is a filter on
|
||||
// target_id ordered by seq.
|
||||
seq: bigint('seq', { mode: 'number' }).notNull().generatedAlwaysAsIdentity(),
|
||||
// No FK: audit events outlive every principal and every target (§5.2).
|
||||
actorId: text('actor_id').notNull(),
|
||||
verb: text('verb').notNull(),
|
||||
targetKind: text('target_kind').notNull(),
|
||||
targetId: uuid('target_id').notNull(),
|
||||
// Immutable snapshot at event time. Node events: { id, slug, name,
|
||||
// parentChain: [{ kind, id, slug }, …] root-first }. Grant events:
|
||||
// { id, subject: { userId | teamId }, target: { kind, id }, role }
|
||||
// (subject and role per contract 2 §4.4).
|
||||
targetSnapshot: jsonb('target_snapshot').notNull(),
|
||||
// Present exactly on transfers: snapshot of the source/destination
|
||||
// parent ({ kind, id, slug }), CHECK-enforced below.
|
||||
transferFrom: jsonb('transfer_from'),
|
||||
transferTo: jsonb('transfer_to'),
|
||||
correlationId: text('correlation_id').notNull(),
|
||||
// Prior event in the causal chain (e.g. cascaded grant_revoke events
|
||||
// caused by a node delete). Self-FK RESTRICT keeps the chain intact.
|
||||
causationId: uuid('causation_id').references((): AnyPgColumn => hierarchyAuditEvents.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
idempotencyKey: text('idempotency_key').notNull(),
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('hierarchy_audit_events_idempotency_idx').on(t.idempotencyKey),
|
||||
uniqueIndex('hierarchy_audit_events_seq_idx').on(t.seq),
|
||||
index('hierarchy_audit_events_target_seq_idx').on(t.targetId, t.seq),
|
||||
index('hierarchy_audit_events_correlation_idx').on(t.correlationId),
|
||||
check(
|
||||
'hierarchy_audit_events_verb_check',
|
||||
sql`verb IN ('create', 'rename', 'transfer', 'delete', 'grant_create', 'grant_change', 'grant_revoke')`,
|
||||
),
|
||||
check(
|
||||
'hierarchy_audit_events_target_kind_check',
|
||||
sql`target_kind IN ('company', 'estate', 'platform_project', 'grant')`,
|
||||
),
|
||||
check(
|
||||
'hierarchy_audit_events_transfer_check',
|
||||
sql`(verb = 'transfer') = (transfer_from IS NOT NULL AND transfer_to IS NOT NULL)`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const hierarchyOutboxStatusEnum = pgEnum('hierarchy_outbox_status', [
|
||||
'pending',
|
||||
'processing',
|
||||
'delivered',
|
||||
]);
|
||||
|
||||
export const hierarchyOutbox = pgTable(
|
||||
'hierarchy_outbox',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
// FK into the append-only events table (not a class table): never
|
||||
// dangles, so RESTRICT is safe and keeps event/outbox integrity.
|
||||
eventId: uuid('event_id')
|
||||
.notNull()
|
||||
.references(() => hierarchyAuditEvents.id, { onDelete: 'restrict' }),
|
||||
idempotencyKey: text('idempotency_key').notNull(),
|
||||
correlationId: text('correlation_id').notNull(),
|
||||
status: hierarchyOutboxStatusEnum('status').notNull().default('pending'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('hierarchy_outbox_event_idx').on(t.eventId),
|
||||
uniqueIndex('hierarchy_outbox_idempotency_idx').on(t.idempotencyKey),
|
||||
index('hierarchy_outbox_status_created_idx').on(t.status, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
|
||||
# Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]
|
||||
# (-c/--comment is a backward-compatible alias for -b/--body; R1, 2026-08-28)
|
||||
# Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]
|
||||
#
|
||||
# tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`);
|
||||
# the non-existent `tea issue comment ...` form does not error — tea silently
|
||||
@@ -33,61 +32,45 @@ ISSUE_NUMBER=""
|
||||
COMMENT=""
|
||||
LOGIN_OVERRIDE=""
|
||||
|
||||
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||
# caller or stop gate can tell an invocation defect from a delivery blocker
|
||||
# (CONSTITUTION gate 8 as amended; E2E-DELIVERY).
|
||||
usage_error() {
|
||||
echo "Error: $*" >&2
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>] (see --help)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-i|--issue)
|
||||
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
||||
ISSUE_NUMBER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-b|--body|-c|--comment)
|
||||
# R1 (2026-08-28): --body is the canonical flag, matching
|
||||
# issue-create/issue-edit/pr-create/pr-edit; -c/--comment stays a
|
||||
# backward-compatible alias.
|
||||
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
||||
-c|--comment)
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--login)
|
||||
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
||||
LOGIN_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]"
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -b, --body Comment text (required; canonical)"
|
||||
echo " -c, --comment Alias for --body"
|
||||
echo " -c, --comment Comment text (required)"
|
||||
echo " -l, --login Override the detected Gitea tea login for this call"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
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
|
||||
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
usage_error "comment is required (-b/--body, or the -c/--comment alias)"
|
||||
echo "Error: Comment is required (-c)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
@@ -357,15 +340,7 @@ PY
|
||||
}
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
# R4 exit-code contract: normalize provider failures to exit 1. gh's own
|
||||
# usage errors exit 2, which would collide with this wrapper's reserved
|
||||
# usage-error status if propagated raw (codex review of 08a00149).
|
||||
gh_rc=0
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
|
||||
if [[ "$gh_rc" -ne 0 ]]; then
|
||||
echo "Error: GitHub comment write failed (gh exit $gh_rc; provider/credential failure — usage errors are exit 2)" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
# A --login override selects a NAMED tea credential and is the only way to
|
||||
|
||||
@@ -42,8 +42,6 @@
|
||||
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
|
||||
# success or the failure path — nested function-scoped RETURN traps do not
|
||||
# clobber each other and every scratch file is removed on all exit paths.
|
||||
# 11. accepts the canonical -b/--body flag exactly like the -c/--comment alias
|
||||
# (R1, 2026-08-28): a full verified write via -b alone.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -411,28 +409,11 @@ run_comment() {
|
||||
seed_state "$mode"
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
# Provisioned seats export MOSAIC_GIT_IDENTITY and MOSAIC_BRAIN_HOME
|
||||
# seat-wide (launcher), and both escape this harness's sandboxed HOME:
|
||||
# detect-platform.sh consults MOSAIC_GIT_IDENTITY BEFORE the repo-local
|
||||
# mosaic.gitIdentity pin, and resolves the brain home (whose
|
||||
# fleet/agents presence arms the no-identity fail-loud branch) from
|
||||
# MOSAIC_BRAIN_HOME before $HOME. Without these explicit empties the
|
||||
# wrapper either resolves the REAL seat-slot token (stub curl rejects
|
||||
# it: the documented HTTP 401) or fails loud before any request.
|
||||
# Set-but-empty reads as unset to detect-platform's "${VAR:-}" forms.
|
||||
# NOTE: keep this comment block ABOVE the assignment chain — a comment
|
||||
# inside a backslash-continued prefix chain terminates the command and
|
||||
# silently demotes every earlier assignment to an unexported subshell
|
||||
# assignment (measured 2026-08-28: the wrapper then ran without
|
||||
# MOSAIC_CREDENTIALS_FILE and the suite died at credential resolution
|
||||
# with zero diagnostic output).
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
TMPDIR="$TMP_SCRATCH" \
|
||||
HOME="$HOME_DIR" \
|
||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_GIT_IDENTITY="" \
|
||||
MOSAIC_BRAIN_HOME="" \
|
||||
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
||||
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
|
||||
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
|
||||
@@ -449,7 +430,7 @@ run_comment() {
|
||||
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
|
||||
ISSUE_COMMENT_API_BASE="$API_BASE" \
|
||||
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
|
||||
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" "${BODY_FLAG:--c}" "$BODY" "$@"
|
||||
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" "$@"
|
||||
) > "$OUTPUT_FILE" 2>&1
|
||||
}
|
||||
|
||||
@@ -633,21 +614,4 @@ done
|
||||
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
|
||||
# is not rejecting genuine writes.
|
||||
|
||||
# Case 11 (R1, 2026-08-28): -b/--body is the canonical comment flag and must
|
||||
# drive a full verified write exactly like the -c/--comment alias. BODY_FLAG
|
||||
# swaps only the flag spelling; every assertion below is case 1's contract.
|
||||
BODY_FLAG="-b"
|
||||
run_comment fresh-success
|
||||
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
|
||||
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
|
||||
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
|
||||
echo "FAIL: --body write went through tea instead of REST" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
|
||||
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
|
||||
assert_no_temp_leak "fresh-success-body-flag"
|
||||
assert_token_not_in_argv "fresh-success-body-flag"
|
||||
unset BODY_FLAG
|
||||
|
||||
echo "issue-comment.sh REST create + exact-id read-back regression passed"
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage-error contract for issue-comment.sh (R1/R4 remediation, 2026-08-28).
|
||||
#
|
||||
# R4: usage errors print to STDERR and exit 2, distinct from provider,
|
||||
# credential, and verification failures (exit 1), so a caller (or a stop gate)
|
||||
# can tell an invocation defect from a delivery blocker. Before this contract
|
||||
# the wrapper exited 1 for usage errors with messages on STDOUT, and a
|
||||
# value-less flag (-c with no value) died SILENTLY at rc=1 because set -e
|
||||
# killed the failed `shift 2`. That silent shape is what full-stopped a fleet
|
||||
# seat: a caller could not distinguish "I invoked it wrong" from "delivery is
|
||||
# blocked".
|
||||
#
|
||||
# R1: -b/--body is the canonical comment flag (matching issue-create,
|
||||
# issue-edit, pr-create, pr-edit); -c/--comment remains a backward-compatible
|
||||
# alias.
|
||||
#
|
||||
# Arms:
|
||||
# 1. --help and -h exit 0 and print usage.
|
||||
# 2. Unknown option exits 2 with the message on stderr.
|
||||
# 3. Missing required -i exits 2 (stderr).
|
||||
# 4. Missing required comment exits 2 (stderr).
|
||||
# 5. A value-less flag (-i -b -c -l and long forms) exits 2 with a
|
||||
# "requires a value" message on stderr (the former silent-death class).
|
||||
# 6. -b and -c both pass parsing (the run then fails at platform detection
|
||||
# in this non-repo fixture, nonzero and NOT 2), proving alias acceptance
|
||||
# without any provider fixture.
|
||||
# 7. No arm performs any provider request: PATH shims for gh/tea/curl
|
||||
# record every invocation and the probe log must stay empty.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-usage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||
OUT_FILE="$WORK_DIR/out.log"
|
||||
ERR_FILE="$WORK_DIR/err.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
: > "$PROBE_LOG"
|
||||
|
||||
# Provider shims: any invocation is recorded and fails the run at the end.
|
||||
# Usage-error arms must exit during argument parsing, before detect_platform,
|
||||
# so these prove "no provider request on parser failure".
|
||||
for tool in gh tea curl; do
|
||||
cat > "$BIN_DIR/$tool" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
echo "$tool \$*" >> "$PROBE_LOG"
|
||||
# gh doubles as platform probe AND write path in arm 6b: probes exit 0; the
|
||||
# comment write exits 2 (gh's own usage-error status) to prove the wrapper
|
||||
# normalizes provider failures to exit 1 instead of propagating 2.
|
||||
if [[ "\$1 \$2" == "issue comment" ]]; then exit 2; fi
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$BIN_DIR/$tool"
|
||||
done
|
||||
|
||||
run_wrapper() {
|
||||
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-comment.sh" "$@" )
|
||||
}
|
||||
|
||||
# Hermetic variant for parse-acceptance arms: neutralizes every identity/
|
||||
# credential source the wrapper consults (seat env vars, HOME, XDG tea config)
|
||||
# so the arm fails at credential resolution in ANY cwd repo, never reading a
|
||||
# real token or contacting a provider. Measured 2026-08-28: without this, the
|
||||
# arm's outcome depended on incidental URL-resolution state (brain cwd died at
|
||||
# URL-not-found; a stack worktree cwd resolved a configured URL, read the real
|
||||
# seat token, and invoked the curl stub — the suite then failed its own
|
||||
# no-provider-contact check, correctly).
|
||||
run_wrapper_sandboxed() {
|
||||
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-comment.sh" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
echo "--- stderr ---" >&2
|
||||
cat "$ERR_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||
local want="$1" desc="$2" rc=0
|
||||
shift 2
|
||||
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||
}
|
||||
|
||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||
grep -q "$1" "$ERR_FILE" || fail "$desc: stderr missing '$1'"
|
||||
}
|
||||
|
||||
# 1. Help exits 0 and prints usage on stdout.
|
||||
expect_rc 0 "--help exits 0" --help
|
||||
grep -q "Usage: issue-comment.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||
expect_rc 0 "-h exits 0" -h
|
||||
|
||||
# 2. Unknown option: rc 2, message on stderr.
|
||||
expect_rc 2 "unknown option exits 2" --bogus
|
||||
expect_stderr "unknown option" "unknown option names itself on stderr"
|
||||
|
||||
# 3. Missing required issue number: rc 2, stderr.
|
||||
expect_rc 2 "missing -i exits 2"
|
||||
expect_stderr "issue number is required" "missing -i message on stderr"
|
||||
|
||||
# 4. Missing required comment: rc 2, stderr.
|
||||
expect_rc 2 "missing comment exits 2" -i 5
|
||||
expect_stderr "comment is required" "missing comment message on stderr"
|
||||
|
||||
# 5. Value-less flags: rc 2 with "requires a value" on stderr. The old parser
|
||||
# died here silently (set -e on the failed shift 2).
|
||||
for flag in -i -b -c -l --issue --body --comment --login; do
|
||||
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||
done
|
||||
|
||||
# 6. Alias acceptance at parse level: both -b and -c carry a value past
|
||||
# parsing; the wrapper then fails at platform detection (not a git repo)
|
||||
# nonzero but NOT as a usage error (rc must not be 2).
|
||||
for flag in -b -c; do
|
||||
rc=0
|
||||
run_wrapper_sandboxed -i 5 "$flag" "some text" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||
done
|
||||
|
||||
# 6b. GitHub-path exit normalization (codex blocker on 08a00149): gh's own
|
||||
# usage errors exit 2; the wrapper must NOT propagate that status (reserved
|
||||
# for the wrapper's usage-error contract). With a github remote and a gh stub
|
||||
# whose comment write exits 2, the wrapper must exit 1 with the normalized
|
||||
# error on stderr.
|
||||
GH_REPO="$WORK_DIR/repo-gh"
|
||||
mkdir -p "$GH_REPO"
|
||||
git -C "$GH_REPO" init -q
|
||||
git -C "$GH_REPO" remote add origin https://github.com/acme/widgets.git
|
||||
git -C "$GH_REPO" config mosaic.gitIdentity ""
|
||||
rc=0
|
||||
(
|
||||
cd "$GH_REPO"
|
||||
PATH="$BIN_DIR:$PATH" MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||
"$SCRIPT_DIR/issue-comment.sh" -i 5 -b "text" >"$OUT_FILE" 2>"$ERR_FILE"
|
||||
) || rc=$?
|
||||
[[ "$rc" -eq 1 ]] || fail "GitHub path: gh exit 2 must normalize to wrapper exit 1 (got $rc)"
|
||||
grep -q "GitHub comment write failed" "$ERR_FILE" || fail "GitHub path: normalized error missing from stderr"
|
||||
grep -q "^gh issue comment" "$PROBE_LOG" || fail "GitHub path: gh write was not invoked"
|
||||
|
||||
# 7. No provider contact from any usage-error arm (arm 6b's deliberate gh
|
||||
# invocation is the only permitted entry in the probe log).
|
||||
if grep -v '^gh issue comment' "$PROBE_LOG" | grep -q .; then
|
||||
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||
grep -v '^gh issue comment' "$PROBE_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "issue-comment.sh usage-contract regression passed (R1/R4)"
|
||||
@@ -14,6 +14,7 @@
|
||||
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
|
||||
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
|
||||
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix
|
||||
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh | resolves real credentials (#1007 census, fifth entry); joins CI after the wrapper-half hermeticity fix
|
||||
|
||||
# --- tools/git: push guards — measured green locally, CI-image fitness unverified ---
|
||||
packages/mosaic/framework/tools/git/test-push-guard.sh | measured green at 826a8b3b (46 passed / 0 failed, one run, 2026-07-31); CI-image fitness unverified; #1017 burndown
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-issue-comment-usage-contract.sh && bash framework/tools/git/test-issue-comment-readback.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -11,9 +11,6 @@ import { registerQualityRails } from '@mosaicstack/quality-rails';
|
||||
import { registerQueueCommand } from '@mosaicstack/queue';
|
||||
import { registerStorageCommand } from '@mosaicstack/storage';
|
||||
import { registerTelemetryCommand } from './commands/telemetry.js';
|
||||
import { registerCommsCommand } from './commands/comms.js';
|
||||
import { registerQCommand } from './commands/q.js';
|
||||
import { registerWatchCommand } from './commands/watch.js';
|
||||
import { registerAgentCommand } from './commands/agent.js';
|
||||
import { registerInteractionCommand } from './commands/interaction.js';
|
||||
import { registerConfigCommand } from './commands/config.js';
|
||||
@@ -431,9 +428,6 @@ registerSkillCommand(program);
|
||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||
|
||||
registerTelemetryCommand(program);
|
||||
registerWatchCommand(program);
|
||||
registerQCommand(program);
|
||||
registerCommsCommand(program);
|
||||
|
||||
// ─── update ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { accessSync, constants } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { resolveBrainHome } from '../fleet/brain-home.js';
|
||||
|
||||
/**
|
||||
* Shared brain-tool dispatch (fleet CLI integration, Jason ruling
|
||||
* 2026-08-28): the npm package embeds the COMMAND SURFACE; operator-owned
|
||||
* implementations stay in the brain (tools/). Commands resolve the brain
|
||||
* home (MOSAIC_BRAIN_HOME wins — see brain-home.ts) and exec the tool
|
||||
* there. Nothing operator-specific ships inside the package.
|
||||
*
|
||||
* Pass-through contract: arguments, stdout/stderr, and the exit code belong
|
||||
* to the tool. The CLI adds nothing on success; absent tools fail loudly
|
||||
* with the resolved path (127) instead of guessing.
|
||||
*/
|
||||
|
||||
/** Absolute path of a brain-relative tool. */
|
||||
export function resolveBrainTool(mosaicHome: string, relPath: string): string {
|
||||
return join(resolveBrainHome(mosaicHome), ...relPath.split('/'));
|
||||
}
|
||||
|
||||
/** Map a spawnSync result + tool existence to the CLI exit status. */
|
||||
export function exitStatusFor(
|
||||
result: { status: number | null; error?: NodeJS.ErrnoException },
|
||||
toolExists: boolean,
|
||||
): number {
|
||||
if (!toolExists) return 127;
|
||||
if (result.status !== null) return result.status;
|
||||
return 125; // killed by signal / could not run
|
||||
}
|
||||
|
||||
export function brainToolExists(tool: string): boolean {
|
||||
try {
|
||||
accessSync(tool, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exec a brain tool with full pass-through. `interpreter` runs the tool
|
||||
* through e.g. python3 (renderers); omit it for executable scripts.
|
||||
* Returns the process exit status; callers assign it to process.exitCode.
|
||||
*/
|
||||
export function execBrainTool(
|
||||
mosaicHome: string,
|
||||
relPath: string,
|
||||
args: string[],
|
||||
interpreter?: string,
|
||||
): number {
|
||||
const tool = resolveBrainTool(mosaicHome, relPath);
|
||||
if (!brainToolExists(tool)) {
|
||||
console.error(
|
||||
`mosaic: brain tool not found (expected ${tool}). ` +
|
||||
'Tool suites live in the brain tree under tools/; ' +
|
||||
'check MOSAIC_BRAIN_HOME or the brain checkout.',
|
||||
);
|
||||
return 127;
|
||||
}
|
||||
const result = interpreter
|
||||
? spawnSync(interpreter, [tool, ...args], { stdio: 'inherit', env: process.env })
|
||||
: spawnSync(tool, args, { stdio: 'inherit', env: process.env });
|
||||
return exitStatusFor(result, true);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { fleetCommsSendArgs, registerCommsCommand, tmuxSendArgs } from './comms.js';
|
||||
|
||||
describe('arg translation', () => {
|
||||
it('tmux path: -s/-C/-L/-f/-m per agent-send.sh getopts', () => {
|
||||
expect(tmuxSendArgs('orch-01', 'hello', {})).toEqual(['-s', 'orch-01', '-m', 'hello']);
|
||||
expect(
|
||||
tmuxSendArgs('orch-01', 'unused', {
|
||||
class: 'actionable',
|
||||
socket: 'mosaic-fleet',
|
||||
file: '/tmp/body.txt',
|
||||
}),
|
||||
).toEqual(['-s', 'orch-01', '-C', 'actionable', '-L', 'mosaic-fleet', '-f', '/tmp/body.txt']);
|
||||
});
|
||||
|
||||
it('fleet-comms path: -t site/agent and -c class', () => {
|
||||
expect(fleetCommsSendArgs('usc', 'fred', 'hi', {})).toEqual(['-t', 'usc/fred', '-m', 'hi']);
|
||||
expect(fleetCommsSendArgs('usc', 'fred', 'hi', { class: 'human' })).toEqual([
|
||||
'-t',
|
||||
'usc/fred',
|
||||
'-c',
|
||||
'human',
|
||||
'-m',
|
||||
'hi',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerCommsCommand routing', () => {
|
||||
const savedBrain = process.env['MOSAIC_BRAIN_HOME'];
|
||||
const savedRepo = process.env['MOSAIC_FLEET_COMMS_REPO'];
|
||||
const savedAgent = process.env['MOSAIC_AGENT_NAME'];
|
||||
afterEach(() => {
|
||||
for (const [k, v] of [
|
||||
['MOSAIC_BRAIN_HOME', savedBrain],
|
||||
['MOSAIC_FLEET_COMMS_REPO', savedRepo],
|
||||
['MOSAIC_AGENT_NAME', savedAgent],
|
||||
] as const) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
function fixture(): { brain: string; repo: string; tmuxLog: string; commsLog: string } {
|
||||
const brain = mkdtempSync(join(tmpdir(), 'comms-brain-'));
|
||||
const repo = mkdtempSync(join(tmpdir(), 'comms-repo-'));
|
||||
mkdirSync(join(brain, 'tools', 'tmux'), { recursive: true });
|
||||
mkdirSync(join(repo, 'tools'), { recursive: true });
|
||||
const tmuxLog = join(brain, 'tmux.log');
|
||||
const commsLog = join(repo, 'comms.log');
|
||||
writeFileSync(
|
||||
join(brain, 'tools', 'tmux', 'agent-send.sh'),
|
||||
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(tmuxLog)}\nexit 7\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(repo, 'tools', 'comms-send.sh'),
|
||||
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(commsLog)}\nprintf 'FLEET_COMMS_REPO=%s FLEET_COMMS_SITE=%s\\n' "$FLEET_COMMS_REPO" "$FLEET_COMMS_SITE" >> ${JSON.stringify(commsLog)}\nexit 5\n`,
|
||||
);
|
||||
chmodSync(join(brain, 'tools', 'tmux', 'agent-send.sh'), 0o755);
|
||||
chmodSync(join(repo, 'tools', 'comms-send.sh'), 0o755);
|
||||
process.env['MOSAIC_BRAIN_HOME'] = brain;
|
||||
process.env['MOSAIC_FLEET_COMMS_REPO'] = repo;
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'tester';
|
||||
return { brain, repo, tmuxLog, commsLog };
|
||||
}
|
||||
|
||||
it('default routes same-host via agent-send with translated flags and passes rc through', async () => {
|
||||
const f = fixture();
|
||||
const program = new Command();
|
||||
registerCommsCommand(program);
|
||||
await program.parseAsync(
|
||||
[
|
||||
'comms',
|
||||
'send',
|
||||
'orch-01',
|
||||
'--class',
|
||||
'actionable',
|
||||
'--socket',
|
||||
'mosaic-fleet',
|
||||
'verdict',
|
||||
'landed',
|
||||
],
|
||||
{ from: 'user' },
|
||||
);
|
||||
expect(process.exitCode).toBe(7);
|
||||
expect(readFileSync(f.tmuxLog, 'utf8').trim()).toBe(
|
||||
'-s orch-01 -C actionable -L mosaic-fleet -m verdict landed',
|
||||
);
|
||||
expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked
|
||||
});
|
||||
|
||||
it('--site routes inter-site via comms-send with site-prefixed target and passes rc through', async () => {
|
||||
const f = fixture();
|
||||
const program = new Command();
|
||||
registerCommsCommand(program);
|
||||
await program.parseAsync(
|
||||
['comms', 'send', 'fred', '--site', 'usc', '--class', 'human', 'hello', 'there'],
|
||||
{ from: 'user' },
|
||||
);
|
||||
expect(process.exitCode).toBe(5);
|
||||
expect(readFileSync(f.commsLog, 'utf8').split('\n')[0]?.trim()).toBe(
|
||||
'-t usc/fred -c human -m hello there',
|
||||
);
|
||||
// The sender must bind comms-send.sh to the SELECTED repo (codex 9c8b6ebf).
|
||||
expect(readFileSync(f.commsLog, 'utf8')).toContain(
|
||||
`FLEET_COMMS_REPO=${f.repo} FLEET_COMMS_SITE=usc`,
|
||||
);
|
||||
expect(existsSync(f.tmuxLog)).toBe(false); // same-host tool never invoked
|
||||
});
|
||||
|
||||
it('inter-site without MOSAIC_AGENT_NAME is an invocation defect (exit 2)', async () => {
|
||||
const f = fixture();
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
const program = new Command();
|
||||
registerCommsCommand(program);
|
||||
await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(2);
|
||||
expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked
|
||||
});
|
||||
|
||||
it('missing fleet-comms repo fails 127 naming the expected path', async () => {
|
||||
fixture();
|
||||
process.env['MOSAIC_FLEET_COMMS_REPO'] = '/nonexistent-comms-repo';
|
||||
const program = new Command();
|
||||
registerCommsCommand(program);
|
||||
await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(127);
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { accessSync, constants } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||
import { execBrainTool } from './brain-dispatch.js';
|
||||
|
||||
/**
|
||||
* `mosaic comms send` — routed agent messaging (FLEET-COMMS.md doctrine).
|
||||
*
|
||||
* Same-host (default): brain tools/tmux/agent-send.sh. Inter-site
|
||||
* (--site <site>): the fleet-comms repo's comms-send.sh — never for
|
||||
* local traffic (a git round trip per message; Jason 2026-08-28).
|
||||
*
|
||||
* Exit codes pass through BOTH paths. rc=2 (text in pane, still draft) is
|
||||
* a CONTRACT, not a failure: never retry, confirm with capture-pane.
|
||||
*/
|
||||
export interface CommsSendOptions {
|
||||
readonly class?: string;
|
||||
readonly file?: string;
|
||||
readonly socket?: string;
|
||||
readonly site?: string;
|
||||
readonly commsRepo?: string;
|
||||
}
|
||||
|
||||
export function defaultCommsRepo(): string {
|
||||
return process.env['MOSAIC_FLEET_COMMS_REPO'] ?? join(homedir(), 'src', 'fleet-comms');
|
||||
}
|
||||
|
||||
/** Build the agent-send.sh argv for the same-host path. */
|
||||
export function tmuxSendArgs(target: string, message: string, opts: CommsSendOptions): string[] {
|
||||
const args = ['-s', target];
|
||||
if (opts.class) args.push('-C', opts.class);
|
||||
if (opts.socket) args.push('-L', opts.socket);
|
||||
if (opts.file) args.push('-f', opts.file);
|
||||
else args.push('-m', message);
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Build the comms-send.sh argv for the inter-site path. */
|
||||
export function fleetCommsSendArgs(
|
||||
site: string,
|
||||
target: string,
|
||||
message: string,
|
||||
opts: CommsSendOptions,
|
||||
): string[] {
|
||||
const args = ['-t', `${site}/${target}`];
|
||||
if (opts.class) args.push('-c', opts.class);
|
||||
args.push('-m', message);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function registerCommsCommand(program: Command): void {
|
||||
const cmd: Command = program
|
||||
.command('comms')
|
||||
.description(
|
||||
'Routed agent messaging: tmux same-host (default), fleet-comms inter-site (--site)',
|
||||
)
|
||||
.command('send')
|
||||
.description('send <target> [message...] — same-host tmux unless --site is given')
|
||||
.option('--class <class>', 'terminal-log | actionable | human | reaction | digest')
|
||||
.option('--file <path>', 'message body from file (same-host path only)')
|
||||
.option('--socket <name>', 'tmux socket for the same-host send (e.g. mosaic-fleet)')
|
||||
.option('--site <site>', 'route via fleet-comms to <site>/<target>')
|
||||
.option('--comms-repo <path>', 'fleet-comms checkout', defaultCommsRepo())
|
||||
.argument('<target>', 'destination seat (session name)')
|
||||
.argument('[message...]', 'message text (joined; or use --file)')
|
||||
.action(
|
||||
async (
|
||||
target: string,
|
||||
messageWords: string[],
|
||||
opts: CommsSendOptions & Record<string, unknown>,
|
||||
command: Command,
|
||||
) => {
|
||||
let mosaicHome: string | undefined;
|
||||
for (let anc: Command | null = command; anc; anc = anc.parent) {
|
||||
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
|
||||
if (v !== undefined) {
|
||||
mosaicHome = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
|
||||
const message = messageWords.join(' ');
|
||||
|
||||
if (opts.site) {
|
||||
const repo = opts.commsRepo ?? defaultCommsRepo();
|
||||
const tool = join(repo, 'tools', 'comms-send.sh');
|
||||
try {
|
||||
accessSync(tool, constants.X_OK);
|
||||
} catch {
|
||||
console.error(
|
||||
`mosaic comms: fleet-comms sender not found (expected ${tool}). ` +
|
||||
'Clone the fleet-comms repo or point --comms-repo at it.',
|
||||
);
|
||||
process.exitCode = 127;
|
||||
return;
|
||||
}
|
||||
if (!process.env['MOSAIC_AGENT_NAME']) {
|
||||
console.error(
|
||||
'mosaic comms: inter-site sends require MOSAIC_AGENT_NAME (sending identity).',
|
||||
);
|
||||
process.exitCode = 2; // invocation defect: fixable by the caller
|
||||
return;
|
||||
}
|
||||
// comms-send.sh locates its working repo via FLEET_COMMS_REPO
|
||||
// (default $HOME/src/fleet-comms); without this, --comms-repo
|
||||
// would select the executable but not the repository it operates
|
||||
// on (codex review of 9c8b6ebf).
|
||||
const env = { ...process.env, FLEET_COMMS_SITE: opts.site, FLEET_COMMS_REPO: repo };
|
||||
const result = spawnSync(tool, fleetCommsSendArgs(opts.site, target, message, opts), {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
process.exitCode = result.status ?? 125;
|
||||
return;
|
||||
}
|
||||
|
||||
// Same-host: the brain tool owns validation (bad class -> its rc 3)
|
||||
// and absence (execBrainTool -> 127 with the resolved path).
|
||||
process.exitCode = execBrainTool(
|
||||
home,
|
||||
'tools/tmux/agent-send.sh',
|
||||
tmuxSendArgs(target, message, opts),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
cmd.addHelpText(
|
||||
'after',
|
||||
'\nExit codes pass through. rc=2 means the text reached the pane but is still a draft: NEVER retry (double-send); confirm with tmux capture-pane.',
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, chmodSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { registerQCommand, resolveQuestionTool } from './q.js';
|
||||
import { exitStatusFor, resolveBrainTool } from './brain-dispatch.js';
|
||||
|
||||
describe('resolveBrainTool', () => {
|
||||
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||
});
|
||||
|
||||
it('joins brain home with the relative tool path', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'dispatch-resolve-'));
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
expect(resolveBrainTool('/nonexistent/mosaic-home', 'tools/questions/q-new.sh')).toBe(
|
||||
join(tmp, 'tools', 'questions', 'q-new.sh'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveQuestionTool', () => {
|
||||
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||
});
|
||||
|
||||
it('maps new/render subcommands to their brain tools', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'q-resolve-'));
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
expect(resolveQuestionTool(tmp, 'new')).toBe(join(tmp, 'tools', 'questions', 'q-new.sh'));
|
||||
expect(resolveQuestionTool(tmp, 'render')).toBe(join(tmp, 'tools', 'questions', 'render.py'));
|
||||
expect(resolveQuestionTool(tmp, 'bogus')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerQCommand usage + dispatch', () => {
|
||||
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it('exit 2 with the subcommand list when no/unknown subcommand', async () => {
|
||||
process.env['MOSAIC_BRAIN_HOME'] = mkdtempSync(join(tmpdir(), 'q-usage-'));
|
||||
const program = new Command();
|
||||
registerQCommand(program);
|
||||
await program.parseAsync(['q'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(2);
|
||||
process.exitCode = undefined;
|
||||
await program.parseAsync(['q', 'bogus'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(2);
|
||||
process.exitCode = undefined;
|
||||
// Reserved property names must not leak through the record lookup.
|
||||
await program.parseAsync(['q', 'toString'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(2);
|
||||
});
|
||||
|
||||
it('execs the brain tool with pass-through args and exit code', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'q-live-'));
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
mkdirSync(join(tmp, 'tools', 'questions'), { recursive: true });
|
||||
const stub = join(tmp, 'tools', 'questions', 'q-new.sh');
|
||||
writeFileSync(stub, '#!/usr/bin/env bash\necho "called with: $*"\nexit 7\n');
|
||||
chmodSync(stub, 0o755);
|
||||
|
||||
const program = new Command();
|
||||
registerQCommand(program);
|
||||
await program.parseAsync(['q', 'new', '--slug', 'x', '--question', 'why'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exitStatusFor (shared dispatch contract)', () => {
|
||||
it('maps absent tool to 127', () => {
|
||||
expect(exitStatusFor({ status: 0 }, false)).toBe(127);
|
||||
});
|
||||
it('passes tool status through', () => {
|
||||
expect(exitStatusFor({ status: 7 }, true)).toBe(7);
|
||||
});
|
||||
it('maps signal death to 125', () => {
|
||||
expect(exitStatusFor({ status: null }, true)).toBe(125);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||
import { execBrainTool, resolveBrainTool } from './brain-dispatch.js';
|
||||
|
||||
/**
|
||||
* `mosaic q` — tracked decision questions (brain tools/questions).
|
||||
* `new` files a question file (one FILE per question, merge-conflict
|
||||
* impossible by construction); `render` regenerates the
|
||||
* docs/OPEN-QUESTIONS.md index (id allocation happens in the renderer).
|
||||
*/
|
||||
const SUBCOMMANDS: Record<string, { path: string; interpreter?: string; help: string }> = {
|
||||
new: {
|
||||
path: 'tools/questions/q-new.sh',
|
||||
help: 'file a question (--question, --slug, --owed-by, ...)',
|
||||
},
|
||||
render: {
|
||||
path: 'tools/questions/render.py',
|
||||
interpreter: 'python3',
|
||||
help: 'regenerate docs/OPEN-QUESTIONS.md (owns Q-id allocation)',
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveQuestionTool(mosaicHome: string, sub: string): string | undefined {
|
||||
const entry = SUBCOMMANDS[sub];
|
||||
return entry ? resolveBrainTool(mosaicHome, entry.path) : undefined;
|
||||
}
|
||||
|
||||
export function registerQCommand(program: Command): void {
|
||||
const cmd: Command = program
|
||||
.command('q')
|
||||
.description('Tracked decision questions: file and render (brain tools/questions)')
|
||||
.allowUnknownOption()
|
||||
.argument('[args...]', 'subcommand + args passed through to the question tools')
|
||||
.action(async (args: string[], _opts: unknown, command: Command) => {
|
||||
let mosaicHome: string | undefined;
|
||||
for (let anc: Command | null = command; anc; anc = anc.parent) {
|
||||
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
|
||||
if (v !== undefined) {
|
||||
mosaicHome = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
|
||||
|
||||
const sub = args[0];
|
||||
if (!sub || !Object.hasOwn(SUBCOMMANDS, sub)) {
|
||||
console.error('mosaic q: expected a subcommand:');
|
||||
for (const [name, entry] of Object.entries(SUBCOMMANDS)) {
|
||||
console.error(` mosaic q ${name} ${entry.help}`);
|
||||
}
|
||||
process.exitCode = 2; // usage error contract: invocation defect
|
||||
return;
|
||||
}
|
||||
const entry = SUBCOMMANDS[sub]!;
|
||||
process.exitCode = execBrainTool(home, entry.path, args.slice(1), entry.interpreter);
|
||||
});
|
||||
|
||||
cmd.addHelpText(
|
||||
'after',
|
||||
'\nEverything after the subcommand is passed through verbatim (args, output, exit code).',
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { exitStatusFor, registerWatchCommand, resolveAgentWatchTool } from './watch.js';
|
||||
|
||||
// The dispatch command execs a real process with inherited stdio; the spec
|
||||
// covers the pure resolution and exit-mapping surfaces plus the absent-tool
|
||||
// path (which exits without spawning). Live pass-through is exercised by the
|
||||
// fleet smoke test against the real brain tool.
|
||||
|
||||
describe('resolveAgentWatchTool', () => {
|
||||
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||
});
|
||||
|
||||
it('honors MOSAIC_BRAIN_HOME over the canonical brain', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
expect(resolveAgentWatchTool('/nonexistent/mosaic-home')).toBe(
|
||||
join(tmp, 'tools', 'agent-watch', 'agent-watch.sh'),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves inside the brain tools tree', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
const tool = resolveAgentWatchTool(tmp);
|
||||
expect(tool.endsWith(join('tools', 'agent-watch', 'agent-watch.sh'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exitStatusFor', () => {
|
||||
it('maps absent tool to 127', () => {
|
||||
expect(exitStatusFor({ status: 0 }, false)).toBe(127);
|
||||
});
|
||||
|
||||
it('passes the tool exit status through', () => {
|
||||
expect(exitStatusFor({ status: 2 }, true)).toBe(2);
|
||||
expect(exitStatusFor({ status: 78 }, true)).toBe(78);
|
||||
});
|
||||
|
||||
it('maps signal death / null status to 125', () => {
|
||||
expect(exitStatusFor({ status: null }, true)).toBe(125);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerWatchCommand absent-tool path', () => {
|
||||
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it('sets exitCode 127 with the resolved path when the tool is missing', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'watch-missing-'));
|
||||
// The suite directory exists but the tool file does not.
|
||||
mkdirSync(join(tmp, 'tools', 'agent-watch'), { recursive: true });
|
||||
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||
|
||||
const program = new Command();
|
||||
registerWatchCommand(program);
|
||||
await program.parseAsync(['watch', 'list'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(127);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||
import {
|
||||
brainToolExists,
|
||||
execBrainTool,
|
||||
exitStatusFor,
|
||||
resolveBrainTool,
|
||||
} from './brain-dispatch.js';
|
||||
|
||||
export { exitStatusFor };
|
||||
|
||||
/**
|
||||
* `mosaic watch` — dispatch to the brain's agent-watch suite.
|
||||
* See brain-dispatch.ts for the architecture and pass-through contract.
|
||||
*/
|
||||
export function resolveAgentWatchTool(mosaicHome: string): string {
|
||||
return resolveBrainTool(mosaicHome, 'tools/agent-watch/agent-watch.sh');
|
||||
}
|
||||
|
||||
export function registerWatchCommand(program: Command): void {
|
||||
const cmd: Command = program
|
||||
.command('watch')
|
||||
.description('Wake-me-when watchers (agent-watch): start, list, stop')
|
||||
// allowUnknownOption + variadic = full ordered pass-through: unknown
|
||||
// options (--name, --when, ...) and their values land in args verbatim
|
||||
// (commander 13 measured behavior), so the tool owns its own flag
|
||||
// surface without the CLI needing passThroughOptions (which would
|
||||
// force enablePositionalOptions fleet-wide on the root program).
|
||||
.allowUnknownOption()
|
||||
// The tool owns help too: without this, commander would intercept
|
||||
// --help and answer with wrapper help instead of agent-watch's own
|
||||
// (codex review of 18f3dd49).
|
||||
.helpOption(false)
|
||||
.argument('[args...]', 'args passed through to agent-watch.sh')
|
||||
.action(async (args: string[], _opts: unknown, command: Command) => {
|
||||
// --mosaic-home is not global in this CLI; walk parents for it and
|
||||
// fall back to the default. MOSAIC_BRAIN_HOME (seat launchers export
|
||||
// it) wins inside resolveBrainHome regardless.
|
||||
let mosaicHome: string | undefined;
|
||||
for (let anc: Command | null = command; anc; anc = anc.parent) {
|
||||
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
|
||||
if (v !== undefined) {
|
||||
mosaicHome = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
|
||||
|
||||
const tool = resolveAgentWatchTool(home);
|
||||
if (!brainToolExists(tool)) {
|
||||
console.error(
|
||||
`mosaic watch: agent-watch not found (expected ${tool}). ` +
|
||||
'The watcher suite lives in the brain tree under tools/agent-watch/; ' +
|
||||
'check MOSAIC_BRAIN_HOME or the brain checkout.',
|
||||
);
|
||||
process.exitCode = 127;
|
||||
return;
|
||||
}
|
||||
|
||||
process.exitCode = execBrainTool(home, 'tools/agent-watch/agent-watch.sh', args);
|
||||
});
|
||||
|
||||
cmd.addHelpText(
|
||||
'after',
|
||||
'\nEverything after `mosaic watch` is passed through to agent-watch.sh verbatim (args, output, exit code).',
|
||||
);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ def run_pi_registry_command(
|
||||
runner=subprocess.run,
|
||||
sleeper=time.sleep,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a Pi probe command with bounded retries for concurrent-Pi stalls."""
|
||||
"""Run the registry probe with bounded retries for concurrent-Pi stalls."""
|
||||
|
||||
for attempt in range(1, PI_PROBE_ATTEMPTS + 1):
|
||||
try:
|
||||
@@ -106,7 +106,13 @@ def probe_pi_registry() -> list[dict[str, object]]:
|
||||
if pi is None:
|
||||
raise AssertionError("installed Pi runtime is required for Invariant R")
|
||||
|
||||
version = run_pi_registry_command([pi, "--version"], dict(os.environ))
|
||||
version = subprocess.run(
|
||||
[pi, "--version"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if version.returncode != 0:
|
||||
raise AssertionError(f"Pi version probe failed: {version.stderr.strip()}")
|
||||
if version.stdout.strip() != PI_VERSION:
|
||||
|
||||
Reference in New Issue
Block a user