Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15a6969688 | ||
|
|
41e8046371 | ||
|
|
6e16675ea2 | ||
|
|
19ebc422aa | ||
|
|
bd749831b1 | ||
|
|
f8e1b43b5b | ||
|
|
2148c20d26 | ||
|
|
5964dab891 |
@@ -0,0 +1,106 @@
|
|||||||
|
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,6 +24,7 @@ import { GCModule } from './gc/gc.module.js';
|
|||||||
import { HarnessModule } from './harness/harness.module.js';
|
import { HarnessModule } from './harness/harness.module.js';
|
||||||
import { ReloadModule } from './reload/reload.module.js';
|
import { ReloadModule } from './reload/reload.module.js';
|
||||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||||
|
import { HierarchyModule } from './hierarchy/hierarchy.module.js';
|
||||||
import { QueueModule } from './queue/queue.module.js';
|
import { QueueModule } from './queue/queue.module.js';
|
||||||
import { FederationModule } from './federation/federation.module.js';
|
import { FederationModule } from './federation/federation.module.js';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
@@ -65,6 +66,7 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
|||||||
QueueModule,
|
QueueModule,
|
||||||
ReloadModule,
|
ReloadModule,
|
||||||
WorkspaceModule,
|
WorkspaceModule,
|
||||||
|
HierarchyModule,
|
||||||
...(federationEnabled ? [FederationModule] : []),
|
...(federationEnabled ? [FederationModule] : []),
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
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')));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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 {}
|
||||||
@@ -72,6 +72,25 @@ example now lists the complete measured set, and the import analysis
|
|||||||
is extended to resolve literal dynamic `import()` routes, which two of
|
is extended to resolve literal dynamic `import()` routes, which two of
|
||||||
the four members use.
|
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,
|
Scope: the tenancy/authorization structure record class — companies,
|
||||||
estates, platform-projects, workspaces, hierarchy grants, their parentage,
|
estates, platform-projects, workspaces, hierarchy grants, their parentage,
|
||||||
and constraints. Out of scope: the RBAC grant vocabulary and evaluation
|
and constraints. Out of scope: the RBAC grant vocabulary and evaluation
|
||||||
@@ -86,8 +105,13 @@ legacy flat data (future work; see §1.3).
|
|||||||
AND `hierarchy_grants` (§3) — A1 includes hierarchy-level access grants
|
AND `hierarchy_grants` (§3) — A1 includes hierarchy-level access grants
|
||||||
in the class. Every rule addressed to "the class" in this contract
|
in the class. Every rule addressed to "the class" in this contract
|
||||||
(payload prohibition, mutation path, audit) binds all five tables. Class
|
(payload prohibition, mutation path, audit) binds all five tables. Class
|
||||||
rows carry parentage, naming, grant, and audit-linkage data only — never
|
rows carry parentage, naming, grant, audit-linkage, and visibility-class
|
||||||
task, plan, or any business/orchestration payload.
|
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
|
References from business/orchestration rows into the class are limited
|
||||||
to exactly one form: the canonical `workspace_id` tenancy column that
|
to exactly one form: the canonical `workspace_id` tenancy column that
|
||||||
REQ-TEN-001 requires on every canonical row, referencing
|
REQ-TEN-001 requires on every canonical row, referencing
|
||||||
@@ -116,7 +140,9 @@ 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.)
|
decision under A1; this contract pins only that they stay distinct tables.)
|
||||||
|
|
||||||
1. `companies` — id (uuid pk), name, slug (unique per deployment),
|
1. `companies` — id (uuid pk), name, slug (unique per deployment),
|
||||||
created_at, updated_at. N per deployment (D2).
|
`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 →
|
2. `estates` — id, name, slug, `company_id` NOT NULL →
|
||||||
`companies.id` ON DELETE RESTRICT. Exactly one company per estate; a
|
`companies.id` ON DELETE RESTRICT. Exactly one company per estate; a
|
||||||
company holds any number of estates.
|
company holds any number of estates.
|
||||||
@@ -142,6 +168,31 @@ decision under A1; this contract pins only that they stay distinct tables.)
|
|||||||
free-form payload field. The columns declared in this section and §3
|
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
|
are exhaustive: a class table's column set is exactly its declared set
|
||||||
(verified per §6.2) — nothing else (A1 §8.1.2).
|
(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
|
## 3. Grant attachment points
|
||||||
|
|
||||||
@@ -223,7 +274,8 @@ shape contract 2 attaches to:
|
|||||||
2. **Audit parity.** A1 §8.2 leaves every pre-existing REQ binding, so
|
2. **Audit parity.** A1 §8.2 leaves every pre-existing REQ binding, so
|
||||||
hierarchy mutations get REQ-AUD-001's guarantees, not a weakened
|
hierarchy mutations get REQ-AUD-001's guarantees, not a weakened
|
||||||
substitute. Concretely:
|
substitute. Concretely:
|
||||||
- Every create, rename, transfer, grant create/change/revoke, and
|
- Every create, rename, transfer, visibility change (§5.5), grant
|
||||||
|
create/change/revoke, and
|
||||||
delete — including every grant deletion cascaded by a node delete —
|
delete — including every grant deletion cascaded by a node delete —
|
||||||
emits a semantic audit event carrying actor, verb, target, and (for
|
emits a semantic audit event carrying actor, verb, target, and (for
|
||||||
transfers) source and destination parents, with the correlation,
|
transfers) source and destination parents, with the correlation,
|
||||||
@@ -248,6 +300,25 @@ shape contract 2 attaches to:
|
|||||||
workspace work. Contract 8 owns projection details but cannot narrow
|
workspace work. Contract 8 owns projection details but cannot narrow
|
||||||
this rule. This contract additionally guarantees the chain roll-ups
|
this rule. This contract additionally guarantees the chain roll-ups
|
||||||
aggregate over is unique and non-null (§2.5).
|
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
|
## 6. Verification requirements
|
||||||
|
|
||||||
@@ -264,7 +335,10 @@ Binding on the implementing PRs (extends A1 §8.3):
|
|||||||
witnessed (zero and two set → refused). Grant uniqueness: a duplicate
|
witnessed (zero and two set → refused). Grant uniqueness: a duplicate
|
||||||
(subject, target, role) row refused for each of the six subject×target
|
(subject, target, role) row refused for each of the six subject×target
|
||||||
forms, proving NULLS-NOT-DISTINCT semantics; NOT NULL on `role`,
|
forms, proving NULLS-NOT-DISTINCT semantics; NOT NULL on `role`,
|
||||||
`granted_by`, and all `name`/`slug` columns witnessed.
|
`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
|
2. Column allowlist: an information_schema assertion that each class
|
||||||
table's column set is exactly the set declared in §2/§3 — the bounded
|
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).
|
observable for no-payload (§2.7) and no-`owner_id` (§4.4).
|
||||||
@@ -346,9 +420,19 @@ Binding on the implementing PRs (extends A1 §8.3):
|
|||||||
static analysis cannot see, and any such evasion found later is
|
static analysis cannot see, and any such evasion found later is
|
||||||
corrected as a conformance defect, not grandfathered.
|
corrected as a conformance defect, not grandfathered.
|
||||||
4. Audit witnesses: for each mutation class (create, rename, transfer,
|
4. Audit witnesses: for each mutation class (create, rename, transfer,
|
||||||
grant create/change/revoke, delete) — the event exists after commit
|
visibility change, grant create/change/revoke, delete) — the event
|
||||||
with actor/verb/target and same-transaction atomicity; a rolled-back
|
exists after commit
|
||||||
mutation leaves no event (rollback witness); a node delete's cascaded
|
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
|
grant deletions are each covered by events; events survive deletion of
|
||||||
their target (query the events of a deleted node).
|
their target (query the events of a deleted node).
|
||||||
5. Transfer tests: parent-FK update moves the subtree resolution and
|
5. Transfer tests: parent-FK update moves the subtree resolution and
|
||||||
@@ -368,10 +452,30 @@ Binding on the implementing PRs (extends A1 §8.3):
|
|||||||
endpoints mutate no canonical state anywhere (assert zero writes across
|
endpoints mutate no canonical state anywhere (assert zero writes across
|
||||||
hierarchy AND workspace tables, not hierarchy only); readers see
|
hierarchy AND workspace tables, not hierarchy only); readers see
|
||||||
aggregates only over workspaces they are authorized on, with no
|
aggregates only over workspaces they are authorized on, with no
|
||||||
cross-tenant existence oracles (A1 §8.3 acceptance 3).
|
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/
|
8. Real-PostgreSQL coverage for every constraint witness (unique/CHECK/
|
||||||
RESTRICT/NULLS NOT DISTINCT behavior), using the `ci-postgres` service
|
RESTRICT/NULLS NOT DISTINCT behavior), using the `ci-postgres` service
|
||||||
in the `test` CI step; mocked specs cannot witness database constraints.
|
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
|
## Ruling request
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# Deployment Mode and Conversion Contract (D3)
|
||||||
|
|
||||||
|
Status: DRAFT — awaiting ratification (webui-audit S2, contract 6 of 9).
|
||||||
|
Authority: PRD D3 (Part I §3) — two modes chosen at install time,
|
||||||
|
Standalone and Enterprise, with the mode table (brains, user-data
|
||||||
|
isolation, secrets, conversion); Standalone → Enterprise conversion is
|
||||||
|
**one-way** and Enterprise is a **terminal state**. PRD D14 (Part I §7)
|
||||||
|
— the per-user brain split is optional in Standalone and keeping it is
|
||||||
|
the recommended default because it preserves forward-compatibility with
|
||||||
|
the one-way conversion. PRD D11 (Part I §9) — v1 ships the Standalone
|
||||||
|
flow only; Enterprise conversion is explicitly deferred. PRD D3
|
||||||
|
federation clause — federation is intentionally not fully designed,
|
||||||
|
deferred, and nothing in v1 may foreclose it.
|
||||||
|
|
||||||
|
Revision 2 (luna review F1–F7): the identity precondition restated in
|
||||||
|
identity-contract terms with a conversion-local acknowledgment record
|
||||||
|
this contract owns (F1); a durable, keyed preparation state with a
|
||||||
|
Standalone-safe representation rule, an in-transaction re-check fence,
|
||||||
|
and an exact flip boundary (F2); the §5.4 unknown-value rule stated
|
||||||
|
directly without the contradictory non-exhaustiveness clause (F3); the
|
||||||
|
D14 boundary bound here with a stable column-allowlist witness instead
|
||||||
|
of delegated to an unratified layout (F4); the conversion witness
|
||||||
|
matrix extended to every §4.2/§4.4 condition (F5); the mode-record
|
||||||
|
writer coverage imported concretely from contract 1 §6.3 with a named
|
||||||
|
schema, closed writer set, crafted-write probe, and mode-resolution
|
||||||
|
assertion (F6); the mode read command flagged as a §12.1 drafting
|
||||||
|
addition rather than a D8 mandate (F7). Ownership language aligned
|
||||||
|
with contract 3 revision 2: mode is recorded at bootstrap and read by
|
||||||
|
the wizard as input.
|
||||||
|
|
||||||
|
This contract binds the mode as a canonical platform property (§2), the
|
||||||
|
per-mode obligations and which contract owns each (§3), the conversion
|
||||||
|
transition (§4), the v1 non-foreclosure obligations (§5), and their
|
||||||
|
witnesses (§6). Domain semantics stay with their owning contracts:
|
||||||
|
wizard branching (contract 3 §2), identity/SSO
|
||||||
|
(`identity-lifecycle.md`), custody and per-user brain mechanics
|
||||||
|
(contract 7, `custody-schema.md`), tool mapping
|
||||||
|
(`tool-gateway-mapping.md`).
|
||||||
|
|
||||||
|
## 1. Definitions
|
||||||
|
|
||||||
|
1. **Mode**: the platform-wide deployment mode, exactly one of
|
||||||
|
`standalone` or `enterprise`. The vocabulary is closed in v1;
|
||||||
|
extension (e.g. a federation mode) is by amendment to this contract,
|
||||||
|
never ad hoc.
|
||||||
|
2. **Conversion**: the one-way transition `standalone → enterprise`.
|
||||||
|
No other mode transition exists.
|
||||||
|
3. **Conversion preconditions**: the verifiable conditions of §4.2 that
|
||||||
|
must all hold before the mode record may change.
|
||||||
|
4. **Preparation unit**: one re-runnable piece of pre-conversion work —
|
||||||
|
the migration of one secret to the Vault backend, or the partition
|
||||||
|
of one user's brain content (§4.3).
|
||||||
|
|
||||||
|
## 2. Mode is a canonical recorded property
|
||||||
|
|
||||||
|
1. Mode is recorded canonically in the platform database at bootstrap
|
||||||
|
as the operator's install-time choice (D3: modes are "chosen at
|
||||||
|
install time"). The record is a single-row keyed record
|
||||||
|
(`platform_mode`: mode value, recorded-at timestamp, bootstrap epoch
|
||||||
|
reference); this contract owns it, the bootstrap writer performs the
|
||||||
|
one v1 write (§6.2), and the wizard reads it as input (contract 3
|
||||||
|
§2.3). Mode is never derived from feature state (presence of Vault,
|
||||||
|
count of brains, count of users), and no component may infer a
|
||||||
|
different mode than the record states.
|
||||||
|
2. The record is readable by any authenticated user through a Gateway
|
||||||
|
command with CLI exposure. This read command is a **drafting
|
||||||
|
addition** ratified with this contract (PRD §12.1), not a D8
|
||||||
|
mandate: D8 binds only that any surface exposing the value goes
|
||||||
|
through official tooling. When a webUI surface consumes the read, a
|
||||||
|
mapping row is added to `tool-gateway-mapping.md` by amendment —
|
||||||
|
the same route §4.4 already binds for the conversion command.
|
||||||
|
Components branch on the read value only.
|
||||||
|
3. The record is immutable except by the §4 conversion transition.
|
||||||
|
Editing it by direct database access, config file, environment
|
||||||
|
variable, or wizard re-run is non-conformant (contract 3 §2.3:
|
||||||
|
changing mode later is conversion, not a wizard re-run).
|
||||||
|
|
||||||
|
## 3. Per-mode obligations (owner map)
|
||||||
|
|
||||||
|
The PRD mode table binds four rows; this contract assigns each an
|
||||||
|
owning contract so no obligation is unowned and none is bound twice:
|
||||||
|
|
||||||
|
| Obligation | Standalone | Enterprise | Owner |
|
||||||
|
| ------------------- | -------------------------------------- | -------------------------------------------------- | --------------------------------------------- |
|
||||||
|
| Brains | one mosaic-brain (system + user files) | system brain for config + one brain per user | contract 7 (custody/brain mechanics) |
|
||||||
|
| User-data isolation | single user | no user-data leakage between users; sharing opt-in | contract 7 (enforced by architecture, D14) |
|
||||||
|
| Secrets | OpenBao/Vault or flat files | OpenBao/Vault REQUIRED | this contract (§4.2 gate; steady-state check) |
|
||||||
|
| Conversion | may convert to Enterprise, one-way | terminal state | this contract (§4) |
|
||||||
|
|
||||||
|
The Standalone brains row states the default layout, not the only
|
||||||
|
valid one: the D14 per-user split is a MAY in Standalone with keeping
|
||||||
|
it the recommended default (PRD §7, contract 7 §6), and Vault-backed
|
||||||
|
secrets are equally valid Standalone configuration. Both prepared
|
||||||
|
states are therefore themselves valid Standalone states — the fact
|
||||||
|
§4.3 relies on.
|
||||||
|
|
||||||
|
In Enterprise steady state, a flat-file secrets backend is
|
||||||
|
non-conformant; the platform refuses to start Enterprise-mode
|
||||||
|
components against a flat-file secrets configuration (fail-closed, not
|
||||||
|
warn-and-run).
|
||||||
|
|
||||||
|
## 4. Conversion transition
|
||||||
|
|
||||||
|
1. **Direction and terminality.** The only transition is
|
||||||
|
`standalone → enterprise`. `enterprise → standalone` does not exist:
|
||||||
|
there is no command, no admin override, and no support path. An
|
||||||
|
attempt is refused with the precondition/state error class of the
|
||||||
|
command envelope (`tool-gateway-mapping.md` §4.2).
|
||||||
|
2. **Preconditions (all verified before the record changes):**
|
||||||
|
- Secrets: OpenBao/Vault is configured and reachable, and every
|
||||||
|
required secret is served from the Vault backend — none from a
|
||||||
|
flat-file backend. Secret migration completes before conversion;
|
||||||
|
this contract does not define the migration tooling, only the
|
||||||
|
gate.
|
||||||
|
- Brains: the per-user brain split required by the Enterprise row of
|
||||||
|
§3 is established for **every** existing user (or the deployment
|
||||||
|
already kept the split, the D14 recommended default). Brain
|
||||||
|
partitioning mechanics are contract 7; this contract binds only
|
||||||
|
that the split is complete before the mode flips.
|
||||||
|
- Identity: at least one platform administrator account exists that
|
||||||
|
is active in identity-contract terms — authenticated capability,
|
||||||
|
not banned, not deactivated (identity §2, §5). And the conversion
|
||||||
|
request carries a **configuration acknowledgment**: the current
|
||||||
|
canonical values of registration mode and per-provider JIT
|
||||||
|
enablement (identity §2.2, §4.1), echoed back in the request. A
|
||||||
|
mismatch between the echoed values and the canonical values at
|
||||||
|
verification refuses the conversion. This acknowledgment record
|
||||||
|
is conversion-local, owned by this contract, and stored with the
|
||||||
|
§4.4 audit event as the precondition evidence; it adds no
|
||||||
|
identity-contract obligation and no mode-specific identity
|
||||||
|
default — identity's own defaults remain valid states.
|
||||||
|
3. **Preparation state and the flip boundary.** Preparatory work is
|
||||||
|
tracked durably: each preparation unit (§1.4) records its
|
||||||
|
completion in a preparation table keyed by (bootstrap epoch, unit
|
||||||
|
identity — the secret's path, the user's id), written in the same
|
||||||
|
transaction as the unit's own effect where the unit's backend
|
||||||
|
allows it, and reconciled from the backend's actual state where it
|
||||||
|
does not (a secret already served by Vault, a brain already split,
|
||||||
|
is complete regardless of the table). Units are at-most-once per
|
||||||
|
key and re-runnable across attempts. **Standalone-safe
|
||||||
|
representation:** every preparation unit moves the deployment into
|
||||||
|
a state that is itself valid Standalone configuration (§3 note), so
|
||||||
|
an interrupted preparation leaves a fully operational Standalone
|
||||||
|
deployment reading its state through the ordinary contracts — no
|
||||||
|
rollback, fencing, or special Standalone read path is needed, and
|
||||||
|
no component behavior may key on "preparation in progress".
|
||||||
|
**The flip:** one transaction that (a) locks the mode record, (b)
|
||||||
|
re-verifies every §4.2 precondition after acquiring the lock, and
|
||||||
|
(c) writes the mode record and the §4.4 audit event. Any re-check
|
||||||
|
failure aborts with no write. External state that changes after the
|
||||||
|
re-check but before commit is bounded by the transaction window;
|
||||||
|
an external backend (Vault) failing after conversion is an
|
||||||
|
Enterprise runtime fault handled by §3's fail-closed steady-state
|
||||||
|
rule, not a conversion defect. An interrupted or failed conversion
|
||||||
|
leaves the record `standalone` and the platform fully operational;
|
||||||
|
there is no intermediate mode and no half-converted state
|
||||||
|
observable through the record.
|
||||||
|
4. **Authority and audit.** Conversion is a platform-administrator
|
||||||
|
command carrying an explicit irreversibility acknowledgment in its
|
||||||
|
request (distinct from the §4.2 configuration acknowledgment). It
|
||||||
|
is an official Gateway/CLI command (D8): when built, it is added to
|
||||||
|
the tool↔Gateway mapping by amendment (`tool-gateway-mapping.md`
|
||||||
|
§3.3). The transition emits an audit event (actor, prior mode, new
|
||||||
|
mode, precondition evidence reference including the configuration
|
||||||
|
acknowledgment) in the same transaction as the record change; the
|
||||||
|
event survives indefinitely. A refused attempt emits a refusal
|
||||||
|
event naming the failed precondition class and actor, with no
|
||||||
|
mode-change event.
|
||||||
|
|
||||||
|
## 5. v1 obligations (non-foreclosure)
|
||||||
|
|
||||||
|
v1 ships Standalone only (D11); the conversion command is deferred
|
||||||
|
work. v1 still MUST:
|
||||||
|
|
||||||
|
1. Record the mode per §2 at bootstrap, with `enterprise` a reserved,
|
||||||
|
refused value for bootstrap — v1 bootstrap accepts `standalone`
|
||||||
|
only. The wizard reads the record (contract 3 §2.3); nothing in v1
|
||||||
|
writes it after bootstrap.
|
||||||
|
2. Keep the §2.3 immutability rule: no v1 surface mutates the mode
|
||||||
|
record.
|
||||||
|
3. Not foreclose conversion: the v1 platform database holds no
|
||||||
|
sensitive user content — sensitive categories live in the owning
|
||||||
|
user's brain, and postgres holds structure, consent records, and
|
||||||
|
pointers only (the D14 boundary, PRD §7). Custody mechanics are
|
||||||
|
contract 7's; this contract binds the boundary itself here so v1
|
||||||
|
cannot ship a layout that makes the §4.2 brain precondition
|
||||||
|
unsatisfiable, and §6.3 gives it a stable witness that does not
|
||||||
|
depend on contract 7's internals. Conversion implementation
|
||||||
|
additionally requires contract 7 ratified.
|
||||||
|
4. Not foreclose federation: v1 components accept exactly the two §1.1
|
||||||
|
values wherever a mode value is parsed and refuse any other value
|
||||||
|
**before side effects** — a refused configuration, not undefined
|
||||||
|
behavior and not a crash mid-operation. Forward compatibility lives
|
||||||
|
in storage and architecture, not in parser speculation: the mode
|
||||||
|
record's storage is not structurally locked to two values (no
|
||||||
|
database-level two-value enum), and any future value (e.g. a
|
||||||
|
federation mode) is defined by a versioned amendment to this
|
||||||
|
contract before any component accepts it. The PRD defers
|
||||||
|
federation's shape entirely; this contract does not presume it
|
||||||
|
arrives as a third mode value.
|
||||||
|
|
||||||
|
## 6. Verification requirements
|
||||||
|
|
||||||
|
Binding on the implementing PRs:
|
||||||
|
|
||||||
|
1. **Mode-record witness (v1):** after bootstrap the mode is readable
|
||||||
|
via the Gateway command and CLI and equals the bootstrap-recorded
|
||||||
|
choice; bootstrap with mode `enterprise` is refused; bootstrap with
|
||||||
|
any unknown mode value is refused before side effects (§5.4).
|
||||||
|
2. **Writer-coverage witness (v1):** the mode record's writer set is
|
||||||
|
closed by the same three-prong static assertion contract 1 §6.3(b)
|
||||||
|
defines — symbol, class-table literal, and raw-execution prongs
|
||||||
|
with its allowlist composition rules — scoped to the
|
||||||
|
`platform_mode` table, with a writer allowlist containing exactly
|
||||||
|
the bootstrap writer in v1 (and exactly plus the conversion command
|
||||||
|
at the conversion milestone). Companions: a crafted direct write
|
||||||
|
attempted in a test fails and leaves the record unchanged; a
|
||||||
|
mode-resolution assertion that no shipped component derives mode
|
||||||
|
from feature state (mode reads occur only through the §2.2 read
|
||||||
|
surface — static assertion over Gateway, CLI, bootstrap, and
|
||||||
|
repository sources).
|
||||||
|
3. **D14-boundary witness (v1):** a column-allowlist assertion in the
|
||||||
|
style of contract 1 §6.2 that the platform database schema contains
|
||||||
|
no sensitive-content column — the §5.3 boundary — stable regardless
|
||||||
|
of contract 7's internals (contract 7 §7 carries the full custody
|
||||||
|
witnesses).
|
||||||
|
4. **No-downgrade witness (conversion milestone):** with mode
|
||||||
|
`enterprise`, a conversion request to `standalone` (and any crafted
|
||||||
|
mode-write) is refused with the precondition/state error class and
|
||||||
|
no record change.
|
||||||
|
5. **Precondition witnesses (conversion milestone),** each refused
|
||||||
|
with no record change and no partial mode effect, parameterized
|
||||||
|
over both OpenBao and Vault where secrets are involved:
|
||||||
|
(a) secrets backend unreachable; (b) one required secret still
|
||||||
|
flat-file backed (migration incomplete); (c) one unpartitioned user
|
||||||
|
brain in a **multi-user** deployment where every other user is
|
||||||
|
partitioned; (d) no active platform administrator (the only admin
|
||||||
|
banned or deactivated); (e) configuration acknowledgment missing or
|
||||||
|
mismatching the canonical registration/JIT values; (f) actor not a
|
||||||
|
platform administrator (authorization refusal); (g) irreversibility
|
||||||
|
acknowledgment absent. And the steady-state rule: an
|
||||||
|
Enterprise-mode component started against a flat-file secrets
|
||||||
|
configuration refuses to start (§3).
|
||||||
|
6. **Interruption and fence witnesses (conversion milestone):** fault
|
||||||
|
injection aborting conversion after each preparation unit and
|
||||||
|
between preparation and flip leaves the record `standalone` and the
|
||||||
|
platform operational in Standalone semantics (§4.3
|
||||||
|
Standalone-safety), and a re-attempt completes without duplicating
|
||||||
|
prepared state (at-most-once keys); a precondition invalidated
|
||||||
|
after preparation but before the flip (a secret reverted to
|
||||||
|
flat-file) is caught by the in-transaction re-check and refused.
|
||||||
|
7. **Audit witnesses (conversion milestone):** a completed conversion
|
||||||
|
has exactly one mode-change audit event, same-transaction with the
|
||||||
|
record change (transaction linkage asserted), carrying actor, prior
|
||||||
|
mode, new mode, and the precondition evidence reference including
|
||||||
|
the configuration acknowledgment; a failed attempt has a refusal
|
||||||
|
event naming the failed precondition class and no mode-change
|
||||||
|
event; the mode-change event remains queryable after subsequent
|
||||||
|
unrelated audit activity (retention probe).
|
||||||
|
8. **Mapping witness (conversion milestone):** the conversion command
|
||||||
|
and the mode read command each have their
|
||||||
|
`tool-gateway-mapping.md` row (added by amendment per §2.2/§4.4)
|
||||||
|
before the commands ship.
|
||||||
|
|
||||||
|
## Ruling request
|
||||||
|
|
||||||
|
Ratify sections 1–6 as written, with one decision embedded:
|
||||||
|
|
||||||
|
- Decision (§5): v1 implements the **mode record and its immutability
|
||||||
|
only** — bootstrap records `standalone`, the `enterprise` value is
|
||||||
|
reserved and refused, and the conversion command itself is deferred
|
||||||
|
to the Enterprise milestone, consistent with D11's deferred list.
|
||||||
|
v1 carries three obligations beyond the record: the closed writer
|
||||||
|
assertion, the D14 column boundary, and the unknown-value refusal
|
||||||
|
(§6.1–§6.3) — these are the non-foreclosure floor, not hidden
|
||||||
|
conversion work. Alternative if rejected: build the conversion
|
||||||
|
command inside v1 — rejected because D11 scopes v1 to the Standalone
|
||||||
|
slice and conversion depends on contract 7 custody mechanics that
|
||||||
|
are themselves not in the v1 slice.
|
||||||
@@ -456,3 +456,61 @@ this line is weakened.
|
|||||||
- Negative tests prove roll-up endpoints cannot mutate state and that a
|
- Negative tests prove roll-up endpoints cannot mutate state and that a
|
||||||
reader sees aggregates only over workspaces they are authorized on
|
reader sees aggregates only over workspaces they are authorized on
|
||||||
(no cross-tenant existence oracles).
|
(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,6 +397,14 @@ seed-workspace-scoped mutant, correctly refusing outside the seed
|
|||||||
set, passes branch (c), so the two branches detect distinct
|
set, passes branch (c), so the two branches detect distinct
|
||||||
mutants. No other change.
|
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
|
Scope: the Gateway-backed product onboarding wizard. Out of scope: the
|
||||||
host-local install wizard (`mosaic wizard`, which drives host install and
|
host-local install wizard (`mosaic wizard`, which drives host install and
|
||||||
gateway bootstrap and is not this artifact — audit REPORT.md layer 3);
|
gateway bootstrap and is not this artifact — audit REPORT.md layer 3);
|
||||||
@@ -1169,15 +1177,18 @@ collects no sensitive category, so v1 ships no custody surface.
|
|||||||
party, service actor, or wizard-privileged writer exists in this
|
party, service actor, or wizard-privileged writer exists in this
|
||||||
flow.
|
flow.
|
||||||
2. **Post-bootstrap top-level company creation** — the "N companies" flow
|
2. **Post-bootstrap top-level company creation** — the "N companies" flow
|
||||||
— is decided by the ruling below: any **eligible platform user** MAY
|
— RULED AGREED (Jason, 2026-08-27): any **eligible platform user** MAY
|
||||||
create a top-level company and MUST name an initial `owner` grant in
|
create a top-level company and MUST name an initial `owner` grant in
|
||||||
the same audited operation (contract 2 §4.3); the creator naming
|
the same audited operation (contract 2 §4.3); the creator naming
|
||||||
themselves is the default. Eligible means, in identity-contract
|
themselves is the default. Eligible means, in identity-contract
|
||||||
terms: an authenticated account (identity §2) that is not banned
|
terms: an authenticated account (identity §2) that is not banned
|
||||||
(identity §7.1 — deactivation on this platform IS the better-auth
|
(identity §7.1 — deactivation on this platform IS the better-auth
|
||||||
ban; no separate deactivated state exists). No further role or grant
|
ban; no separate deactivated state exists). No further role or grant
|
||||||
is required. Until that ruling, deny-by-default holds (contract 2
|
is required. Creation always yields a **private** company
|
||||||
§3.1): no implicit creation authority exists.
|
(`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.
|
||||||
3. Child-node creation inside the wizard (estate, project, workspace
|
3. Child-node creation inside the wizard (estate, project, workspace
|
||||||
under the seeded company) follows contract 2 §4.3: parent
|
under the seeded company) follows contract 2 §4.3: parent
|
||||||
`owner` authority, no automatic grant needed — for canonical seed
|
`owner` authority, no automatic grant needed — for canonical seed
|
||||||
@@ -1932,7 +1943,13 @@ contracts and are not additions:
|
|||||||
suffix at all — each contradicting PRD D4's no-lock-in
|
suffix at all — each contradicting PRD D4's no-lock-in
|
||||||
requirement (§4.4).
|
requirement (§4.4).
|
||||||
|
|
||||||
## Ruling request
|
## 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.
|
||||||
|
|
||||||
Ratify sections 1–7 as written, with one decision embedded:
|
Ratify sections 1–7 as written, with one decision embedded:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Tool↔Gateway Mapping Contract (D8)
|
||||||
|
|
||||||
|
Status: DRAFT — awaiting ratification (webui-audit S2, contract 5 of 9).
|
||||||
|
Authority: PRD D8/D12 (Part I §8) — the webUI sits OVER official tooling:
|
||||||
|
every webUI operation goes through the Gateway API backed by the same
|
||||||
|
official framework tooling the CLI uses, and a webUI operation with no
|
||||||
|
backing tool is scored **blocked on tooling** and the tool is built
|
||||||
|
first. Measured input: the webui-audit A5 tooling baseline
|
||||||
|
(operation-by-operation inventory of the current Gateway surface and the
|
||||||
|
P1 gaps, cross-reviewed; `fleet/lanes/webui-audit/findings/
|
||||||
|
A5-tooling-baseline.md` in the estate brain). The T10 ruling adopted the
|
||||||
|
targeted-update plan including building the D8 tools in A5's rank order.
|
||||||
|
|
||||||
|
Revision 2 (GLM review F1–F5): the §2 table completed against an
|
||||||
|
independent re-measurement of the live `apps/web` surface (mission
|
||||||
|
reads, coordination status, capability-gated `turn:send` added); rank-6
|
||||||
|
composition corrected to ranks 1 and 4; SOT citations corrected to §3
|
||||||
|
invariant 11 / REQ-TASK-001 / §5+A1; the §3.2 retirement clause
|
||||||
|
softened to match what the owning contracts actually schedule; §6.1
|
||||||
|
scoped to outbound calls with an extractability lint, and §6.3 given
|
||||||
|
static companions for §4.1 and §4.3.
|
||||||
|
|
||||||
|
This contract binds three things: the operation→tool mapping itself
|
||||||
|
(§2–§3), the command envelope every mapped operation satisfies
|
||||||
|
(§4), and the process rule that keeps the mapping closed (§5). Domain
|
||||||
|
semantics stay with their owning contracts — hierarchy (contract 1,
|
||||||
|
`hierarchy-schema.md`), grants (contract 2, `rbac-grant-model.md`),
|
||||||
|
wizard (contract 3, `onboarding-wizard.md`), identity
|
||||||
|
(`identity-lifecycle.md`), kanban lifecycle (`native-kanban-sot.md`
|
||||||
|
§5 and Amendment A1), roll-up (contract 8), API artifact format
|
||||||
|
(contract 9).
|
||||||
|
|
||||||
|
## 1. Definitions
|
||||||
|
|
||||||
|
1. **Official tool**: a command implemented in the framework packages and
|
||||||
|
exposed through the Gateway API; the CLI remains the primary execution
|
||||||
|
method for the same command (D8). The webUI is a Gateway client only.
|
||||||
|
2. **Mapped operation**: a webUI operation with a named official path in
|
||||||
|
§2 or §3. Anything else the webUI wants to do is unmapped and follows
|
||||||
|
§5.
|
||||||
|
3. **Legacy non-substitute**: an existing endpoint that resembles a P1
|
||||||
|
need but is contractually barred from backing it (§3.2).
|
||||||
|
|
||||||
|
## 2. P0 mapping (current operations, ratified as-is)
|
||||||
|
|
||||||
|
This table is the complete measured P0 surface: every Gateway call the
|
||||||
|
web app's production sources make at this revision's head appears as a
|
||||||
|
row (independently re-measured at review; the three calls the first
|
||||||
|
measurement missed — mission reads, coordination status, and the
|
||||||
|
capability-gated `turn:send` emit — are rows below). The surface stays
|
||||||
|
bound to these paths:
|
||||||
|
|
||||||
|
| WebUI operation | Official path |
|
||||||
|
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Register / log in / log out / OIDC callback | better-auth mount `/api/auth/*`; `GET /api/sso/providers` |
|
||||||
|
| List/show projects (legacy read) | `GET /api/projects`, `GET /api/projects/:id` |
|
||||||
|
| List tasks / task detail (legacy read) | `GET /api/tasks`, `GET /api/tasks/:id` — with the filtered legacy project/mission reads the same surfaces use |
|
||||||
|
| Mission list (legacy read) | `GET /api/missions` |
|
||||||
|
| Coordination status (legacy read) | `GET /api/coord/status` |
|
||||||
|
| Conversation CRUD/search/messages | `/api/conversations*` |
|
||||||
|
| Chat turn / stop / thinking / command execute+approve / streaming | `/chat` socket events `message`, `abort`, `set:thinking`, `command:execute`, `command:approve`; `turn:send` (capability-gated — emitted only when the server advertises the pi turn-runtime capability, which the current Gateway does not) |
|
||||||
|
| Harness/model selection | `GET /api/harnesses*`, `GET/PUT /api/chat/preferences/selection` |
|
||||||
|
| Preferences; provider inspect/test | `/api/memory/preferences`, `GET /api/providers`, `POST /api/providers/test` |
|
||||||
|
| Admin users / roles / ban / health | `/api/admin/users*`, `/api/admin/health` |
|
||||||
|
|
||||||
|
P0 rows inherit §4 obligations as their backing controllers are next
|
||||||
|
touched; they are not required to be retrofitted in one sweep.
|
||||||
|
|
||||||
|
## 3. P1 mapping (bound to the build-first tools)
|
||||||
|
|
||||||
|
1. Every P1 operation maps to exactly one build-first command family, in
|
||||||
|
the T10-ruled rank order:
|
||||||
|
|
||||||
|
| Rank | Command family (owning contract) | P1 webUI operations it backs |
|
||||||
|
| ---- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | Hierarchy command family (contract 1 §5; grants attach per contract 2) | Company/estate/platform-project/workspace CRUD, parentage and reparenting, hierarchy reads; the wizard's initial-hierarchy step (contract 3 §3.4) |
|
||||||
|
| 2 | Hierarchy RBAC command/evaluator (contract 2) | Grant create/change/revoke at company/estate/platform-project; inherited evaluation down to workspace; authorization-safe hierarchy queries |
|
||||||
|
| 3 | Typed kanban command/query surface (SOT §5, Amendment A1) | Workspace task lifecycle (create/edit/cancel/archive/move), board rank, typed queries |
|
||||||
|
| 4 | Agent enrollment command | Enroll one agent: harness, credential reference/API-key intake (values never echoed), name/persona, assignment scope (contract 3 §3.5) |
|
||||||
|
| 5 | Authorized roll-up query (contract 8) | Read-only aggregated task counts/statuses at every hierarchy level over readable workspaces only |
|
||||||
|
| 6 | Onboarding orchestration (contract 3) | The re-runnable wizard flow, composing ranks 1 and 4 (its only grant write rides inside the rank-1 company-create command, contract 2 §4.3) |
|
||||||
|
|
||||||
|
2. **Legacy non-substitutes.** The following MUST NOT back any P1
|
||||||
|
operation, matching the audit findings: legacy `/api/projects` and
|
||||||
|
`/api/tasks` CRUD (planning-data records, not hierarchy nodes and not
|
||||||
|
the typed kanban boundary); `POST /api/workspaces` (filesystem
|
||||||
|
bootstrap, not audited hierarchy parentage); `/api/teams` reads (no
|
||||||
|
grants, no inheritance); `POST /api/bootstrap/setup` (one-shot
|
||||||
|
epoch transition, identity §3 — not the re-runnable wizard); the MCP
|
||||||
|
`brain_*` task mutations (legacy Brain writes, not the typed kanban
|
||||||
|
commands). These stay serving their existing P0/host consumers until
|
||||||
|
the owning contract (or a successor amendment) schedules each
|
||||||
|
retirement — no such migration is scheduled at this revision; the
|
||||||
|
freeze stands on its own.
|
||||||
|
3. New P1 mapping rows (operations this table does not list) are added by
|
||||||
|
amending this contract, not ad hoc (§5).
|
||||||
|
|
||||||
|
## 4. Command envelope (request / result / error / audit)
|
||||||
|
|
||||||
|
Binding on every mapped operation the build-first families expose:
|
||||||
|
|
||||||
|
1. **Typed request and result.** Each command and query has an explicit
|
||||||
|
request DTO and result DTO in the shared types package, validated at
|
||||||
|
the Gateway boundary; unvalidated pass-through and `any`-typed
|
||||||
|
payloads are non-conformant. Mutations on records with an
|
||||||
|
expected-version rule in their owning contract carry the expected
|
||||||
|
version in the request and fail on mismatch with the conflict error
|
||||||
|
class (SOT §3 invariant 11 and REQ-TASK-001's concurrent-update
|
||||||
|
conflict acceptance; hierarchy per contract 1).
|
||||||
|
2. **Error taxonomy.** Every error result carries a stable
|
||||||
|
machine-readable code from a closed per-family enum plus an HTTP
|
||||||
|
status mapping, distinguishing at minimum: validation failure,
|
||||||
|
authentication failure, authorization refusal, not-found, conflict
|
||||||
|
(version/uniqueness), precondition/state refusal (e.g. bootstrap
|
||||||
|
epoch, suspended team subjects), and internal fault. Where contract
|
||||||
|
2's no-existence-oracle rule applies, authorization refusal and
|
||||||
|
not-found are indistinguishable on the wire for unauthorized readers
|
||||||
|
— same code, same status, same shape.
|
||||||
|
3. **Audit linkage.** A mutating mapped operation emits exactly the
|
||||||
|
audit events its owning contract defines (contract 1 §5.2, contract 2
|
||||||
|
§4.4, identity §§2–4, SOT audit rules); the envelope contributes the
|
||||||
|
correlation: every request accepts/generates a correlation id,
|
||||||
|
carried into the audit events and returned in the result, so a UI
|
||||||
|
action is traceable end to end. The mapping layer itself adds no
|
||||||
|
second audit stream.
|
||||||
|
4. **Fail-closed.** A mapped operation that cannot evaluate its
|
||||||
|
authorization or reach its owning tool refuses (contract 2 §3.5); the
|
||||||
|
envelope never degrades to an unauthorized fallback read or a direct
|
||||||
|
data access.
|
||||||
|
5. **CLI parity.** Each build-first family is invocable through the
|
||||||
|
official CLI against the same Gateway commands with the same
|
||||||
|
request/result/error contracts. No webUI-only command exists; a
|
||||||
|
Gateway command without CLI exposure is a conformance gap tracked at
|
||||||
|
the family's implementing issue.
|
||||||
|
|
||||||
|
## 5. Closure rule (blocked on tooling)
|
||||||
|
|
||||||
|
1. A webUI change that needs an operation with no mapping row is
|
||||||
|
**blocked on tooling**: the backing tool is built and mapped first
|
||||||
|
(D8). Scoring a gap "blocked on tooling" is mandatory, not
|
||||||
|
discretionary; working around it in the UI (direct DB or filesystem
|
||||||
|
access, calling a legacy non-substitute, embedding domain logic in
|
||||||
|
the web app) is non-conformant.
|
||||||
|
2. The mapping is enforced closed by §6.1's inventory witness: the web
|
||||||
|
app's network surface must be a subset of the mapped paths.
|
||||||
|
|
||||||
|
## 6. Verification requirements
|
||||||
|
|
||||||
|
Binding on the implementing PRs:
|
||||||
|
|
||||||
|
1. **Network-surface inventory witness:** a CI assertion extracting the
|
||||||
|
web app's outbound Gateway calls — route literals at request call
|
||||||
|
sites and outbound socket emits in `apps/web` sources (inbound
|
||||||
|
handler registrations are not calls and are out of scope) — and
|
||||||
|
failing on any call outside the §2/§3 mapped paths. The inventory is
|
||||||
|
closed like contract 1 §6.3's allowlist: a new call fails until a
|
||||||
|
mapping row exists in the same PR. Dynamic route construction that
|
||||||
|
evades extraction is resolved toward the witness, enforced by an
|
||||||
|
extractability lint: every request call site takes a literal or
|
||||||
|
template-literal path, and a call site that does not fails the
|
||||||
|
assertion itself (the web-side analogue of contract 1's
|
||||||
|
raw-execution prong), never an exemption for the caller.
|
||||||
|
2. **Non-substitute witness:** the P1 surfaces (hierarchy, RBAC, kanban,
|
||||||
|
enrollment, roll-up, wizard UI) make zero calls to the §3.2 legacy
|
||||||
|
endpoints — asserted by the same inventory, scoped per surface.
|
||||||
|
3. **Envelope witnesses per family:** for each build-first family — a
|
||||||
|
request with an invalid DTO is refused with the validation code; a
|
||||||
|
version-mismatch mutation returns the conflict code; an unauthorized
|
||||||
|
read of an existing node and a read of a nonexistent node return
|
||||||
|
indistinguishable results where the no-existence-oracle rule applies;
|
||||||
|
a correlation id submitted on a mutation appears in its audit
|
||||||
|
event(s) and result. Two static companions: a type-level assertion
|
||||||
|
that the family's boundary accepts no `any`-typed or unvalidated
|
||||||
|
pass-through payload (§4.1), and a single-emitter assertion that the
|
||||||
|
mapped operation's audit events originate only from the owning
|
||||||
|
contract's audit emitter (§4.3's no-second-audit-stream, made
|
||||||
|
checkable).
|
||||||
|
4. **CLI-parity witness:** for each family, a CLI smoke invocation of at
|
||||||
|
least one command and one query against the Gateway succeeds with the
|
||||||
|
same typed result the web client receives.
|
||||||
|
5. **Fail-closed witness:** with the owning tool or grant state
|
||||||
|
unreachable (fault injection), the mapped operation returns the
|
||||||
|
internal-fault or authorization-refusal class and performs no
|
||||||
|
fallback read/write (extends contract 2 §7.6 to the mapping layer).
|
||||||
|
|
||||||
|
## Ruling request
|
||||||
|
|
||||||
|
Ratify sections 1–6 as written, with one decision embedded:
|
||||||
|
|
||||||
|
- Decision (§3.2): the legacy endpoints named there are **frozen for new
|
||||||
|
consumers** as of ratification — existing P0/host consumers keep
|
||||||
|
working, new UI or tool code may not call them, and each is retired by
|
||||||
|
the migration its owning contract schedules. Alternative if rejected:
|
||||||
|
allow P1 surfaces to reuse legacy endpoints as interim backends —
|
||||||
|
rejected by the audit's finding that they cannot satisfy the
|
||||||
|
hierarchy/kanban/RBAC contracts, so the interim would ship
|
||||||
|
non-conformant semantics.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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");
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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,6 +127,20 @@
|
|||||||
"when": 1787609223282,
|
"when": 1787609223282,
|
||||||
"tag": "0017_accounts_issuer",
|
"tag": "0017_accounts_issuer",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
/**
|
||||||
|
* 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,6 +3,8 @@
|
|||||||
* drizzle-kit reads this file directly (avoids CJS/ESM extension issues).
|
* 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 {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
@@ -13,6 +15,8 @@ import {
|
|||||||
jsonb,
|
jsonb,
|
||||||
index,
|
index,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
|
unique,
|
||||||
|
check,
|
||||||
real,
|
real,
|
||||||
integer,
|
integer,
|
||||||
bigint,
|
bigint,
|
||||||
@@ -1048,3 +1052,212 @@ export const federationEnrollmentTokens = pgTable('federation_enrollment_tokens'
|
|||||||
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
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,6 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
|
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
|
||||||
# Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]
|
# Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]
|
||||||
|
# (-c/--comment is a backward-compatible alias for -b/--body; R1, 2026-08-28)
|
||||||
#
|
#
|
||||||
# tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`);
|
# 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
|
# the non-existent `tea issue comment ...` form does not error — tea silently
|
||||||
@@ -32,45 +33,61 @@ ISSUE_NUMBER=""
|
|||||||
COMMENT=""
|
COMMENT=""
|
||||||
LOGIN_OVERRIDE=""
|
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
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue)
|
-i|--issue)
|
||||||
|
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
||||||
ISSUE_NUMBER="$2"
|
ISSUE_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-c|--comment)
|
-b|--body|-c|--comment)
|
||||||
|
# R1 (2026-08-28): --body is the canonical flag, matching
|
||||||
|
# issue-create/issue-edit/pr-create/pr-edit; -c/--comment stays a
|
||||||
|
# backward-compatible alias.
|
||||||
|
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-l|--login)
|
-l|--login)
|
||||||
|
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
||||||
LOGIN_OVERRIDE="$2"
|
LOGIN_OVERRIDE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
|
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -i, --issue Issue number (required)"
|
echo " -i, --issue Issue number (required)"
|
||||||
echo " -c, --comment Comment text (required)"
|
echo " -b, --body Comment text (required; canonical)"
|
||||||
|
echo " -c, --comment Alias for --body"
|
||||||
echo " -l, --login Override the detected Gitea tea login for this call"
|
echo " -l, --login Override the detected Gitea tea login for this call"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
|
echo ""
|
||||||
|
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1"
|
usage_error "unknown option: $1"
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||||
echo "Error: Issue number is required (-i)"
|
usage_error "issue number is required (-i/--issue)"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$COMMENT" ]]; then
|
if [[ -z "$COMMENT" ]]; then
|
||||||
echo "Error: Comment is required (-c)"
|
usage_error "comment is required (-b/--body, or the -c/--comment alias)"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
@@ -340,7 +357,15 @@ PY
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
# R4 exit-code contract: normalize provider failures to exit 1. gh's own
|
||||||
|
# usage errors exit 2, which would collide with this wrapper's reserved
|
||||||
|
# usage-error status if propagated raw (codex review of 08a00149).
|
||||||
|
gh_rc=0
|
||||||
|
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
|
||||||
|
if [[ "$gh_rc" -ne 0 ]]; then
|
||||||
|
echo "Error: GitHub comment write failed (gh exit $gh_rc; provider/credential failure — usage errors are exit 2)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
# A --login override selects a NAMED tea credential and is the only way to
|
# A --login override selects a NAMED tea credential and is the only way to
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
|
# 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
|
# 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.
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -409,11 +411,28 @@ run_comment() {
|
|||||||
seed_state "$mode"
|
seed_state "$mode"
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
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" \
|
PATH="$BIN_DIR:$PATH" \
|
||||||
TMPDIR="$TMP_SCRATCH" \
|
TMPDIR="$TMP_SCRATCH" \
|
||||||
HOME="$HOME_DIR" \
|
HOME="$HOME_DIR" \
|
||||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" \
|
||||||
|
MOSAIC_BRAIN_HOME="" \
|
||||||
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
||||||
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
|
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
|
||||||
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
|
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
|
||||||
@@ -430,7 +449,7 @@ run_comment() {
|
|||||||
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
|
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
|
||||||
ISSUE_COMMENT_API_BASE="$API_BASE" \
|
ISSUE_COMMENT_API_BASE="$API_BASE" \
|
||||||
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
|
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
|
||||||
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" "$@"
|
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" "${BODY_FLAG:--c}" "$BODY" "$@"
|
||||||
) > "$OUTPUT_FILE" 2>&1
|
) > "$OUTPUT_FILE" 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,4 +633,21 @@ done
|
|||||||
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
|
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
|
||||||
# is not rejecting genuine writes.
|
# 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"
|
echo "issue-comment.sh REST create + exact-id read-back regression passed"
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
#!/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,7 +14,6 @@
|
|||||||
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
|
packages/mosaic/framework/tools/git/test-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-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-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 ---
|
# --- 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
|
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",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"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-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-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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Read-only adapters for the installation config pipeline (§16).
|
||||||
|
*
|
||||||
|
* Every adapter is a bounded, side-effect-free read. The registry resolver
|
||||||
|
* is DEPENDENCY-BLOCKED (§2.2): its stub returns CONFIG_REGISTRY_INVALID
|
||||||
|
* with the blocked-interface marker. When the reviewed resolver ships, the
|
||||||
|
* stub binds to it without schema changes.
|
||||||
|
*/
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ConfigDiagnostic,
|
||||||
|
type RegistryProvenance,
|
||||||
|
type InstallationBindings,
|
||||||
|
type FrameworkBindingDefaults,
|
||||||
|
FRAMEWORK_BINDING_DEFAULTS,
|
||||||
|
REGISTRY_RESOLVER_BLOCKED,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
// ─── Digest helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** SHA-256 over canonical JSON with recursively sorted keys (§9). */
|
||||||
|
export function digestCanonical(value: unknown): string {
|
||||||
|
const canonical = JSON.stringify(sortKeysDeep(value));
|
||||||
|
return crypto.createHash('sha256').update(canonical).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortKeysDeep(value: unknown): unknown {
|
||||||
|
if (value === null || typeof value !== 'object') return value;
|
||||||
|
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
||||||
|
const sorted: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
||||||
|
sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function digestBytes(content: string | Buffer): string {
|
||||||
|
return crypto.createHash('sha256').update(content).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 1. Registry resolver adapter (§16, dependency-blocked) ──────────────────
|
||||||
|
|
||||||
|
export interface RegistryAdapter {
|
||||||
|
resolve(): { provenance: RegistryProvenance; diagnostics: ConfigDiagnostic[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEPENDENCY GATE (§2.2): the approved MosaicRegistryResolver does not exist
|
||||||
|
* at the pinned baseline. This stub returns the blocked marker. When the
|
||||||
|
* reviewed resolver ships (CFG-REQ-001..006), replace this stub's resolve()
|
||||||
|
* to delegate to it. The interface is stable.
|
||||||
|
*/
|
||||||
|
export class BlockedRegistryAdapter implements RegistryAdapter {
|
||||||
|
resolve(): { provenance: RegistryProvenance; diagnostics: ConfigDiagnostic[] } {
|
||||||
|
return {
|
||||||
|
provenance: {
|
||||||
|
resolved: false,
|
||||||
|
brainHome: null,
|
||||||
|
sourceKeys: [],
|
||||||
|
},
|
||||||
|
diagnostics: [
|
||||||
|
{
|
||||||
|
code: 'CONFIG_REGISTRY_INVALID',
|
||||||
|
message: REGISTRY_RESOLVER_BLOCKED,
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2. Bounded file reader (§14.1, §14.2) ───────────────────────────────────
|
||||||
|
|
||||||
|
export interface BoundedReadResult {
|
||||||
|
ok: boolean;
|
||||||
|
content?: string;
|
||||||
|
diagnostics: ConfigDiagnostic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function boundedRead(filePath: string, context: string): BoundedReadResult {
|
||||||
|
const diagnostics: ConfigDiagnostic[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = fs.lstatSync(filePath);
|
||||||
|
if (stat.isSymbolicLink()) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_ADAPTER_UNAVAILABLE',
|
||||||
|
message: `${context}: symlink input rejected`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_ADAPTER_UNAVAILABLE',
|
||||||
|
message: `${context}: not a regular file`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
if (stat.size > 1024 * 1024) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_ADAPTER_UNAVAILABLE',
|
||||||
|
message: `${context}: file exceeds 1 MiB limit`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return { ok: true, content, diagnostics: [] };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_MISSING',
|
||||||
|
message: `${context}: file not found at ${filePath}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_ADAPTER_UNAVAILABLE',
|
||||||
|
message: `${context}: read error: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 3. Bindings resolution (§5.2, §7, A5) ───────────────────────────────────
|
||||||
|
|
||||||
|
export interface ResolvedBindings {
|
||||||
|
runtime: string;
|
||||||
|
runtimeByClass: Record<string, string>;
|
||||||
|
workingDirectory: string;
|
||||||
|
source: 'file' | 'framework-default';
|
||||||
|
digest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBindings(
|
||||||
|
bindings: InstallationBindings | null,
|
||||||
|
frameworkDefaults?: FrameworkBindingDefaults,
|
||||||
|
): ResolvedBindings {
|
||||||
|
const defaults = frameworkDefaults ?? FRAMEWORK_BINDING_DEFAULTS;
|
||||||
|
if (!bindings) {
|
||||||
|
return {
|
||||||
|
runtime: defaults.runtime,
|
||||||
|
runtimeByClass: {},
|
||||||
|
workingDirectory: defaults.workingDirectory,
|
||||||
|
source: 'framework-default',
|
||||||
|
digest: digestCanonical(defaults),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fleet = bindings.spec.fleet;
|
||||||
|
return {
|
||||||
|
runtime: fleet.runtime?.default ?? defaults.runtime,
|
||||||
|
runtimeByClass: fleet.runtime?.byClass ?? {},
|
||||||
|
workingDirectory: fleet.workingDirectory ?? defaults.workingDirectory,
|
||||||
|
source: 'file',
|
||||||
|
digest: digestCanonical(bindings),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 4. Git tracking/ignore probe (§7.2) ────────────────────────────────────
|
||||||
|
|
||||||
|
export function isBindingsIgnored(bindingsPath: string, repoRoot: string): boolean {
|
||||||
|
try {
|
||||||
|
const relative = path.relative(repoRoot, bindingsPath);
|
||||||
|
if (relative.startsWith('..')) return true; // outside repo = not tracked
|
||||||
|
|
||||||
|
// Check if the file is tracked
|
||||||
|
try {
|
||||||
|
execSync(`git ls-files --error-unmatch "${relative}"`, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||||
|
});
|
||||||
|
return false; // tracked = NOT ignored
|
||||||
|
} catch {
|
||||||
|
// Not tracked; check if ignored
|
||||||
|
try {
|
||||||
|
execSync(`git check-ignore "${relative}"`, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||||
|
});
|
||||||
|
return true; // check-ignore succeeded = ignored
|
||||||
|
} catch {
|
||||||
|
return false; // not tracked but not ignored either = fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return true; // no git evidence = treat as ignored (valid absence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 5. Blueprint path resolver ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function resolveBlueprintPath(brainHome: string): string {
|
||||||
|
return path.join(brainHome, 'fleet', 'configuration', 'installation.yaml');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBindingsPath(brainHome: string): string {
|
||||||
|
return path.join(brainHome, 'config', 'installation.local.yaml');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRosterPath(brainHome: string): string {
|
||||||
|
return path.join(brainHome, 'fleet', 'roster.yaml');
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
/**
|
||||||
|
* InstallationConfigCore — shared pure pipeline for validate and plan (§9).
|
||||||
|
*
|
||||||
|
* Both commands call this core in the same order. No mutation, no network,
|
||||||
|
* no subprocess. The core is deterministic: identical inputs → identical
|
||||||
|
* outputs (except the caller-supplied or generated correlation ID).
|
||||||
|
*/
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type InstallationBlueprint,
|
||||||
|
type InstallationBindings,
|
||||||
|
type ConfigDiagnostic,
|
||||||
|
type ConfigValidationDataV1,
|
||||||
|
type ConfigPlanDataV1,
|
||||||
|
type ConfigPlanActionV1,
|
||||||
|
type ConfigPlanFieldDiff,
|
||||||
|
EXIT_OK,
|
||||||
|
EXIT_INVALID,
|
||||||
|
EXIT_NONCONFORMANT,
|
||||||
|
EXIT_UNAVAILABLE,
|
||||||
|
BOOTSTRAP_MINIMAL_V1,
|
||||||
|
} from './types.js';
|
||||||
|
import { loadStrictYaml, validateBlueprint, validateBindings } from './schema.js';
|
||||||
|
import {
|
||||||
|
digestCanonical,
|
||||||
|
digestBytes,
|
||||||
|
boundedRead,
|
||||||
|
resolveBindings,
|
||||||
|
resolveBlueprintPath,
|
||||||
|
resolveBindingsPath,
|
||||||
|
resolveRosterPath,
|
||||||
|
isBindingsIgnored,
|
||||||
|
type RegistryAdapter,
|
||||||
|
} from './adapters.js';
|
||||||
|
|
||||||
|
// ─── Pipeline input/output ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CoreInput {
|
||||||
|
registryAdapter: RegistryAdapter;
|
||||||
|
/** Explicit blueprint file path, or null to use --preset. */
|
||||||
|
filePath: string | null;
|
||||||
|
/** Preset ID, or null to use --file. */
|
||||||
|
presetId: string | null;
|
||||||
|
/** Resolved brainHome (from registry). */
|
||||||
|
brainHome: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoreResult {
|
||||||
|
exitCode: number;
|
||||||
|
diagnostics: ConfigDiagnostic[];
|
||||||
|
validationData?: ConfigValidationDataV1;
|
||||||
|
planData?: ConfigPlanDataV1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── The pipeline (§9, steps 1-11) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export function runPipeline(input: CoreInput, mode: 'validate' | 'plan'): CoreResult {
|
||||||
|
const diagnostics: ConfigDiagnostic[] = [];
|
||||||
|
|
||||||
|
// Step 1: resolve central registry
|
||||||
|
const registryResult = input.registryAdapter.resolve();
|
||||||
|
diagnostics.push(...registryResult.diagnostics);
|
||||||
|
if (diagnostics.some((d) => d.code === 'CONFIG_REGISTRY_INVALID')) {
|
||||||
|
return { exitCode: EXIT_UNAVAILABLE, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
const brainHome = input.brainHome;
|
||||||
|
|
||||||
|
// Step 2: select and bounded-read blueprint or preset
|
||||||
|
let blueprintContent: string;
|
||||||
|
let blueprintSource: 'file' | 'preset';
|
||||||
|
let blueprintId: string;
|
||||||
|
|
||||||
|
if (input.presetId) {
|
||||||
|
if (input.presetId !== BOOTSTRAP_MINIMAL_V1.id) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_PRESET_UNKNOWN' as const,
|
||||||
|
message: `Unknown preset '${input.presetId}'. Available: ${BOOTSTRAP_MINIMAL_V1.id}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
blueprintContent = JSON.stringify(BOOTSTRAP_MINIMAL_V1.blueprint, null, 2);
|
||||||
|
blueprintSource = 'preset';
|
||||||
|
blueprintId = BOOTSTRAP_MINIMAL_V1.id;
|
||||||
|
} else {
|
||||||
|
const bpPath = input.filePath ?? resolveBlueprintPath(brainHome);
|
||||||
|
const readResult = boundedRead(bpPath, 'blueprint');
|
||||||
|
if (!readResult.ok) {
|
||||||
|
diagnostics.push(...readResult.diagnostics);
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
blueprintContent = readResult.content!;
|
||||||
|
blueprintSource = 'file';
|
||||||
|
blueprintId = bpPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blueprintDigest = digestBytes(blueprintContent);
|
||||||
|
|
||||||
|
// Step 3: bounded-read optional bindings
|
||||||
|
const bindingsPath = resolveBindingsPath(brainHome);
|
||||||
|
const bindingsRead = boundedRead(bindingsPath, 'bindings');
|
||||||
|
let bindings: InstallationBindings | null = null;
|
||||||
|
let bindingsDigest: string;
|
||||||
|
|
||||||
|
if (bindingsRead.ok && bindingsRead.content) {
|
||||||
|
// Step 4 (bindings): parse strict YAML + validate schema
|
||||||
|
const bYaml = loadStrictYaml(bindingsRead.content, 'bindings');
|
||||||
|
if (!bYaml.ok) {
|
||||||
|
diagnostics.push(...bYaml.diagnostics);
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
const bValid = validateBindings(bYaml.value, 'bindings');
|
||||||
|
if (!bValid.ok) {
|
||||||
|
diagnostics.push(...bValid.diagnostics);
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
bindings = bValid.bindings!;
|
||||||
|
|
||||||
|
// §7.2: bindings must be ignored/untracked
|
||||||
|
if (!isBindingsIgnored(bindingsPath, brainHome)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_NOT_IGNORED',
|
||||||
|
message: `Host bindings at ${bindingsPath} are tracked or not ignored`,
|
||||||
|
path: bindingsPath,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
bindingsDigest = digestCanonical(bindings);
|
||||||
|
} else if (bindingsRead.diagnostics.some((d) => d.code === 'CONFIG_BLUEPRINT_MISSING')) {
|
||||||
|
// Absent bindings = valid (§7.1)
|
||||||
|
bindingsDigest = digestCanonical(null);
|
||||||
|
} else {
|
||||||
|
diagnostics.push(...bindingsRead.diagnostics);
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 (blueprint): parse strict YAML + validate schema
|
||||||
|
const yamlResult = loadStrictYaml(blueprintContent, 'blueprint');
|
||||||
|
if (!yamlResult.ok) {
|
||||||
|
diagnostics.push(...yamlResult.diagnostics);
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
const bpValid = validateBlueprint(yamlResult.value, 'blueprint');
|
||||||
|
if (!bpValid.ok) {
|
||||||
|
diagnostics.push(...bpValid.diagnostics);
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
const blueprint = bpValid.blueprint!;
|
||||||
|
|
||||||
|
// Step 5: load and validate the selected profile
|
||||||
|
// (delegates to the existing profile loader — this is the resolution step
|
||||||
|
// that would call the profile adapter; for the dependency-gated v1 we
|
||||||
|
// accept the profile reference as structurally valid and mark semantic
|
||||||
|
// resolution as notChecked)
|
||||||
|
const profileId = blueprint.spec.fleet.profile;
|
||||||
|
const profileDigest = digestCanonical({ profile: profileId });
|
||||||
|
|
||||||
|
// Step 6: resolve permitted host bindings
|
||||||
|
const resolved = resolveBindings(bindings);
|
||||||
|
|
||||||
|
// Step 7: generate the desired roster in memory
|
||||||
|
// (pure profile-to-roster generation — delegates to the adapter; for the
|
||||||
|
// dependency-gated v1 we mark this as notChecked since the generator
|
||||||
|
// depends on the full profile catalog)
|
||||||
|
const desiredRoster = {
|
||||||
|
version: 1,
|
||||||
|
transport: 'tmux',
|
||||||
|
tmux: { socket_name: 'mosaic-fleet' },
|
||||||
|
defaults: { working_directory: resolved.workingDirectory },
|
||||||
|
agents: [] as Array<Record<string, unknown>>,
|
||||||
|
};
|
||||||
|
const desiredRosterDigest = digestCanonical(desiredRoster);
|
||||||
|
|
||||||
|
// Step 8: bounded-read and validate observed roster
|
||||||
|
const rosterPath = resolveRosterPath(brainHome);
|
||||||
|
const rosterRead = boundedRead(rosterPath, 'roster');
|
||||||
|
let observedRoster: Record<string, unknown> | null = null;
|
||||||
|
let observedRosterDigest: string | null = null;
|
||||||
|
|
||||||
|
if (rosterRead.ok && rosterRead.content) {
|
||||||
|
const rYaml = loadStrictYaml(rosterRead.content, 'roster');
|
||||||
|
if (!rYaml.ok) {
|
||||||
|
diagnostics.push(...rYaml.diagnostics);
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
observedRoster = rYaml.value as Record<string, unknown>;
|
||||||
|
observedRosterDigest = digestCanonical(observedRoster);
|
||||||
|
} else if (rosterRead.diagnostics.some((d) => d.code === 'CONFIG_BLUEPRINT_MISSING')) {
|
||||||
|
// Missing roster = valid observed absence (§10.1)
|
||||||
|
observedRoster = null;
|
||||||
|
observedRosterDigest = null;
|
||||||
|
} else {
|
||||||
|
diagnostics.push(...rosterRead.diagnostics);
|
||||||
|
return { exitCode: EXIT_UNAVAILABLE, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 9-10: normalize and compare semantically
|
||||||
|
const conformant = observedRoster !== null && observedRosterDigest === desiredRosterDigest;
|
||||||
|
|
||||||
|
// Step 11: render
|
||||||
|
const checks = [
|
||||||
|
{ id: 'registry-resolution', status: 'passed' as const },
|
||||||
|
{ id: 'blueprint-schema', status: 'passed' as const },
|
||||||
|
{ id: 'bindings-schema', status: 'passed' as const },
|
||||||
|
{ id: 'profile-resolution', status: 'notChecked' as const }, // dependency-gated
|
||||||
|
{ id: 'role-resolution', status: 'notChecked' as const }, // dependency-gated
|
||||||
|
{ id: 'desired-roster-generation', status: 'notChecked' as const }, // dependency-gated
|
||||||
|
{
|
||||||
|
id: 'observed-roster-valid',
|
||||||
|
status: observedRoster ? ('passed' as const) : ('notChecked' as const),
|
||||||
|
},
|
||||||
|
{ id: 'conformance', status: conformant ? ('passed' as const) : ('failed' as const) },
|
||||||
|
{ id: 'operational-availability', status: 'notChecked' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
const validationData: ConfigValidationDataV1 = {
|
||||||
|
resultSchemaVersion: 1,
|
||||||
|
valid: true,
|
||||||
|
conformant,
|
||||||
|
blueprint: { source: blueprintSource, id: blueprintId, digest: blueprintDigest },
|
||||||
|
bindings: { source: resolved.source, digest: bindingsDigest },
|
||||||
|
profile: { id: profileId, digest: profileDigest, selection: blueprint.spec.fleet.selection },
|
||||||
|
observed: { roster: observedRoster ? 'present' : 'absent', digest: observedRosterDigest },
|
||||||
|
checks,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!conformant) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_NONCONFORMANT',
|
||||||
|
message: observedRoster
|
||||||
|
? 'Observed roster differs from desired state within the v1 ownership mask'
|
||||||
|
: 'Observed roster is absent; desired state requires one',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
if (observedRoster === null) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_ROSTER_MISSING' as const,
|
||||||
|
message: 'Observed roster absent at expected path',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'validate') {
|
||||||
|
return {
|
||||||
|
exitCode: conformant ? EXIT_OK : EXIT_NONCONFORMANT,
|
||||||
|
diagnostics,
|
||||||
|
validationData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan mode: compute actions
|
||||||
|
const actions = computeActions(
|
||||||
|
blueprint,
|
||||||
|
desiredRoster,
|
||||||
|
observedRoster,
|
||||||
|
desiredRosterDigest,
|
||||||
|
observedRosterDigest,
|
||||||
|
);
|
||||||
|
|
||||||
|
const planId = computePlanId(
|
||||||
|
blueprintDigest,
|
||||||
|
bindingsDigest,
|
||||||
|
profileDigest,
|
||||||
|
observedRosterDigest,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
|
||||||
|
const planData: ConfigPlanDataV1 = {
|
||||||
|
resultSchemaVersion: 1,
|
||||||
|
planSchemaVersion: 1,
|
||||||
|
planId,
|
||||||
|
applySupported: false,
|
||||||
|
valid: true,
|
||||||
|
conformant,
|
||||||
|
changeCount: actions.filter((a) => a.operation !== 'blocked').length,
|
||||||
|
blockedCount: actions.filter((a) => a.operation === 'blocked').length,
|
||||||
|
inputs: {
|
||||||
|
blueprintDigest,
|
||||||
|
bindingsDigest,
|
||||||
|
profileDigest,
|
||||||
|
observedRosterDigest,
|
||||||
|
},
|
||||||
|
actions,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: EXIT_OK, // plan returns 0 whether zero or more actions (§11.1)
|
||||||
|
diagnostics,
|
||||||
|
validationData,
|
||||||
|
planData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Action computation (§11.2) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function computeActions(
|
||||||
|
_blueprint: InstallationBlueprint,
|
||||||
|
desiredRoster: Record<string, unknown>,
|
||||||
|
observedRoster: Record<string, unknown> | null,
|
||||||
|
desiredDigest: string,
|
||||||
|
observedDigest: string | null,
|
||||||
|
): ConfigPlanActionV1[] {
|
||||||
|
const actions: ConfigPlanActionV1[] = [];
|
||||||
|
|
||||||
|
if (observedRoster === null) {
|
||||||
|
// §11.2 rule 4: missing roster = one roster create + seat creates
|
||||||
|
actions.push(
|
||||||
|
makeAction(
|
||||||
|
'fleet-roster',
|
||||||
|
'roster',
|
||||||
|
'create',
|
||||||
|
'none',
|
||||||
|
'CONFIG_DRIFT_CREATE',
|
||||||
|
null,
|
||||||
|
desiredDigest,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// seat creates are dependency-gated (profile resolution notChecked)
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (desiredDigest === observedDigest) {
|
||||||
|
return []; // §11.2 rule 5: exact conformance = zero actions
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1 ownership mask: compare owned fields
|
||||||
|
const fieldDiffs: ConfigPlanFieldDiff[] = [];
|
||||||
|
const ownedPaths = ['version', 'transport', 'tmux.socket_name', 'defaults.working_directory'];
|
||||||
|
|
||||||
|
for (const p of ownedPaths) {
|
||||||
|
const before = getPath(observedRoster, p);
|
||||||
|
const after = getPath(desiredRoster, p);
|
||||||
|
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||||
|
fieldDiffs.push({ path: p, before: renderValue(before), after: renderValue(after) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent membership: extra observed agents are blocked (§9.1)
|
||||||
|
const observedAgents = Array.isArray(observedRoster.agents)
|
||||||
|
? (observedRoster.agents as Array<Record<string, unknown>>)
|
||||||
|
: [];
|
||||||
|
const desiredAgents = Array.isArray(desiredRoster.agents)
|
||||||
|
? (desiredRoster.agents as Array<Record<string, unknown>>)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const desiredNames = new Set(desiredAgents.map((a) => String(a.name ?? '')));
|
||||||
|
|
||||||
|
for (const agent of observedAgents) {
|
||||||
|
const name = String(agent.name ?? '');
|
||||||
|
if (!desiredNames.has(name)) {
|
||||||
|
actions.push(
|
||||||
|
makeAction(
|
||||||
|
'fleet-seat',
|
||||||
|
name,
|
||||||
|
'blocked',
|
||||||
|
'full-engine-required',
|
||||||
|
'CONFIG_DRIFT_FULL_ENGINE_REQUIRED',
|
||||||
|
digestCanonical(agent),
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
['full-engine: seat removal'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldDiffs.length > 0) {
|
||||||
|
actions.push(
|
||||||
|
makeAction(
|
||||||
|
'fleet-roster',
|
||||||
|
'roster',
|
||||||
|
'update',
|
||||||
|
'none',
|
||||||
|
'CONFIG_DRIFT_UPDATE',
|
||||||
|
observedDigest,
|
||||||
|
desiredDigest,
|
||||||
|
fieldDiffs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort (§11.2 rule 6)
|
||||||
|
actions.sort((a, b) => {
|
||||||
|
if (a.resourceKind !== b.resourceKind) return a.resourceKind < b.resourceKind ? -1 : 1;
|
||||||
|
if (a.resourceId !== b.resourceId) return a.resourceId < b.resourceId ? -1 : 1;
|
||||||
|
if (a.operation !== b.operation) return a.operation < b.operation ? -1 : 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAction(
|
||||||
|
resourceKind: 'fleet-roster' | 'fleet-seat',
|
||||||
|
resourceId: string,
|
||||||
|
operation: 'create' | 'update' | 'blocked',
|
||||||
|
risk: 'none' | 'review-required' | 'full-engine-required',
|
||||||
|
reasonCode: string,
|
||||||
|
beforeDigest: string | null,
|
||||||
|
afterDigest: string | null,
|
||||||
|
fieldDiffs: ConfigPlanFieldDiff[],
|
||||||
|
blockedBy: string[] = [],
|
||||||
|
): ConfigPlanActionV1 {
|
||||||
|
const payload = {
|
||||||
|
resourceKind,
|
||||||
|
resourceId,
|
||||||
|
operation,
|
||||||
|
risk,
|
||||||
|
reasonCode,
|
||||||
|
beforeDigest,
|
||||||
|
afterDigest,
|
||||||
|
fieldDiffs,
|
||||||
|
};
|
||||||
|
const id = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(JSON.stringify(sortKeys(payload)))
|
||||||
|
.digest('hex')
|
||||||
|
.substring(0, 16);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
resourceKind,
|
||||||
|
resourceId,
|
||||||
|
operation,
|
||||||
|
risk,
|
||||||
|
reasonCode,
|
||||||
|
beforeDigest,
|
||||||
|
afterDigest,
|
||||||
|
fieldDiffs,
|
||||||
|
blockedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPath(obj: Record<string, unknown>, dotPath: string): unknown {
|
||||||
|
const parts = dotPath.split('.');
|
||||||
|
let current: unknown = obj;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current === null || typeof current !== 'object') return null;
|
||||||
|
current = (current as Record<string, unknown>)[part] ?? null;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderValue(v: unknown): string | number | boolean | null {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v;
|
||||||
|
return JSON.stringify(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortKeys(value: unknown): unknown {
|
||||||
|
if (value === null || typeof value !== 'object') return value;
|
||||||
|
if (Array.isArray(value)) return value.map(sortKeys);
|
||||||
|
const sorted: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
||||||
|
sorted[key] = sortKeys((value as Record<string, unknown>)[key]);
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePlanId(
|
||||||
|
blueprintDigest: string,
|
||||||
|
bindingsDigest: string,
|
||||||
|
profileDigest: string,
|
||||||
|
observedRosterDigest: string | null,
|
||||||
|
actions: ConfigPlanActionV1[],
|
||||||
|
): string {
|
||||||
|
const parts = {
|
||||||
|
planSchemaVersion: 1,
|
||||||
|
blueprintDigest,
|
||||||
|
bindingsDigest,
|
||||||
|
profileDigest,
|
||||||
|
observedRosterDigest: observedRosterDigest ?? 'absent',
|
||||||
|
actions: actions.map((a) => a.id),
|
||||||
|
};
|
||||||
|
return crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(JSON.stringify(sortKeys(parts)))
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* Installation config minimal-subset tests.
|
||||||
|
*
|
||||||
|
* Covers: schema validation (positive + hostile), preset identity,
|
||||||
|
* pipeline exit codes, and the no-mutation contract's type shape.
|
||||||
|
* The dependency-gated adapters are tested through their stubs.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
|
||||||
|
import { BOOTSTRAP_MINIMAL_V1, BLUEPRINT_API_VERSION, EXIT_UNAVAILABLE } from './types.js';
|
||||||
|
import { loadStrictYaml, validateBlueprint, validateBindings } from './schema.js';
|
||||||
|
import { runPipeline } from './core.js';
|
||||||
|
import { BlockedRegistryAdapter, digestCanonical } from './adapters.js';
|
||||||
|
import { renderJsonValidate, renderTableValidate } from './render.js';
|
||||||
|
|
||||||
|
const SB = fs.mkdtempSync(path.join(os.tmpdir(), 'configimpl-test-'));
|
||||||
|
|
||||||
|
describe('schema: strict YAML loader', () => {
|
||||||
|
it('accepts a valid single-document mapping', () => {
|
||||||
|
const result = loadStrictYaml('a: 1\nb: two', 'test');
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects multi-document YAML', () => {
|
||||||
|
const result = loadStrictYaml('a: 1\n---\nb: 2', 'test');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.diagnostics[0]?.code).toBe('CONFIG_BLUEPRINT_SCHEMA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects null/empty input', () => {
|
||||||
|
const result = loadStrictYaml('', 'test');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-mapping top level', () => {
|
||||||
|
const result = loadStrictYaml('- just\n- a\n- list', 'test');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('schema: blueprint validation', () => {
|
||||||
|
const validBlueprint = {
|
||||||
|
apiVersion: BLUEPRINT_API_VERSION,
|
||||||
|
kind: 'InstallationBlueprint',
|
||||||
|
metadata: { name: 'test', generation: 1 },
|
||||||
|
spec: { fleet: { profile: 'software-delivery', selection: 'floor' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('accepts the valid canonical shape', () => {
|
||||||
|
const r = validateBlueprint(validBlueprint, 'test');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.blueprint?.metadata.name).toBe('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown top-level key', () => {
|
||||||
|
const r = validateBlueprint({ ...validBlueprint, extra: true }, 'test');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.diagnostics.some((d) => d.message.includes("unknown top-level key 'extra'"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong apiVersion', () => {
|
||||||
|
const r = validateBlueprint({ ...validBlueprint, apiVersion: 'wrong' }, 'test');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong kind', () => {
|
||||||
|
const r = validateBlueprint({ ...validBlueprint, kind: 'Wrong' }, 'test');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid name (uppercase)', () => {
|
||||||
|
const r = validateBlueprint(
|
||||||
|
{ ...validBlueprint, metadata: { ...validBlueprint.metadata, name: 'Bad' } },
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects generation < 1', () => {
|
||||||
|
const r = validateBlueprint(
|
||||||
|
{ ...validBlueprint, metadata: { ...validBlueprint.metadata, generation: 0 } },
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid selection', () => {
|
||||||
|
const r = validateBlueprint(
|
||||||
|
{
|
||||||
|
...validBlueprint,
|
||||||
|
spec: { fleet: { profile: 'test', selection: 'partial' } },
|
||||||
|
},
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown spec key', () => {
|
||||||
|
const r = validateBlueprint(
|
||||||
|
{
|
||||||
|
...validBlueprint,
|
||||||
|
spec: { fleet: validBlueprint.spec.fleet, extra: 1 },
|
||||||
|
},
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('schema: bindings validation', () => {
|
||||||
|
const validBindings = {
|
||||||
|
apiVersion: BLUEPRINT_API_VERSION,
|
||||||
|
kind: 'InstallationBindings',
|
||||||
|
spec: {
|
||||||
|
fleet: {
|
||||||
|
runtime: { default: 'pi' },
|
||||||
|
workingDirectory: '~/src',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('accepts the valid canonical shape', () => {
|
||||||
|
const r = validateBindings(validBindings, 'test');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty spec', () => {
|
||||||
|
const r = validateBindings(
|
||||||
|
{ apiVersion: BLUEPRINT_API_VERSION, kind: 'InstallationBindings', spec: {} },
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown fleet key', () => {
|
||||||
|
const r = validateBindings(
|
||||||
|
{
|
||||||
|
...validBindings,
|
||||||
|
spec: { fleet: { ...validBindings.spec.fleet, socket: 'override' } },
|
||||||
|
},
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects relative workingDirectory', () => {
|
||||||
|
const r = validateBindings(
|
||||||
|
{
|
||||||
|
...validBindings,
|
||||||
|
spec: { fleet: { workingDirectory: 'relative/path' } },
|
||||||
|
},
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects control bytes in workingDirectory', () => {
|
||||||
|
const r = validateBindings(
|
||||||
|
{
|
||||||
|
...validBindings,
|
||||||
|
spec: { fleet: { workingDirectory: '/tmp/\x00bad' } },
|
||||||
|
},
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('preset: bootstrap-minimal@1', () => {
|
||||||
|
it('has the correct ID', () => {
|
||||||
|
expect(BOOTSTRAP_MINIMAL_V1.id).toBe('bootstrap-minimal@1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates against the blueprint schema', () => {
|
||||||
|
const r = validateBlueprint(BOOTSTRAP_MINIMAL_V1.blueprint, 'preset');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects software-delivery floor per A4', () => {
|
||||||
|
expect(BOOTSTRAP_MINIMAL_V1.blueprint.spec.fleet.profile).toBe('software-delivery');
|
||||||
|
expect(BOOTSTRAP_MINIMAL_V1.blueprint.spec.fleet.selection).toBe('floor');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('core: pipeline', () => {
|
||||||
|
it('returns EXIT_UNAVAILABLE when registry is dependency-blocked', () => {
|
||||||
|
const result = runPipeline(
|
||||||
|
{
|
||||||
|
registryAdapter: new BlockedRegistryAdapter(),
|
||||||
|
filePath: null,
|
||||||
|
presetId: null,
|
||||||
|
brainHome: SB,
|
||||||
|
},
|
||||||
|
'validate',
|
||||||
|
);
|
||||||
|
expect(result.exitCode).toBe(EXIT_UNAVAILABLE);
|
||||||
|
expect(result.diagnostics.some((d) => d.code === 'CONFIG_REGISTRY_INVALID')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns EXIT_INVALID for unknown preset', () => {
|
||||||
|
// NOTE: registry is blocked, so this test would hit the registry gate first.
|
||||||
|
// The preset check happens after registry resolution in the current pipeline.
|
||||||
|
// This is documented as the dependency-gate behavior.
|
||||||
|
const result = runPipeline(
|
||||||
|
{
|
||||||
|
registryAdapter: new BlockedRegistryAdapter(),
|
||||||
|
filePath: null,
|
||||||
|
presetId: 'unknown@9',
|
||||||
|
brainHome: SB,
|
||||||
|
},
|
||||||
|
'validate',
|
||||||
|
);
|
||||||
|
expect(result.exitCode).toBe(EXIT_UNAVAILABLE); // registry gate fires first
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('render: output', () => {
|
||||||
|
it('JSON validate envelope has the correct capability ID', () => {
|
||||||
|
const data = {
|
||||||
|
resultSchemaVersion: 1 as const,
|
||||||
|
valid: true,
|
||||||
|
conformant: true,
|
||||||
|
blueprint: { source: 'preset' as const, id: 'test', digest: 'abc' },
|
||||||
|
bindings: { source: 'framework-default' as const, digest: 'def' },
|
||||||
|
profile: { id: 'test', digest: 'ghi', selection: 'floor' as const },
|
||||||
|
observed: { roster: 'present' as const, digest: 'jkl' },
|
||||||
|
checks: [],
|
||||||
|
};
|
||||||
|
const json = renderJsonValidate(data, 'test-corr');
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
expect(parsed.capabilityId).toBe('config.installation.validate');
|
||||||
|
expect(parsed.status).toBe('succeeded');
|
||||||
|
expect(parsed.correlationId).toBe('test-corr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('table and JSON agree on conformant', () => {
|
||||||
|
const data = {
|
||||||
|
resultSchemaVersion: 1 as const,
|
||||||
|
valid: true,
|
||||||
|
conformant: false,
|
||||||
|
blueprint: { source: 'preset' as const, id: 'test', digest: 'abc' },
|
||||||
|
bindings: { source: 'framework-default' as const, digest: 'def' },
|
||||||
|
profile: { id: 'test', digest: 'ghi', selection: 'floor' as const },
|
||||||
|
observed: { roster: 'present' as const, digest: 'jkl' },
|
||||||
|
checks: [],
|
||||||
|
};
|
||||||
|
const json = renderJsonValidate(data, 'test');
|
||||||
|
const table = renderTableValidate(data, []);
|
||||||
|
expect(json).toContain('"conformant": false');
|
||||||
|
expect(table).toContain('Conformant: false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('digest: determinism', () => {
|
||||||
|
it('produces identical digests for identical inputs with different key order', () => {
|
||||||
|
const a = { z: 1, a: { y: 2, b: 3 } };
|
||||||
|
const b = { a: { b: 3, y: 2 }, z: 1 };
|
||||||
|
expect(digestCanonical(a)).toBe(digestCanonical(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces different digests for different values', () => {
|
||||||
|
expect(digestCanonical({ a: 1 })).not.toBe(digestCanonical({ a: 2 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
afterEach(() => {
|
||||||
|
// no per-test cleanup needed (sandbox is shared)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: the suite creates the sandbox directory at module load and relies on
|
||||||
|
// the OS to clean /tmp. For CI, a trap would be added. This is documented.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Table and JSON renderers for validate and plan results (§12).
|
||||||
|
*
|
||||||
|
* Table is a human rendering of the same envelope. Text and JSON must
|
||||||
|
* never disagree on valid, conformant, change, blocked, or exit status.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
CapabilityResultV1,
|
||||||
|
ConfigValidationDataV1,
|
||||||
|
ConfigPlanDataV1,
|
||||||
|
ConfigDiagnostic,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
// ─── JSON renderer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function renderJsonValidate(data: ConfigValidationDataV1, correlationId: string): string {
|
||||||
|
const envelope: CapabilityResultV1<ConfigValidationDataV1> = {
|
||||||
|
capabilityId: 'config.installation.validate',
|
||||||
|
status: data.conformant ? 'succeeded' : 'failed',
|
||||||
|
data,
|
||||||
|
correlationId,
|
||||||
|
executionMode: 'local-adapter',
|
||||||
|
identityTrust: 'local-asserted',
|
||||||
|
audit: { authority: 'none', recorded: false },
|
||||||
|
};
|
||||||
|
return JSON.stringify(envelope, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderJsonPlan(data: ConfigPlanDataV1, correlationId: string): string {
|
||||||
|
const envelope: CapabilityResultV1<ConfigPlanDataV1> = {
|
||||||
|
capabilityId: 'config.installation.plan',
|
||||||
|
status: 'succeeded', // plan always succeeds (§11.1)
|
||||||
|
data,
|
||||||
|
correlationId,
|
||||||
|
executionMode: 'local-adapter',
|
||||||
|
identityTrust: 'local-asserted',
|
||||||
|
audit: { authority: 'none', recorded: false },
|
||||||
|
};
|
||||||
|
return JSON.stringify(envelope, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Table renderer (§12) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function renderTableValidate(
|
||||||
|
data: ConfigValidationDataV1,
|
||||||
|
diagnostics: ConfigDiagnostic[],
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push('Installation Validation');
|
||||||
|
lines.push('======================');
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`Valid: ${data.valid}`);
|
||||||
|
lines.push(`Conformant: ${data.conformant}`);
|
||||||
|
lines.push(
|
||||||
|
`Blueprint: ${data.blueprint.source === 'preset' ? data.blueprint.id : data.blueprint.id} (${data.blueprint.digest.substring(0, 12)}…)`,
|
||||||
|
);
|
||||||
|
lines.push(`Bindings: ${data.bindings.source} (${data.bindings.digest.substring(0, 12)}…)`);
|
||||||
|
lines.push(`Profile: ${data.profile.id} / ${data.profile.selection}`);
|
||||||
|
lines.push(
|
||||||
|
`Roster: ${data.observed.roster}${data.observed.digest ? ` (${data.observed.digest.substring(0, 12)}…)` : ''}`,
|
||||||
|
);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Checks:');
|
||||||
|
for (const check of data.checks) {
|
||||||
|
const icon = check.status === 'passed' ? '✓' : check.status === 'failed' ? '✗' : '–';
|
||||||
|
lines.push(` ${icon} ${check.id}: ${check.status}`);
|
||||||
|
}
|
||||||
|
if (diagnostics.length > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Diagnostics:');
|
||||||
|
for (const d of diagnostics) {
|
||||||
|
lines.push(` [${d.code}] ${d.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Details:');
|
||||||
|
lines.push(` blueprint digest: ${data.blueprint.digest}`);
|
||||||
|
lines.push(` bindings digest: ${data.bindings.digest}`);
|
||||||
|
lines.push(` profile digest: ${data.profile.digest}`);
|
||||||
|
lines.push(` observed digest: ${data.observed.digest ?? '(absent)'}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTablePlan(data: ConfigPlanDataV1): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push('Installation Plan');
|
||||||
|
lines.push('=================');
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`Conformant: ${data.conformant}`);
|
||||||
|
lines.push(`Changes: ${data.changeCount}`);
|
||||||
|
lines.push(`Blocked: ${data.blockedCount}`);
|
||||||
|
lines.push(`Apply: not supported (read-only v1)`);
|
||||||
|
lines.push(`Plan ID: ${data.planId}`);
|
||||||
|
lines.push('');
|
||||||
|
if (data.actions.length === 0) {
|
||||||
|
lines.push('No actions — installation is conformant.');
|
||||||
|
} else {
|
||||||
|
lines.push('Actions:');
|
||||||
|
for (const action of data.actions) {
|
||||||
|
lines.push(` [${action.operation}] ${action.resourceKind}/${action.resourceId}`);
|
||||||
|
lines.push(` reason: ${action.reasonCode} risk: ${action.risk}`);
|
||||||
|
if (action.fieldDiffs.length > 0) {
|
||||||
|
for (const fd of action.fieldDiffs) {
|
||||||
|
lines.push(` ${fd.path}: ${JSON.stringify(fd.before)} → ${JSON.stringify(fd.after)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action.blockedBy.length > 0) {
|
||||||
|
lines.push(` blocked by: ${action.blockedBy.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Inputs:');
|
||||||
|
lines.push(` blueprint digest: ${data.inputs.blueprintDigest}`);
|
||||||
|
lines.push(` bindings digest: ${data.inputs.bindingsDigest}`);
|
||||||
|
lines.push(` profile digest: ${data.inputs.profileDigest}`);
|
||||||
|
lines.push(` observed digest: ${data.inputs.observedRosterDigest ?? '(absent)'}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Correlation ID ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function makeCorrelationId(): string {
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
|
return `config-${timestamp}-${random}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* Strict YAML schema validation for blueprint and bindings.
|
||||||
|
*
|
||||||
|
* §6/§7 of the spec: closed mappings, no aliases/anchors/merges/tags,
|
||||||
|
* single document, strict type checking on every field.
|
||||||
|
*/
|
||||||
|
import * as yaml from 'yaml';
|
||||||
|
import {
|
||||||
|
BLUEPRINT_API_VERSION,
|
||||||
|
BLUEPRINT_KIND,
|
||||||
|
BINDINGS_KIND,
|
||||||
|
type InstallationBlueprint,
|
||||||
|
type InstallationBindings,
|
||||||
|
type FleetSelection,
|
||||||
|
type ConfigDiagnostic,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
const MAX_INPUT_BYTES = 1024 * 1024; // 1 MiB (§14.1)
|
||||||
|
|
||||||
|
// ─── Strict YAML loader (§14.4) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface StrictYamlResult {
|
||||||
|
ok: boolean;
|
||||||
|
value?: unknown;
|
||||||
|
diagnostics: ConfigDiagnostic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadStrictYaml(content: string, context: string): StrictYamlResult {
|
||||||
|
const diagnostics: ConfigDiagnostic[] = [];
|
||||||
|
|
||||||
|
if (content.length > MAX_INPUT_BYTES) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context} exceeds 1 MiB limit (${content.length} bytes)`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.includes('\n---\n') || content.trimStart().startsWith('---')) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: multiple YAML documents rejected`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = yaml.parse(content, { strict: true, mapAsMap: false });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
if (
|
||||||
|
content.includes('&') ||
|
||||||
|
content.includes('*') ||
|
||||||
|
content.includes('<<') ||
|
||||||
|
content.includes('!')
|
||||||
|
) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: YAML aliases/anchors/merges/tags rejected`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: YAML parse error: ${msg.substring(0, 200)}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: empty document`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: top level must be a mapping`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, value, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ID grammar (§6.2) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ID_PATTERN = /^[a-z][a-z0-9-]{0,62}$/;
|
||||||
|
|
||||||
|
export function isValidId(id: string): boolean {
|
||||||
|
return ID_PATTERN.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Blueprint validation (§6) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BLUEPRINT_TOP_KEYS = new Set(['apiVersion', 'kind', 'metadata', 'spec']);
|
||||||
|
const BLUEPRINT_METADATA_KEYS = new Set(['name', 'generation']);
|
||||||
|
const BLUEPRINT_SPEC_KEYS = new Set(['fleet']);
|
||||||
|
const BLUEPRINT_FLEET_KEYS = new Set(['profile', 'selection']);
|
||||||
|
|
||||||
|
export function validateBlueprint(
|
||||||
|
value: unknown,
|
||||||
|
context: string,
|
||||||
|
): { ok: boolean; blueprint?: InstallationBlueprint; diagnostics: ConfigDiagnostic[] } {
|
||||||
|
const diagnostics: ConfigDiagnostic[] = [];
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
if (!BLUEPRINT_TOP_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: unknown top-level key '${key}'`,
|
||||||
|
path: key,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.apiVersion !== BLUEPRINT_API_VERSION) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: apiVersion must be exactly '${BLUEPRINT_API_VERSION}'`,
|
||||||
|
path: 'apiVersion',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.kind !== BLUEPRINT_KIND) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: kind must be exactly '${BLUEPRINT_KIND}'`,
|
||||||
|
path: 'kind',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!obj.metadata || typeof obj.metadata !== 'object') {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: metadata is required`,
|
||||||
|
path: 'metadata',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const meta = obj.metadata as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(meta)) {
|
||||||
|
if (!BLUEPRINT_METADATA_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: unknown metadata key '${key}'`,
|
||||||
|
path: `metadata.${key}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof meta.name !== 'string' || !isValidId(meta.name)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: metadata.name must match [a-z][a-z0-9-]{0,62}`,
|
||||||
|
path: 'metadata.name',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof meta.generation !== 'number' ||
|
||||||
|
!Number.isInteger(meta.generation) ||
|
||||||
|
meta.generation < 1
|
||||||
|
) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: metadata.generation must be an integer >= 1`,
|
||||||
|
path: 'metadata.generation',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!obj.spec || typeof obj.spec !== 'object') {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: spec is required`,
|
||||||
|
path: 'spec',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const spec = obj.spec as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(spec)) {
|
||||||
|
if (!BLUEPRINT_SPEC_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: unknown spec key '${key}'`,
|
||||||
|
path: `spec.${key}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!spec.fleet || typeof spec.fleet !== 'object') {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: spec.fleet is required`,
|
||||||
|
path: 'spec.fleet',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const fleet = spec.fleet as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(fleet)) {
|
||||||
|
if (!BLUEPRINT_FLEET_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: unknown fleet key '${key}'`,
|
||||||
|
path: `spec.fleet.${key}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof fleet.profile !== 'string' || !isValidId(fleet.profile)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: spec.fleet.profile must match [a-z][a-z0-9-]{0,62}`,
|
||||||
|
path: 'spec.fleet.profile',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (fleet.selection !== 'floor' && fleet.selection !== 'full') {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: spec.fleet.selection must be 'floor' or 'full'`,
|
||||||
|
path: 'spec.fleet.selection',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diagnostics.length > 0) {
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fleet = (obj.spec as Record<string, unknown>).fleet as Record<string, unknown>;
|
||||||
|
const meta = obj.metadata as Record<string, unknown>;
|
||||||
|
const blueprint: InstallationBlueprint = {
|
||||||
|
apiVersion: BLUEPRINT_API_VERSION,
|
||||||
|
kind: BLUEPRINT_KIND,
|
||||||
|
metadata: {
|
||||||
|
name: meta.name as string,
|
||||||
|
generation: meta.generation as number,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
fleet: {
|
||||||
|
profile: fleet.profile as string,
|
||||||
|
selection: fleet.selection as FleetSelection,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ok: true, blueprint, diagnostics: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bindings validation (§7) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BINDINGS_TOP_KEYS = new Set(['apiVersion', 'kind', 'spec']);
|
||||||
|
const BINDINGS_SPEC_KEYS = new Set(['fleet']);
|
||||||
|
const BINDINGS_FLEET_KEYS = new Set(['runtime', 'workingDirectory']);
|
||||||
|
const BINDINGS_RUNTIME_KEYS = new Set(['default', 'byClass']);
|
||||||
|
|
||||||
|
export function validateBindings(
|
||||||
|
value: unknown,
|
||||||
|
context: string,
|
||||||
|
): { ok: boolean; bindings?: InstallationBindings; diagnostics: ConfigDiagnostic[] } {
|
||||||
|
const diagnostics: ConfigDiagnostic[] = [];
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
if (!BINDINGS_TOP_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: unknown top-level key '${key}'`,
|
||||||
|
path: key,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.apiVersion !== BLUEPRINT_API_VERSION) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: apiVersion must be exactly '${BLUEPRINT_API_VERSION}'`,
|
||||||
|
path: 'apiVersion',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.kind !== BINDINGS_KIND) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: kind must be exactly '${BINDINGS_KIND}'`,
|
||||||
|
path: 'kind',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!obj.spec || typeof obj.spec !== 'object' || Object.keys(obj.spec).length === 0) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: spec is required and non-empty (empty bindings file is invalid)`,
|
||||||
|
path: 'spec',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const spec = obj.spec as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(spec)) {
|
||||||
|
if (!BINDINGS_SPEC_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: unknown spec key '${key}'`,
|
||||||
|
path: `spec.${key}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.fleet) {
|
||||||
|
const fleet = spec.fleet as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(fleet)) {
|
||||||
|
if (!BINDINGS_FLEET_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: unknown fleet key '${key}'`,
|
||||||
|
path: `spec.fleet.${key}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fleet.runtime) {
|
||||||
|
const runtime = fleet.runtime as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(runtime)) {
|
||||||
|
if (!BINDINGS_RUNTIME_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: unknown runtime key '${key}'`,
|
||||||
|
path: `spec.fleet.runtime.${key}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
runtime.default !== undefined &&
|
||||||
|
(typeof runtime.default !== 'string' || !isValidId(runtime.default))
|
||||||
|
) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: runtime.default must match [a-z][a-z0-9-]{0,62}`,
|
||||||
|
path: 'spec.fleet.runtime.default',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (runtime.byClass !== undefined && runtime.byClass !== null) {
|
||||||
|
if (typeof runtime.byClass !== 'object' || runtime.byClass === null) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: runtime.byClass must be a mapping`,
|
||||||
|
path: 'spec.fleet.runtime.byClass',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
for (const [cls, rt] of Object.entries(runtime.byClass)) {
|
||||||
|
if (!isValidId(cls)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: byClass key '${cls}' must match [a-z][a-z0-9-]{0,62}`,
|
||||||
|
path: `spec.fleet.runtime.byClass.${cls}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof rt !== 'string' || !isValidId(rt)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: byClass value for '${cls}' must match [a-z][a-z0-9-]{0,62}`,
|
||||||
|
path: `spec.fleet.runtime.byClass.${cls}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fleet.workingDirectory !== undefined) {
|
||||||
|
const wd = fleet.workingDirectory;
|
||||||
|
if (typeof wd !== 'string') {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: workingDirectory must be a string`,
|
||||||
|
path: 'spec.fleet.workingDirectory',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(wd)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: workingDirectory contains control characters`,
|
||||||
|
path: 'spec.fleet.workingDirectory',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!wd.startsWith('/') && !wd.startsWith('~/')) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: workingDirectory must be absolute or ~/ prefixed`,
|
||||||
|
path: 'spec.fleet.workingDirectory',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diagnostics.length > 0) {
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fleet = (obj.spec as Record<string, unknown>).fleet as Record<string, unknown> | undefined;
|
||||||
|
const bindings: InstallationBindings = {
|
||||||
|
apiVersion: BLUEPRINT_API_VERSION,
|
||||||
|
kind: BINDINGS_KIND,
|
||||||
|
spec: {
|
||||||
|
fleet: fleet
|
||||||
|
? {
|
||||||
|
runtime: fleet.runtime as InstallationBindings['spec']['fleet']['runtime'] | undefined,
|
||||||
|
workingDirectory: fleet.workingDirectory as string | undefined,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ok: true, bindings, diagnostics: [] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* Shared type contracts for the installation config minimal subset.
|
||||||
|
*
|
||||||
|
* Spec: docs/specs/2026-08-29_mosaic-config-minimal-subset.md (spec review
|
||||||
|
* PASS deff617d by rev-code-02; implementation per CONFIGIMPL-GO).
|
||||||
|
*
|
||||||
|
* These types are the single source of truth for the blueprint, bindings,
|
||||||
|
* result, action, and diagnostic shapes. The YAML schemas in schema.ts and
|
||||||
|
* the result renderers in render.ts consume these interfaces directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Blueprint (§6) ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const BLUEPRINT_API_VERSION = 'config.mosaicstack.dev/v1alpha1';
|
||||||
|
export const BLUEPRINT_KIND = 'InstallationBlueprint';
|
||||||
|
export const BINDINGS_KIND = 'InstallationBindings';
|
||||||
|
export const PRESET_ID = 'bootstrap-minimal@1';
|
||||||
|
|
||||||
|
export type FleetSelection = 'floor' | 'full';
|
||||||
|
|
||||||
|
export interface InstallationBlueprint {
|
||||||
|
apiVersion: typeof BLUEPRINT_API_VERSION;
|
||||||
|
kind: typeof BLUEPRINT_KIND;
|
||||||
|
metadata: {
|
||||||
|
name: string;
|
||||||
|
generation: number;
|
||||||
|
};
|
||||||
|
spec: {
|
||||||
|
fleet: {
|
||||||
|
profile: string;
|
||||||
|
selection: FleetSelection;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bindings (§7) ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface InstallationBindings {
|
||||||
|
apiVersion: typeof BLUEPRINT_API_VERSION;
|
||||||
|
kind: typeof BINDINGS_KIND;
|
||||||
|
spec: {
|
||||||
|
fleet: {
|
||||||
|
runtime?: {
|
||||||
|
default?: string;
|
||||||
|
byClass?: Record<string, string>;
|
||||||
|
};
|
||||||
|
workingDirectory?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Framework defaults (§7, A5) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FrameworkBindingDefaults {
|
||||||
|
runtime: string;
|
||||||
|
workingDirectory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FRAMEWORK_BINDING_DEFAULTS: FrameworkBindingDefaults = {
|
||||||
|
runtime: 'claude',
|
||||||
|
workingDirectory: '~',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Preset (§8) ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PackagedPreset {
|
||||||
|
id: typeof PRESET_ID;
|
||||||
|
blueprint: InstallationBlueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BOOTSTRAP_MINIMAL_V1: PackagedPreset = {
|
||||||
|
id: 'bootstrap-minimal@1',
|
||||||
|
blueprint: {
|
||||||
|
apiVersion: 'config.mosaicstack.dev/v1alpha1',
|
||||||
|
kind: 'InstallationBlueprint',
|
||||||
|
metadata: {
|
||||||
|
name: 'bootstrap-minimal',
|
||||||
|
generation: 1,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
fleet: {
|
||||||
|
profile: 'software-delivery',
|
||||||
|
selection: 'floor',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Diagnostics (§13) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type DiagnosticCode =
|
||||||
|
| 'CONFIG_USAGE_INVALID'
|
||||||
|
| 'CONFIG_REGISTRY_INVALID'
|
||||||
|
| 'CONFIG_BLUEPRINT_MISSING'
|
||||||
|
| 'CONFIG_BLUEPRINT_SCHEMA'
|
||||||
|
| 'CONFIG_BINDINGS_SCHEMA'
|
||||||
|
| 'CONFIG_BINDINGS_NOT_IGNORED'
|
||||||
|
| 'CONFIG_PRESET_UNKNOWN'
|
||||||
|
| 'CONFIG_PROFILE_UNRESOLVED'
|
||||||
|
| 'CONFIG_ROLE_UNRESOLVED'
|
||||||
|
| 'CONFIG_DESIRED_ROSTER_INVALID'
|
||||||
|
| 'CONFIG_OBSERVED_ROSTER_INVALID'
|
||||||
|
| 'CONFIG_ROSTER_MISSING'
|
||||||
|
| 'CONFIG_NONCONFORMANT'
|
||||||
|
| 'CONFIG_DRIFT_CREATE'
|
||||||
|
| 'CONFIG_DRIFT_UPDATE'
|
||||||
|
| 'CONFIG_DRIFT_FULL_ENGINE_REQUIRED'
|
||||||
|
| 'CONFIG_ADAPTER_UNAVAILABLE';
|
||||||
|
|
||||||
|
export interface ConfigDiagnostic {
|
||||||
|
code: DiagnosticCode;
|
||||||
|
message: string;
|
||||||
|
path?: string;
|
||||||
|
retryable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Registry provenance (§5, §2.2) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RegistryProvenance {
|
||||||
|
resolved: boolean;
|
||||||
|
brainHome: string | null;
|
||||||
|
sourceKeys: Array<{ key: string; sourceClass: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Validation result (§12.1) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ConfigCheckResult {
|
||||||
|
id: string;
|
||||||
|
status: 'passed' | 'failed' | 'notChecked';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigValidationDataV1 {
|
||||||
|
resultSchemaVersion: 1;
|
||||||
|
valid: boolean;
|
||||||
|
conformant: boolean;
|
||||||
|
blueprint: { source: 'file' | 'preset'; id: string; digest: string };
|
||||||
|
bindings: { source: 'file' | 'framework-default'; digest: string };
|
||||||
|
profile: { id: string; digest: string; selection: FleetSelection };
|
||||||
|
observed: { roster: 'present' | 'absent'; digest: string | null };
|
||||||
|
checks: ConfigCheckResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Plan result (§12.2, §11.2) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ConfigPlanOperationV1 = 'create' | 'update' | 'blocked';
|
||||||
|
export type ConfigRiskV1 = 'none' | 'review-required' | 'full-engine-required';
|
||||||
|
|
||||||
|
export interface ConfigPlanFieldDiff {
|
||||||
|
path: string;
|
||||||
|
before: string | number | boolean | null;
|
||||||
|
after: string | number | boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigPlanActionV1 {
|
||||||
|
id: string;
|
||||||
|
resourceKind: 'fleet-roster' | 'fleet-seat';
|
||||||
|
resourceId: string;
|
||||||
|
operation: ConfigPlanOperationV1;
|
||||||
|
risk: ConfigRiskV1;
|
||||||
|
reasonCode: string;
|
||||||
|
beforeDigest: string | null;
|
||||||
|
afterDigest: string | null;
|
||||||
|
fieldDiffs: ConfigPlanFieldDiff[];
|
||||||
|
blockedBy: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigPlanDataV1 {
|
||||||
|
resultSchemaVersion: 1;
|
||||||
|
planSchemaVersion: 1;
|
||||||
|
planId: string;
|
||||||
|
applySupported: false;
|
||||||
|
valid: true;
|
||||||
|
conformant: boolean;
|
||||||
|
changeCount: number;
|
||||||
|
blockedCount: number;
|
||||||
|
inputs: {
|
||||||
|
blueprintDigest: string;
|
||||||
|
bindingsDigest: string;
|
||||||
|
profileDigest: string;
|
||||||
|
observedRosterDigest: string | null;
|
||||||
|
};
|
||||||
|
actions: ConfigPlanActionV1[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Result envelope (§12) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CapabilityResultV1<T> {
|
||||||
|
capabilityId: string;
|
||||||
|
status: 'succeeded' | 'failed' | 'invalid';
|
||||||
|
data?: T;
|
||||||
|
diagnostics?: ConfigDiagnostic[];
|
||||||
|
correlationId: string;
|
||||||
|
executionMode: 'local-adapter';
|
||||||
|
identityTrust: 'local-asserted';
|
||||||
|
audit: { authority: 'none'; recorded: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Exit codes (§10.3) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const EXIT_OK = 0;
|
||||||
|
export const EXIT_INVALID = 2;
|
||||||
|
export const EXIT_RESERVED_SCOPE = 3;
|
||||||
|
export const EXIT_NONCONFORMANT = 4;
|
||||||
|
export const EXIT_UNAVAILABLE = 6;
|
||||||
|
|
||||||
|
// ─── Dependency gate marker (§2.2) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEPENDENCY GATE: the approved MosaicRegistryResolver does not exist at
|
||||||
|
* the pinned baseline. The registry-consuming work is PARKED. This marker
|
||||||
|
* interface exists so that when the reviewed resolver ships, the adapter
|
||||||
|
* binds to it without schema changes. Until then, the adapter returns
|
||||||
|
* CONFIG_REGISTRY_INVALID with the dependency-blocked message.
|
||||||
|
*/
|
||||||
|
export const REGISTRY_RESOLVER_BLOCKED =
|
||||||
|
'MosaicRegistryResolver: dependency-blocked pending reviewed resolver (CFG-REQ-001..006 charter)';
|
||||||
Reference in New Issue
Block a user