Compare commits

..
Author SHA1 Message Date
fred 752ee40edd preflight: retire the .next generated-state certification (#1444)
ci/woodpecker/pr/ci Pipeline was successful
CI's test step caught the gap: sourceFingerprint resolved
next/package.json from apps/web, which P5 removed. The certification
(fingerprint incl. @next/env expansion, symlink manifest, foreign-uid
scan, build lock) defended typecheck against stale apps/web/.next
output; the Vite SPA has no generated tree later gates consume, so the
class it defended against is gone. Preflight keeps its other job, the
missing-binaries check with exit 42. clean-generated.mjs (a .next
quarantine helper) goes with it.
2026-08-27 07:48:40 -05:00
fred c5a45784ac ci: retire the standalone web image and build-web step (#1444)
ci/woodpecker/pr/ci Pipeline failed
The gateway image now carries the SPA bundle, so web.Dockerfile,
scripts/build-web.mjs (+ test), and the publish build-web step go away.
The two CI-structure tests drop build-web from their expected-effects
lists. .env.example replaces the Next block with WEB_DIST_DIR docs.
2026-08-27 07:32:38 -05:00
fred 385b2b6886 gateway: serve the SPA bundle same-origin (#1444)
New spa/serve-spa.ts: @fastify/static with wildcard:false plus a GET /*
catch-all that returns index.html for non-backend paths and a JSON 404
for unknown /api, /mcp, /socket.io paths. WEB_DIST_DIR unset disables
serving (dev uses the Vite dev server, which proxies to the gateway);
set but invalid fails loud at boot.

A wildcard route, not setNotFoundHandler: Nest installs its own
not-found handler at init and Fastify allows only one. find-my-way
matches most-specific-first, so declared routes win over the catch-all.
Cache semantics stay the library default (max-age=0 + ETag
revalidation); immutable hashed-asset caching is deferred to P6.

gateway.Dockerfile builds the web bundle and ships it at /app/web-dist
with WEB_DIST_DIR set.
2026-08-27 07:32:27 -05:00
fred 70c7311a46 web: cut over to Vite SPA, retire the Next.js shell (#1444)
Delete the src/app tree, next.config.ts, next-env.d.ts, and the Next-only
guard/header components. Port AppShell/Sidebar/Topbar to react-router and
mount them as a DashboardLayout route over all authenticated routes. Strip
'use client' directives, move globals.css up from the deleted app/ tree,
rewrite tsconfig for Vite/Bundler resolution, drop the next dependency.

Test fixes the port surfaced: jsdom v29 has no window.matchMedia (sidebar
breakpoint) so setup.ts stubs it; the router-boundary specs render inside
ThemeProvider because the chrome's ThemeToggle requires the context.
2026-08-27 07:32:14 -05:00
81 changed files with 275 additions and 20815 deletions
-4
View File
@@ -23,7 +23,3 @@ infra/step-ca/dev-password
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
# file vanished mid-scan. Ignoring them removes the race.
*.timestamp-*.mjs
# Playwright run artifacts (#1445, P6 E2E gate)
apps/web/test-results/
apps/web/playwright-report/
-17
View File
@@ -254,23 +254,6 @@ steps:
depends_on:
- typecheck
# Canonical verify:release stage `build` (#1445, P6): every PR proves the
# full workspace build — including the SPA `vite build` — before merge,
# instead of leaving build breakage to surface post-merge in publish.yml's
# verify step. Same canonical command the publish pipeline's build step runs.
build:
image: *node_image
commands:
- *enable_pnpm
- pnpm build
depends_on:
# after test, not typecheck: turbo gives `test` a ^build dependency, so
# running this step concurrently with test would put two independent
# turbo builds on the same shared-workspace dist/ and turbo cache with
# no cross-process locking — the same serialization invariant
# publish.yml documents for #1411.
- test
services:
ci-postgres:
image: pgvector/pgvector:pg17
-94
View File
@@ -407,96 +407,6 @@ steps:
- build
- verify
# #1445 (P6): headless Playwright E2E gate on every trunk merge. Boots the
# real gateway on the embedded PGlite path (no DATABASE_URL, no services)
# serving the built SPA bundle via WEB_DIST_DIR — the exact serving path the
# gateway image ships (docker/gateway.Dockerfile sets WEB_DIST_DIR to the
# baked bundle), which keeps #1407's parity guarantee: the image build steps
# below depend on this gate, so a bundle that fails E2E never publishes.
#
# Image pinned to the @playwright/test version in pnpm-lock.yaml so the
# image's bundled browsers match the workspace driver exactly (bump the two
# together). The step installs no workspace packages (corepack does fetch
# the pinned pnpm itself): it reuses the workspace node_modules
# from `install` and the dist outputs from `build` — the gateway's runtime
# dependency path is pure JS/WASM (PGlite is WASM, postgres-js is pure JS),
# so the alpine-installed modules run unchanged under this glibc image.
# depends_on publish-next-npm per the #1411 serialization invariant: this
# step reads the workspace and must never run inside the manifest-transform
# window.
e2e:
image: mcr.microsoft.com/playwright:v1.58.2-noble
environment:
GATEWAY_PORT: '14242'
PLAYWRIGHT_BASE_URL: http://localhost:14242
# The database is seeded by Playwright's globalSetup in this step, so
# login failures are real failures: without this flag the suite's
# skip-when-login-fails guards (a live-environment affordance) could
# skip every authenticated spec and go green while proving nothing.
E2E_REQUIRE_SEEDED_AUTH: '1'
commands:
- corepack enable
- |
# Throwaway signing secret for this step's ephemeral embedded database
# (the gateway refuses to boot without one). Generated per run so no
# usable literal lives in the tree.
export BETTER_AUTH_SECRET="$(head -c 32 /dev/urandom | base64)"
export WEB_DIST_DIR="$(pwd)/apps/web/dist"
if [ ! -f "$WEB_DIST_DIR/index.html" ]; then
echo "[e2e] FATAL: $WEB_DIST_DIR/index.html missing — did the build step run?" >&2
exit 1
fi
# Boot the gateway from the built dist, cwd- AND HOME-isolated: the
# local-tier PGlite database lives under $HOME/.config/mosaic/gateway/
# (database.module.ts), not under cwd, so HOME must point at the
# throwaway dir too or the run would share a database with anything
# else in the container's home.
GATEWAY_RUN_DIR="$(mktemp -d /tmp/e2e-gateway.XXXXXX)"
(cd "$GATEWAY_RUN_DIR" && export HOME="$GATEWAY_RUN_DIR" && exec node "$OLDPWD/apps/gateway/dist/main.js") > /tmp/gateway.log 2>&1 &
GATEWAY_PID=$!
ready=0
for i in $(seq 1 90); do
if node -e "fetch('http://localhost:' + process.env.GATEWAY_PORT + '/health', { signal: AbortSignal.timeout(2000) }).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"; then
ready=1
break
fi
if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
echo "[e2e] FATAL: gateway process exited during startup" >&2
cat /tmp/gateway.log >&2
exit 1
fi
echo "[e2e] waiting for gateway ($i/90)..."
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "[e2e] FATAL: gateway did not become ready in 90s" >&2
cat /tmp/gateway.log >&2
exit 1
fi
echo "[e2e] gateway ready; running Playwright suite"
set +e
pnpm --filter @mosaicstack/web exec playwright test
E2E_EXIT=$?
set -e
kill "$GATEWAY_PID" 2>/dev/null || true
if [ "$E2E_EXIT" -ne 0 ]; then
echo "[e2e] FATAL: Playwright suite failed (exit $E2E_EXIT); gateway log follows" >&2
tail -100 /tmp/gateway.log >&2
echo "[e2e] browser-side traces/screenshots are under apps/web/test-results/ in the step workspace (not persisted past the pod)" >&2
fi
exit "$E2E_EXIT"
# Same filter as the image builds it gates: a merge that publishes no
# image (docs-only on main) pays no browser suite, and a skipped e2e does
# not block anything (skipped-dependency semantics, same as
# publish-next-npm on tag events).
when: *image_build_when
depends_on:
- build
- verify
# #1411: never read the workspace inside publish-next-npm's
# manifest-transform window.
- publish-next-npm
# TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs:
# image: *node_image
@@ -556,8 +466,6 @@ steps:
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
# serialization invariant; add it to every new workspace consumer.
- publish-next-npm
# #1445 (P6): a bundle that fails the E2E gate never publishes an image.
- e2e
build-appservice:
image: gcr.io/kaniko-project/executor:debug
@@ -602,5 +510,3 @@ steps:
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
# serialization invariant; add it to every new workspace consumer.
- publish-next-npm
# #1445 (P6): a bundle that fails the E2E gate never publishes an image.
- e2e
@@ -1,106 +0,0 @@
import { RequestMethod, type Type } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import { AppModule } from '../app.module.js';
import { HierarchyModule } from '../hierarchy/hierarchy.module.js';
/**
* Hierarchy route-inventory baseline (contract 1 §6.3(a)).
*
* M4-1b-i ships the audit event + outbox machinery with NO mutation routes:
* the hierarchy command family (controllers + DTOs) lands in M4-1b-ii once
* contract 2 merges. This witness enumerates every route the AppModule graph
* declares and pins that baseline, so a hierarchy route appearing before its
* command-family witnesses exist fails here first. When M4-1b-ii lands, this
* baseline is replaced by an exact inventory of the command family.
*/
interface RouteEntry {
method: string;
path: string;
controller: string;
}
/** Module-metadata entry: a module class or a DynamicModule-shaped object. */
type ModuleEntry =
| Type<unknown>
| { module: Type<unknown>; imports?: unknown[]; controllers?: Type<unknown>[] };
function collectControllers(root: ModuleEntry): Type<unknown>[] {
const visited = new Set<unknown>();
const controllers: Type<unknown>[] = [];
const walk = (entry: ModuleEntry | undefined | null): void => {
if (!entry || visited.has(entry)) return;
visited.add(entry);
const moduleClass = typeof entry === 'function' ? entry : entry.module;
// Entries with no resolvable class (forwardRef wrappers, async dynamic
// modules) carry no decorator metadata to read here.
if (typeof moduleClass !== 'function') return;
if (visited.has(moduleClass) && typeof entry !== 'function') return;
visited.add(moduleClass);
// 'controllers' / 'imports' are the metadata keys the @Module decorator writes.
const declared = (Reflect.getMetadata('controllers', moduleClass) ?? []) as Type<unknown>[];
controllers.push(...declared);
if (typeof entry !== 'function' && entry.controllers) controllers.push(...entry.controllers);
const imports = [
...((Reflect.getMetadata('imports', moduleClass) ?? []) as ModuleEntry[]),
...(typeof entry !== 'function' ? ((entry.imports ?? []) as ModuleEntry[]) : []),
];
for (const imported of imports) walk(imported);
};
walk(root);
return controllers;
}
function routesOf(controller: Type<unknown>): RouteEntry[] {
// 'path' on the class is the @Controller prefix; 'path'/'method' on a
// handler are written by the @Get/@Post/... route decorators.
const base = (Reflect.getMetadata('path', controller) ?? '') as string | string[];
const bases = Array.isArray(base) ? base : [base];
const routes: RouteEntry[] = [];
const prototype = controller.prototype as Record<string, unknown>;
for (const name of Object.getOwnPropertyNames(prototype)) {
if (name === 'constructor') continue;
const handler = Object.getOwnPropertyDescriptor(prototype, name)?.value;
if (typeof handler !== 'function') continue;
const method = Reflect.getMetadata('method', handler) as number | undefined;
if (method === undefined) continue;
const sub = (Reflect.getMetadata('path', handler) ?? '/') as string;
for (const prefix of bases) {
const path = `/${prefix}/${sub}`.replace(/\/+/g, '/').replace(/(.)\/$/, '$1');
routes.push({
method: RequestMethod[method] ?? String(method),
path,
controller: controller.name,
});
}
}
return routes;
}
describe('hierarchy route-inventory baseline (§6.3(a))', () => {
const inventory = collectControllers(AppModule).flatMap(routesOf);
it('control: the enumeration sees the known route surface', () => {
const paths = inventory.map((r) => `${r.method} ${r.path}`);
expect(paths).toContain('GET /health');
expect(paths).toContain('POST /api/workspaces');
expect(paths).toContain('GET /api/teams');
expect(inventory.length).toBeGreaterThan(20);
});
it('declares zero hierarchy mutation routes before M4-1b-ii', () => {
const hierarchyRoutes = inventory.filter((r) =>
/hierarch|compan|estate|platform[-_]?project/i.test(r.path),
);
expect(
hierarchyRoutes,
'a hierarchy route landed without replacing the §6.3(a) baseline with a command-family inventory',
).toEqual([]);
});
it('HierarchyModule itself declares no controllers', () => {
expect((Reflect.getMetadata('controllers', HierarchyModule) ?? []) as unknown[]).toEqual([]);
const hierarchyControllers = collectControllers(HierarchyModule);
expect(hierarchyControllers).toEqual([]);
});
});
-2
View File
@@ -24,7 +24,6 @@ import { GCModule } from './gc/gc.module.js';
import { HarnessModule } from './harness/harness.module.js';
import { ReloadModule } from './reload/reload.module.js';
import { WorkspaceModule } from './workspace/workspace.module.js';
import { HierarchyModule } from './hierarchy/hierarchy.module.js';
import { QueueModule } from './queue/queue.module.js';
import { FederationModule } from './federation/federation.module.js';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
@@ -66,7 +65,6 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
QueueModule,
ReloadModule,
WorkspaceModule,
HierarchyModule,
...(federationEnabled ? [FederationModule] : []),
],
controllers: [HealthController],
@@ -1,247 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import {
companies,
createPgliteDb,
eq,
estates,
hierarchyAuditEvents,
hierarchyOutbox,
platformProjects,
runPgliteMigrations,
type DbHandle,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import {
HierarchyAuditIdempotencyConflictError,
HierarchyAuditRepository,
HierarchyNodeNotFoundError,
type AppendHierarchyEventInput,
} from './hierarchy-audit.repository.js';
/**
* Repository-level §6.4 witnesses for the hierarchy audit machinery
* (contract 1 §5.2, REQ-AUD-001): same-transaction atomicity of state +
* event + outbox, rollback leaving no residue, idempotent replay, snapshot
* parent chains, events surviving target deletion, per-target ordering, and
* the outbox claim/complete/release CAS. The schema-level constraints are
* witnessed in packages/db/src/hierarchy-audit.witness.test.ts.
*/
describe('hierarchy audit repository integration', (): void => {
let dataDir: string;
let handle: DbHandle;
let moduleRef: TestingModule;
let repo: HierarchyAuditRepository;
const input = (
overrides: Partial<AppendHierarchyEventInput> = {},
): AppendHierarchyEventInput => ({
actorId: 'user-actor',
verb: 'create',
targetKind: 'company',
targetId: randomUUID(),
targetSnapshot: { id: 'x', slug: 'x', name: 'x', parentChain: [] },
correlationId: 'corr-1',
idempotencyKey: `key-${randomUUID()}`,
...overrides,
});
beforeAll(async (): Promise<void> => {
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-audit-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
moduleRef = await Test.createTestingModule({
providers: [HierarchyAuditRepository, { provide: DB, useValue: handle.db }],
}).compile();
repo = moduleRef.get(HierarchyAuditRepository);
});
afterAll(async (): Promise<void> => {
await moduleRef.close();
await handle.close();
await rm(dataDir, { recursive: true, force: true });
});
it('commits state, event, and outbox record atomically in one transaction', async () => {
const companyId = randomUUID();
const key = `key-${randomUUID()}`;
await handle.db.transaction(async (tx) => {
await tx.insert(companies).values({ id: companyId, name: 'Atomic Co', slug: 'atomic-co' });
const snapshot = await repo.snapshot(tx, 'company', companyId);
const result = await repo.append(tx, {
...input({ targetId: companyId, idempotencyKey: key }),
targetSnapshot: { ...snapshot },
});
expect(result.replayed).toBe(false);
expect(result.event.idempotencyKey).toBe(key);
});
const events = await handle.db
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
expect(events).toHaveLength(1);
const outbox = await handle.db
.select()
.from(hierarchyOutbox)
.where(eq(hierarchyOutbox.eventId, events[0]!.id));
expect(outbox).toHaveLength(1);
expect(outbox[0]).toMatchObject({
status: 'pending',
idempotencyKey: key,
correlationId: 'corr-1',
});
});
it('a rolled-back transaction leaves no state, no event, and no outbox record', async () => {
const companyId = randomUUID();
const key = `key-${randomUUID()}`;
await expect(
handle.db.transaction(async (tx) => {
await tx.insert(companies).values({ id: companyId, name: 'Doomed Co', slug: 'doomed-co' });
await repo.append(tx, input({ targetId: companyId, idempotencyKey: key }));
throw new Error('deliberate rollback');
}),
).rejects.toThrow('deliberate rollback');
const [companyRows, eventRows, outboxRows] = await Promise.all([
handle.db.select().from(companies).where(eq(companies.id, companyId)),
handle.db
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.idempotencyKey, key)),
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key)),
]);
expect(companyRows).toHaveLength(0);
expect(eventRows).toHaveLength(0);
expect(outboxRows).toHaveLength(0);
});
it('replays a duplicate idempotency key without inserting a second event or outbox record', async () => {
const first = input();
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
expect(original.replayed).toBe(false);
expect(replay.replayed).toBe(true);
expect(replay.event.id).toBe(original.event.id);
const outbox = await handle.db
.select()
.from(hierarchyOutbox)
.where(eq(hierarchyOutbox.eventId, original.event.id));
expect(outbox).toHaveLength(1);
});
it('throws on a duplicate idempotency key carrying different event content', async () => {
const first = input();
await handle.db.transaction(async (tx) => repo.append(tx, first));
await expect(
handle.db.transaction(async (tx) =>
repo.append(tx, { ...first, verb: 'rename', targetId: randomUUID() }),
),
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
});
it('throws on a duplicate idempotency key whose transfer destination differs', async () => {
const from = { kind: 'company' as const, id: randomUUID(), slug: 'src-co' };
const to = { kind: 'company' as const, id: randomUUID(), slug: 'dst-co' };
const first = input({
verb: 'transfer',
targetKind: 'estate',
transferFrom: from,
transferTo: to,
});
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
expect(original.replayed).toBe(false);
// Identical retry replays; a retry re-routed to a different destination must conflict.
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
expect(replay.replayed).toBe(true);
await expect(
handle.db.transaction(async (tx) =>
repo.append(tx, { ...first, transferTo: { ...to, id: randomUUID() } }),
),
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
});
it('builds root-first parent chains and rejects unknown nodes', async () => {
const companyId = randomUUID();
const estateId = randomUUID();
const projectId = randomUUID();
await handle.db.transaction(async (tx) => {
await tx.insert(companies).values({ id: companyId, name: 'Chain Co', slug: 'chain-co' });
await tx
.insert(estates)
.values({ id: estateId, name: 'Chain Estate', slug: 'chain-estate', companyId });
await tx
.insert(platformProjects)
.values({ id: projectId, name: 'Chain Project', slug: 'chain-project', estateId });
});
const snapshot = await repo.snapshot(handle.db, 'platform_project', projectId);
expect(snapshot).toMatchObject({ id: projectId, slug: 'chain-project', name: 'Chain Project' });
expect(snapshot.parentChain).toEqual([
{ kind: 'company', id: companyId, slug: 'chain-co' },
{ kind: 'estate', id: estateId, slug: 'chain-estate' },
]);
await expect(repo.snapshot(handle.db, 'estate', randomUUID())).rejects.toThrow(
HierarchyNodeNotFoundError,
);
});
it('keeps events readable, in per-target seq order, after the target row is deleted', async () => {
const companyId = randomUUID();
await handle.db.transaction(async (tx) => {
await tx.insert(companies).values({ id: companyId, name: 'Mortal Co', slug: 'mortal-co' });
const snapshot = await repo.snapshot(tx, 'company', companyId);
await repo.append(tx, input({ targetId: companyId, targetSnapshot: { ...snapshot } }));
});
await handle.db.transaction(async (tx) => {
const snapshot = await repo.snapshot(tx, 'company', companyId);
await repo.append(tx, {
...input({ verb: 'delete', targetId: companyId }),
targetSnapshot: { ...snapshot },
});
await tx.delete(companies).where(eq(companies.id, companyId));
});
const events = await repo.eventsForTarget(companyId);
expect(events.map((e) => e.verb)).toEqual(['create', 'delete']);
expect(events[1]!.seq).toBeGreaterThan(events[0]!.seq);
expect((events[1]!.targetSnapshot as { id: string }).id).toBe(companyId);
});
it('claims the oldest pending outbox record exactly once, completes and releases by CAS', async () => {
// Drain records left pending by earlier cases so ordering is deterministic.
for (;;) {
const drained = await repo.claimPendingOutbox();
if (!drained) break;
await repo.completeOutbox(drained.id);
}
const older = await handle.db.transaction(async (tx) => repo.append(tx, input()));
const newer = await handle.db.transaction(async (tx) => repo.append(tx, input()));
const claimed = await repo.claimPendingOutbox();
expect(claimed).not.toBeNull();
expect(claimed!.eventId).toBe(older.event.id);
expect(claimed!.status).toBe('processing');
// Delivery fails: release returns it to pending and it is claimable again.
await repo.releaseOutbox(claimed!.id);
const reclaimed = await repo.claimPendingOutbox();
expect(reclaimed!.id).toBe(claimed!.id);
await repo.completeOutbox(reclaimed!.id);
const done = await handle.db
.select()
.from(hierarchyOutbox)
.where(eq(hierarchyOutbox.id, reclaimed!.id));
expect(done[0]!.status).toBe('delivered');
expect(done[0]!.deliveredAt).not.toBeNull();
// completeOutbox is CAS-guarded on 'processing': completing again is a no-op.
await repo.completeOutbox(reclaimed!.id);
const second = await repo.claimPendingOutbox();
expect(second!.eventId).toBe(newer.event.id);
await repo.completeOutbox(second!.id);
expect(await repo.claimPendingOutbox()).toBeNull();
});
});
@@ -1,274 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import {
and,
asc,
companies,
eq,
estates,
hierarchyAuditEvents,
hierarchyOutbox,
platformProjects,
type Db,
type HIERARCHY_AUDIT_TARGET_KINDS,
type HIERARCHY_AUDIT_VERBS,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
/**
* Hierarchy audit event + outbox machinery (contract 1 §5.2).
*
* Every hierarchy mutation writes its semantic audit event AND the event's
* outbox record on the caller's transaction, so state, event, and outbox
* commit or roll back together. Events reference their target by an
* immutable snapshot (id, slug, parent chain at event time), never by a
* foreign key into the class tables — append-only events survive the
* deletion of their target. This module exposes no update or delete path
* for events: append-only is a property of the code surface, witnessed by
* the integration tests.
*
* This is NOT a class-table writer: it touches only the audit/outbox
* tables, so it does not appear on the writer-coverage allowlist. The
* hierarchy command repositories (M4-1b-ii) are the allowlisted writers and
* call into this on their own transactions.
*/
export type HierarchyAuditVerb = (typeof HIERARCHY_AUDIT_VERBS)[number];
export type HierarchyTargetKind = (typeof HIERARCHY_AUDIT_TARGET_KINDS)[number];
export type HierarchyNodeKind = Exclude<HierarchyTargetKind, 'grant'>;
export interface ParentChainEntry {
readonly kind: HierarchyNodeKind;
readonly id: string;
readonly slug: string;
}
/** Immutable node snapshot at event time; parentChain is root-first. */
export interface HierarchyNodeSnapshot {
readonly id: string;
readonly slug: string;
readonly name: string;
readonly parentChain: readonly ParentChainEntry[];
}
export interface AppendHierarchyEventInput {
readonly actorId: string;
readonly verb: HierarchyAuditVerb;
readonly targetKind: HierarchyTargetKind;
readonly targetId: string;
/** Node events: HierarchyNodeSnapshot. Grant events: subject/target/role snapshot (contract 2 §4.4). */
readonly targetSnapshot: Record<string, unknown>;
/** Present exactly on transfers (CHECK-enforced): source/destination parent { kind, id, slug }. */
readonly transferFrom?: ParentChainEntry;
readonly transferTo?: ParentChainEntry;
readonly correlationId: string;
/** Prior event in the causal chain (e.g. the delete event causing cascaded grant_revoke events). */
readonly causationId?: string;
readonly idempotencyKey: string;
}
export type HierarchyAuditEventRow = typeof hierarchyAuditEvents.$inferSelect;
export type HierarchyOutboxRow = typeof hierarchyOutbox.$inferSelect;
export interface AppendHierarchyEventResult {
readonly event: HierarchyAuditEventRow;
/** True when the idempotency key had already committed an identical event (REQ-AUD-001 duplicate suppression). */
readonly replayed: boolean;
}
type Tx = Pick<Db, 'insert' | 'select'>;
export class HierarchyAuditIdempotencyConflictError extends Error {
constructor(idempotencyKey: string) {
super(
`hierarchy audit idempotency key ${idempotencyKey} already exists with different event content`,
);
this.name = 'HierarchyAuditIdempotencyConflictError';
}
}
export class HierarchyNodeNotFoundError extends Error {
constructor(kind: HierarchyNodeKind, id: string) {
super(`hierarchy node not found: ${kind} ${id}`);
this.name = 'HierarchyNodeNotFoundError';
}
}
/**
* Append one audit event and its outbox record on the caller's transaction.
* A duplicate idempotency key with identical semantic content returns the
* prior event (replayed: true) without inserting anything; a duplicate key
* with different content throws.
*/
export async function appendHierarchyEvent(
tx: Tx,
input: AppendHierarchyEventInput,
): Promise<AppendHierarchyEventResult> {
const inserted = await tx
.insert(hierarchyAuditEvents)
.values({
actorId: input.actorId,
verb: input.verb,
targetKind: input.targetKind,
targetId: input.targetId,
targetSnapshot: input.targetSnapshot,
transferFrom: input.transferFrom ?? null,
transferTo: input.transferTo ?? null,
correlationId: input.correlationId,
causationId: input.causationId ?? null,
idempotencyKey: input.idempotencyKey,
})
.onConflictDoNothing()
.returning();
const event = inserted[0];
if (event) {
await tx.insert(hierarchyOutbox).values({
eventId: event.id,
idempotencyKey: input.idempotencyKey,
correlationId: input.correlationId,
});
return { event, replayed: false };
}
const prior = await tx
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.idempotencyKey, input.idempotencyKey))
.limit(1);
const existing = prior[0];
if (!existing || !sameEvent(existing, input)) {
throw new HierarchyAuditIdempotencyConflictError(input.idempotencyKey);
}
// Event and outbox committed atomically the first time, so the outbox
// record already exists; a replay inserts nothing.
return { event: existing, replayed: true };
}
/** Key-order-independent serialization: jsonb does not preserve key order. */
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (value !== null && typeof value === 'object') {
const record = value as Record<string, unknown>;
const body = Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(',');
return `{${body}}`;
}
return JSON.stringify(value);
}
function sameEvent(row: HierarchyAuditEventRow, input: AppendHierarchyEventInput): boolean {
return (
row.actorId === input.actorId &&
row.verb === input.verb &&
row.targetKind === input.targetKind &&
row.targetId === input.targetId &&
row.correlationId === input.correlationId &&
(row.causationId ?? null) === (input.causationId ?? null) &&
canonicalJson(row.targetSnapshot) === canonicalJson(input.targetSnapshot) &&
// Transfer source/destination are semantic content (§5.2): a retry with a
// different destination must conflict, never silently replay.
canonicalJson(row.transferFrom ?? null) === canonicalJson(input.transferFrom ?? null) &&
canonicalJson(row.transferTo ?? null) === canonicalJson(input.transferTo ?? null)
);
}
/**
* Build the immutable snapshot for a node: its row plus the parent chain up
* to the company root, root-first, read on the caller's transaction so the
* snapshot is consistent with the mutation it audits.
*/
export async function buildNodeSnapshot(
tx: Tx,
kind: HierarchyNodeKind,
id: string,
): Promise<HierarchyNodeSnapshot> {
if (kind === 'company') {
const rows = await tx.select().from(companies).where(eq(companies.id, id)).limit(1);
const row = rows[0];
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
return { id: row.id, slug: row.slug, name: row.name, parentChain: [] };
}
if (kind === 'estate') {
const rows = await tx.select().from(estates).where(eq(estates.id, id)).limit(1);
const row = rows[0];
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
const parent = await buildNodeSnapshot(tx, 'company', row.companyId);
return {
id: row.id,
slug: row.slug,
name: row.name,
parentChain: [...parent.parentChain, { kind: 'company', id: parent.id, slug: parent.slug }],
};
}
const rows = await tx.select().from(platformProjects).where(eq(platformProjects.id, id)).limit(1);
const row = rows[0];
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
const parent = await buildNodeSnapshot(tx, 'estate', row.estateId);
return {
id: row.id,
slug: row.slug,
name: row.name,
parentChain: [...parent.parentChain, { kind: 'estate', id: parent.id, slug: parent.slug }],
};
}
@Injectable()
export class HierarchyAuditRepository {
constructor(@Inject(DB) private readonly db: Db) {}
/** Compose an event+outbox append into a caller-owned transaction. */
append(tx: Tx, input: AppendHierarchyEventInput): Promise<AppendHierarchyEventResult> {
return appendHierarchyEvent(tx, input);
}
snapshot(tx: Tx, kind: HierarchyNodeKind, id: string): Promise<HierarchyNodeSnapshot> {
return buildNodeSnapshot(tx, kind, id);
}
/** Per-target ordered event history (REQ-AUD-001 per-target ordering; read-only). */
async eventsForTarget(targetId: string): Promise<HierarchyAuditEventRow[]> {
return this.db
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.targetId, targetId))
.orderBy(asc(hierarchyAuditEvents.seq));
}
/**
* Claim the oldest pending outbox record (claim-by-CAS: the UPDATE is
* guarded on status so a lost race returns null and the caller retries).
*/
async claimPendingOutbox(): Promise<HierarchyOutboxRow | null> {
const candidates = await this.db
.select()
.from(hierarchyOutbox)
.where(eq(hierarchyOutbox.status, 'pending'))
.orderBy(asc(hierarchyOutbox.createdAt))
.limit(1);
const candidate = candidates[0];
if (!candidate) return null;
const claimed = await this.db
.update(hierarchyOutbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(and(eq(hierarchyOutbox.id, candidate.id), eq(hierarchyOutbox.status, 'pending')))
.returning();
return claimed[0] ?? null;
}
async completeOutbox(id: string): Promise<void> {
const now = new Date();
await this.db
.update(hierarchyOutbox)
.set({ status: 'delivered', deliveredAt: now, updatedAt: now })
.where(and(eq(hierarchyOutbox.id, id), eq(hierarchyOutbox.status, 'processing')));
}
/** Return a claimed record to pending (delivery failed; it stays replayable). */
async releaseOutbox(id: string): Promise<void> {
await this.db
.update(hierarchyOutbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(and(eq(hierarchyOutbox.id, id), eq(hierarchyOutbox.status, 'processing')));
}
}
@@ -1,17 +0,0 @@
import { Module } from '@nestjs/common';
import { HierarchyAuditRepository } from './hierarchy-audit.repository.js';
/**
* Hierarchy (tenancy/authorization structure) feature module.
*
* M4-1b-i ships the audit event + outbox machinery only (contract 1 §5.2).
* The hierarchy command family — controllers, DTOs, and the allowlisted
* class-table repositories — lands in M4-1b-ii once contract 2 (RBAC grant
* model) merges; until then this module exposes no routes, which the
* route-inventory witness asserts.
*/
@Module({
providers: [HierarchyAuditRepository],
exports: [HierarchyAuditRepository],
})
export class HierarchyModule {}
-192
View File
@@ -1,192 +0,0 @@
/**
* E2E integration test — SPA static serving (Phase P5 cutover, #1444; tests
* added in P6, #1445, review follow-up SF1 on PR #1453).
*
* Boots a real Nest+Fastify app the way main.ts does (mountSpaStatic after the
* controllers) against a fixture dist directory, and pins the serving
* contract:
*
* 1. `/` and client-side deep links fall back to index.html.
* 2. Declared API routes win over the catch-all.
* 3. Unknown backend paths (/api, /mcp, /socket.io) are JSON 404s, never the
* SPA page — including with a query string (`/api?x=1`).
* 4. Static files are served exactly; hashed /assets/ files get immutable
* cache headers, everything else revalidates (max-age=0), and a missing
* /assets/ file is a 404 — never the SPA fallback.
* 5. WEB_DIST_DIR unset disables SPA serving entirely.
* 6. WEB_DIST_DIR pointing at a directory without index.html fails at boot.
*/
import 'reflect-metadata';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import { Test } from '@nestjs/testing';
import { Controller, Get, type INestApplication } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import request from 'supertest';
import { mountSpaStatic } from './serve-spa.js';
const INDEX_HTML = '<!doctype html><html><body>mosaic spa fixture</body></html>\n';
const ASSET_JS = 'console.log("hashed asset");\n';
@Controller('api/spa-test')
class SpaTestController {
@Get('ping')
ping(): { ok: boolean } {
return { ok: true };
}
}
async function createApp(): Promise<INestApplication> {
const moduleRef = await Test.createTestingModule({
controllers: [SpaTestController],
}).compile();
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
// Mirror main.ts ordering: SPA mounting happens after the app (and its
// controllers) exist, before listen.
await mountSpaStatic(app as NestFastifyApplication);
await (app as NestFastifyApplication).getHttpAdapter().getInstance().ready();
return app;
}
describe('SPA static serving — fixture dist dir', () => {
let app: INestApplication;
let distDir: string;
let previousWebDistDir: string | undefined;
beforeAll(async () => {
distDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-fixture-'));
await writeFile(path.join(distDir, 'index.html'), INDEX_HTML);
await writeFile(path.join(distDir, 'favicon.svg'), '<svg></svg>\n');
await mkdir(path.join(distDir, 'assets'), { recursive: true });
await writeFile(path.join(distDir, 'assets', 'app-abc123.js'), ASSET_JS);
previousWebDistDir = process.env['WEB_DIST_DIR'];
process.env['WEB_DIST_DIR'] = distDir;
app = await createApp();
});
afterAll(async () => {
if (previousWebDistDir === undefined) {
delete process.env['WEB_DIST_DIR'];
} else {
process.env['WEB_DIST_DIR'] = previousWebDistDir;
}
await app.close();
await rm(distDir, { recursive: true, force: true });
});
it('serves index.html at /', async () => {
const res = await request(app.getHttpServer()).get('/');
expect(res.status).toBe(200);
expect(res.text).toBe(INDEX_HTML);
expect(res.headers['content-type']).toContain('text/html');
});
it('falls back to index.html for client-side deep links', async () => {
for (const deepLink of ['/chat', '/projects/42', '/settings']) {
const res = await request(app.getHttpServer()).get(deepLink);
expect(res.status, deepLink).toBe(200);
expect(res.text, deepLink).toBe(INDEX_HTML);
}
});
it('declared API routes win over the SPA catch-all', async () => {
const res = await request(app.getHttpServer()).get('/api/spa-test/ping');
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
});
it('unknown backend paths are JSON 404s, never the SPA page', async () => {
for (const backendPath of ['/api/nope', '/api', '/mcp/nope', '/socket.io/nope']) {
const res = await request(app.getHttpServer()).get(backendPath);
expect(res.status, backendPath).toBe(404);
expect(res.headers['content-type'], backendPath).toContain('application/json');
expect(res.body, backendPath).toMatchObject({ error: 'Not Found', statusCode: 404 });
}
});
it('a backend path with a query string is still a backend 404 (/api?x=1)', async () => {
const res = await request(app.getHttpServer()).get('/api?x=1');
expect(res.status).toBe(404);
expect(res.headers['content-type']).toContain('application/json');
});
it('serves static files exactly', async () => {
const res = await request(app.getHttpServer()).get('/favicon.svg');
expect(res.status).toBe(200);
// supertest buffers image/svg+xml as a Buffer body, not res.text.
const body = res.text || (res.body as Buffer).toString('utf8');
expect(body).toBe('<svg></svg>\n');
});
it('hashed /assets/ files get immutable cache headers', async () => {
const res = await request(app.getHttpServer()).get('/assets/app-abc123.js');
expect(res.status).toBe(200);
expect(res.text).toBe(ASSET_JS);
expect(res.headers['cache-control']).toBe('public, max-age=31536000, immutable');
});
it('missing /assets/ files are 404s, never the SPA page with an immutable header', async () => {
// The exact request a browser with a stale index.html makes after a
// deploy: the old hashed filename. Serving index.html here would poison
// caches with a year-long immutable entry whose body is HTML.
for (const missingAsset of ['/assets/app-old999.js', '/assets/app-old999.js?v=1']) {
const res = await request(app.getHttpServer()).get(missingAsset);
expect(res.status, missingAsset).toBe(404);
expect(res.text, missingAsset).not.toContain('mosaic spa fixture');
// The 404 carries no cache-control at all; ?? '' keeps the assertion valid.
expect(res.headers['cache-control'] ?? '', missingAsset).not.toContain('immutable');
}
});
it('index.html and non-asset files revalidate (no immutable caching)', async () => {
for (const revalidating of ['/', '/chat', '/favicon.svg']) {
const res = await request(app.getHttpServer()).get(revalidating);
expect(res.headers['cache-control'], revalidating).not.toContain('immutable');
}
});
it('non-GET unmatched requests keep the stock 404 (catch-all is GET/HEAD only)', async () => {
const res = await request(app.getHttpServer()).post('/chat');
expect(res.status).toBe(404);
expect(res.text).not.toContain('mosaic spa fixture');
});
});
describe('SPA static serving — configuration edges', () => {
it('WEB_DIST_DIR unset disables SPA serving', async () => {
const previous = process.env['WEB_DIST_DIR'];
delete process.env['WEB_DIST_DIR'];
try {
const app = await createApp();
const res = await request(app.getHttpServer()).get('/chat');
expect(res.status).toBe(404);
await app.close();
} finally {
if (previous !== undefined) {
process.env['WEB_DIST_DIR'] = previous;
}
}
});
it('WEB_DIST_DIR without index.html fails at boot', async () => {
const emptyDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-empty-'));
const previous = process.env['WEB_DIST_DIR'];
process.env['WEB_DIST_DIR'] = emptyDir;
try {
await expect(createApp()).rejects.toThrow(/index\.html.*does not exist/);
} finally {
if (previous === undefined) {
delete process.env['WEB_DIST_DIR'];
} else {
process.env['WEB_DIST_DIR'] = previous;
}
await rm(emptyDir, { recursive: true, force: true });
}
});
});
+4 -35
View File
@@ -8,12 +8,7 @@ import type { NestFastifyApplication } from '@nestjs/platform-fastify';
const BACKEND_PREFIXES = ['/api', '/mcp', '/socket.io'] as const;
function isBackendPath(url: string): boolean {
// Match on the path only: `/api?x=1` is a backend request, and the query
// string must never turn it into an SPA fallback.
const pathOnly = url.split('?', 1)[0] ?? url;
return BACKEND_PREFIXES.some(
(prefix) => pathOnly === prefix || pathOnly.startsWith(`${prefix}/`),
);
return BACKEND_PREFIXES.some((prefix) => url === prefix || url.startsWith(`${prefix}/`));
}
/**
@@ -44,7 +39,8 @@ export async function mountSpaStatic(app: NestFastifyApplication): Promise<void>
// Default cache semantics: public, max-age=0 with ETag/Last-Modified, so
// every response revalidates (304 when unchanged). Always correct, including
// for index.html after a deploy.
// for index.html after a deploy; immutable caching for hashed /assets/ files
// is a P6 optimization.
await app.register(
fastifyStatic as never,
{
@@ -54,28 +50,13 @@ export async function mountSpaStatic(app: NestFastifyApplication): Promise<void>
} as never,
);
const fastify = app.getHttpAdapter().getInstance();
// Files under /assets/ carry a content hash in their name (Vite emits them
// that way), so they get long-lived immutable caching: a changed file is a
// new URL, never a stale cache hit. An onSend hook rather than the plugin's
// `setHeaders` option, because @fastify/static applies its own computed
// cache-control (reply.headers) after calling setHeaders, overriding it.
fastify.addHook('onSend', (req, reply, payload, done) => {
const pathOnly = (req.raw.url ?? '').split('?', 1)[0] ?? '';
if (reply.statusCode === 200 && pathOnly.startsWith('/assets/')) {
void reply.header('cache-control', 'public, max-age=31536000, immutable');
}
done(null, payload);
});
// A wildcard route, not setNotFoundHandler: Nest installs its own not-found
// handler during init and Fastify allows only one. find-my-way matches
// most-specific-first, so every declared route (API, static files) wins over
// this catch-all; non-GET unmatched requests keep Fastify's stock 404.
const fastify = app.getHttpAdapter().getInstance();
fastify.get('/*', (req, reply) => {
const url = req.raw.url ?? '';
const pathOnly = url.split('?', 1)[0] ?? url;
if (isBackendPath(url)) {
// An unknown backend path is an API 404, never the SPA page.
void reply.code(404).send({
@@ -85,18 +66,6 @@ export async function mountSpaStatic(app: NestFastifyApplication): Promise<void>
});
return;
}
if (pathOnly === '/assets' || pathOnly.startsWith('/assets/')) {
// A missing hashed asset — typically a browser holding a stale
// index.html after a deploy — must 404. Falling through to the SPA
// fallback would return index.html as the asset body, and the onSend
// hook above would stamp it with a year-long immutable cache-control.
void reply.code(404).send({
message: `Asset ${pathOnly} not found`,
error: 'Not Found',
statusCode: 404,
});
return;
}
// sendFile is decorated by @fastify/static; its type augmentation targets
// a different fastify copy in the pnpm tree than the Nest adapter's.
(reply as unknown as { sendFile: (file: string) => unknown }).sendFile('index.html');
+28 -20
View File
@@ -1,14 +1,11 @@
import { test, expect } from '@playwright/test';
import { loginAs, ADMIN_USER, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { loginAs, ADMIN_USER, TEST_USER } from './helpers/auth.js';
test.describe('Admin page — admin user', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, ADMIN_USER.email, ADMIN_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded admin user — skipping admin tests',
);
test.skip(!url.includes('/chat'), 'No seeded admin user — skipping admin tests');
});
test('admin page loads with the Admin Panel heading', async ({ page }) => {
@@ -34,11 +31,15 @@ test.describe('Admin page — admin user', () => {
await page.goto('/admin');
await page.getByRole('button', { name: /system health/i }).click();
// Health cards or loading indicator should appear
const loadingOrCard = page
const hasLoading = await page
.getByText(/loading health/i)
.or(page.getByText(/database/i))
.first();
await expect(loadingOrCard).toBeVisible({ timeout: 10_000 });
.isVisible()
.catch(() => false);
const hasCard = await page
.getByText(/database/i)
.isVisible()
.catch(() => false);
expect(hasLoading || hasCard).toBe(true);
});
});
@@ -46,19 +47,26 @@ test.describe('Admin page — non-admin user', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping non-admin tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping non-admin tests');
});
test('non-admin visiting /admin never sees the admin panel', async ({ page }) => {
test('non-admin visiting /admin sees access denied or is redirected', async ({ page }) => {
await page.goto('/admin');
// Wait for the app shell to render (redirect and access-denied views both
// keep the sidebar), then assert the panel itself is absent. globalSetup
// seeds TEST_USER with role 'member', so this is a real authorization
// assertion, not environment-dependent.
await expect(page.getByRole('img', { name: /mosaic logo/i })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('heading', { name: /admin panel/i })).not.toBeVisible();
// Either redirected away or shown an access-denied message
const onAdmin = page.url().includes('/admin');
if (onAdmin) {
// Should show some access-denied content rather than the full admin panel
const hasPanel = await page
.getByRole('heading', { name: /admin panel/i })
.isVisible()
.catch(() => false);
// If heading is visible, the guard allowed access (user may have admin role in this env)
// — not a failure, just informational
if (!hasPanel) {
// access denied message, redirect, or guard placeholder
const url = page.url();
expect(url).toBeTruthy(); // environment-dependent — no hard assertion
}
}
});
});
+9 -5
View File
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { TEST_USER } from './helpers/auth.js';
// ── Login page ────────────────────────────────────────────────────────────────
@@ -49,14 +49,18 @@ test.describe('Login page', () => {
});
test('redirects to /chat after successful login', async ({ page }) => {
// Only meaningful with known-good credentials; against a live environment
// this would just probe someone else's user table.
test.skip(!REQUIRE_SEEDED_AUTH, 'needs seeded credentials (E2E_REQUIRE_SEEDED_AUTH=1)');
await page.goto('/login');
await page.getByLabel('Email').fill(TEST_USER.email);
await page.getByLabel('Password').fill(TEST_USER.password);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/chat/, { timeout: 10_000 });
// Either reaches /chat or shows an error (if credentials are wrong in this env).
// We assert a navigation away from /login, or the alert is shown.
await Promise.race([
expect(page).toHaveURL(/\/chat/, { timeout: 10_000 }),
expect(page.getByRole('alert')).toBeVisible({ timeout: 10_000 }),
]).catch(() => {
// Acceptable — environment may not have seeded credentials
});
});
});
+26 -19
View File
@@ -1,38 +1,45 @@
import { test, expect } from '@playwright/test';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { loginAs, TEST_USER } from './helpers/auth.js';
test.describe('Chat page', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
// If login failed (no seeded user in env) we may be on /login — skip
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
});
test('chat page loads and shows the conversation area', async ({ page }) => {
test('chat page loads and shows the welcome message or conversation list', async ({ page }) => {
await page.goto('/chat');
await expect(page.getByRole('heading', { level: 1, name: /chat/i })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByRole('log', { name: /conversation/i })).toBeVisible();
// Either there are conversations listed or the welcome empty-state is shown
const hasWelcome = await page
.getByRole('heading', { name: /welcome to mosaic chat/i })
.isVisible()
.catch(() => false);
const hasConversationPanel = await page
.locator('[data-testid="conversation-list"], nav, aside')
.first()
.isVisible()
.catch(() => false);
expect(hasWelcome || hasConversationPanel).toBe(true);
});
test('message composer input is visible', async ({ page }) => {
test('new conversation button is visible', async ({ page }) => {
await page.goto('/chat');
await expect(page.getByLabel('Message')).toBeVisible({ timeout: 10_000 });
// "Start new conversation" button or a "+" button in the sidebar
const newConvButton = page.getByRole('button', { name: /new conversation|start new/i }).first();
await expect(newConvButton).toBeVisible({ timeout: 10_000 });
});
test('command panel lists /new and exposes the run controls', async ({ page }) => {
test('clicking new conversation shows a chat input area', async ({ page }) => {
await page.goto('/chat');
// Conversations are command-driven: /new starts one via the commands panel.
const commandList = page.getByRole('list', { name: /available commands/i });
await expect(commandList).toBeVisible({ timeout: 10_000 });
await expect(commandList.getByText('/new', { exact: true })).toBeVisible();
await expect(page.getByLabel('Command name')).toBeVisible();
await expect(page.getByRole('button', { name: /run command/i })).toBeVisible();
// Find any button that creates a new conversation
const newBtn = page.getByRole('button', { name: /new conversation|start new/i }).first();
await newBtn.click();
// After creating, a text input for sending messages should appear
const chatInput = page.getByRole('textbox').or(page.locator('textarea')).first();
await expect(chatInput).toBeVisible({ timeout: 10_000 });
});
test('sidebar navigation is present on chat page', async ({ page }) => {
-95
View File
@@ -1,95 +0,0 @@
import type { FullConfig } from '@playwright/test';
import { ADMIN_USER, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
/**
* Seed the E2E users through the gateway's real APIs (#1445, P6).
*
* On a fresh database (CI boots the gateway on the embedded PGlite path):
* 1. POST /api/bootstrap/setup creates ADMIN_USER as the first admin.
* 2. The admin signs in and creates TEST_USER via the better-auth admin API.
*
* Against an environment that already has users (needsSetup=false), seeding is
* skipped entirely: the specs keep their own skip-when-login-fails guards, so
* a live environment stays usable as a test target without mutation. Under
* E2E_REQUIRE_SEEDED_AUTH=1 (CI) that state is instead a hard failure and the
* guards are disabled — see helpers/auth.ts.
*
* On a fresh database, any seeding failure throws and fails the whole run: an
* E2E gate whose authenticated suites silently skip would pass while proving
* nothing.
*/
export default async function globalSetup(config: FullConfig): Promise<void> {
const baseURL = config.projects[0]?.use?.baseURL ?? 'http://localhost:14242';
const statusRes = await fetch(`${baseURL}/api/bootstrap/status`);
if (!statusRes.ok) {
throw new Error(`GET /api/bootstrap/status returned ${statusRes.status} — is the gateway up?`);
}
const status = (await statusRes.json()) as { needsSetup: boolean };
if (!status.needsSetup) {
if (REQUIRE_SEEDED_AUTH) {
// CI boots the gateway on a fresh HOME-isolated database, so an
// already-populated one means the isolation regressed — refuse to run
// against unknown data rather than skip-and-pass.
throw new Error(
'E2E_REQUIRE_SEEDED_AUTH=1 but the database already has users — gateway HOME isolation regressed?',
);
}
console.info('[e2e setup] users already exist; skipping seed');
return;
}
const setupRes = await fetch(`${baseURL}/api/bootstrap/setup`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: ADMIN_USER.name,
email: ADMIN_USER.email,
password: ADMIN_USER.password,
}),
});
if (!setupRes.ok) {
throw new Error(
`POST /api/bootstrap/setup failed (${setupRes.status}): ${await setupRes.text()}`,
);
}
console.info(`[e2e setup] bootstrap admin created: ${ADMIN_USER.email}`);
// better-auth's CSRF protection rejects requests without an Origin header
// (403 MISSING_OR_NULL_ORIGIN), so the server-side fetches here send the
// gateway's own origin — the same value a browser tab on the SPA would send.
const authHeaders = { 'content-type': 'application/json', origin: baseURL };
const signInRes = await fetch(`${baseURL}/api/auth/sign-in/email`, {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({ email: ADMIN_USER.email, password: ADMIN_USER.password }),
});
if (!signInRes.ok) {
throw new Error(`admin sign-in failed (${signInRes.status}): ${await signInRes.text()}`);
}
const cookies = signInRes.headers
.getSetCookie()
.map((cookie) => cookie.split(';', 1)[0])
.join('; ');
if (!cookies) {
throw new Error('admin sign-in returned no session cookie');
}
const createRes = await fetch(`${baseURL}/api/auth/admin/create-user`, {
method: 'POST',
headers: { ...authHeaders, cookie: cookies },
body: JSON.stringify({
name: TEST_USER.name,
email: TEST_USER.email,
password: TEST_USER.password,
role: 'member',
}),
});
if (!createRes.ok) {
throw new Error(
`POST /api/auth/admin/create-user failed (${createRes.status}): ${await createRes.text()}`,
);
}
console.info(`[e2e setup] test user created: ${TEST_USER.email}`);
}
+1 -18
View File
@@ -13,28 +13,11 @@ export const ADMIN_USER = {
};
/**
* Set when the database was seeded by global-setup (CI sets it in the
* publish.yml e2e step). Seeded credentials MUST work, so login failures are
* hard failures and the skip-when-login-fails guards are disabled — otherwise
* a login regression would skip every authenticated suite and the gate would
* pass while proving nothing. Unset (a live environment used as a test
* target), the guards stay on and unseeded credentials skip their suites.
*/
export const REQUIRE_SEEDED_AUTH = process.env['E2E_REQUIRE_SEEDED_AUTH'] === '1';
/**
* Fill the login form and submit, then wait for the post-login redirect to
* /chat. Under REQUIRE_SEEDED_AUTH a missed redirect throws (failing the
* test). Otherwise the timeout is swallowed: the page stays on /login and the
* callers' `test.skip(...)` guards see that. Without this wait, every guard
* read page.url() before the redirect happened and skipped its suite even
* when login succeeded (#1445).
* Fill the login form and submit. Waits for navigation after success.
*/
export async function loginAs(page: Page, email: string, password: string): Promise<void> {
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: /sign in/i }).click();
const redirect = page.waitForURL(/\/chat/, { timeout: 10_000 });
await (REQUIRE_SEEDED_AUTH ? redirect : redirect.catch(() => {}));
}
+11 -23
View File
@@ -1,22 +1,16 @@
import { test, expect } from '@playwright/test';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { loginAs, TEST_USER } from './helpers/auth.js';
test.describe('Sidebar navigation', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
});
test('sidebar shows the Mosaic brand', async ({ page }) => {
test('sidebar shows Mosaic brand link', async ({ page }) => {
await page.goto('/chat');
// The brand block is a logo image plus "Mosaic / Mission Control" text,
// not a link.
await expect(page.getByRole('img', { name: /mosaic logo/i })).toBeVisible();
await expect(page.getByText('Mission Control')).toBeVisible();
await expect(page.getByRole('link', { name: /mosaic/i }).first()).toBeVisible();
});
test('Chat nav link navigates to /chat', async ({ page }) => {
@@ -54,12 +48,11 @@ test.describe('Sidebar navigation', () => {
test('active link is visually highlighted', async ({ page }) => {
await page.goto('/chat');
// The sidebar marks the active item with `font-medium` (plus an inline
// primary-color style); inactive items get the hover class instead.
// The active link should have a distinct class — check that the Chat link
// has the active style class (bg-blue-600/20 text-blue-400)
const chatLink = page.getByRole('link', { name: /^chat$/i }).first();
const projectsLink = page.getByRole('link', { name: /^projects$/i }).first();
await expect(chatLink).toHaveClass(/font-medium/);
await expect(projectsLink).not.toHaveClass(/font-medium/);
const cls = await chatLink.getAttribute('class');
expect(cls).toContain('blue');
});
});
@@ -67,23 +60,18 @@ test.describe('Route transitions', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
});
test('navigating chat → projects → settings → chat works without errors', async ({ page }) => {
await page.goto('/chat');
await expect(page).toHaveURL(/\/chat/);
// level: 1 — empty-state h2s ("No projects yet") also match the loose
// patterns, and a two-element match is a strict-mode violation.
await page.goto('/projects');
await expect(page.getByRole('heading', { level: 1, name: /projects/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible();
await page.goto('/settings');
await expect(page.getByRole('heading', { level: 1, name: /settings/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
await page.goto('/chat');
await expect(page).toHaveURL(/\/chat/);
+19 -14
View File
@@ -1,23 +1,16 @@
import { test, expect } from '@playwright/test';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { loginAs, TEST_USER } from './helpers/auth.js';
test.describe('Projects page', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
});
test('projects page loads with heading', async ({ page }) => {
await page.goto('/projects');
// level: 1 — the "No projects yet" empty-state h2 also matches /projects/i
// and a two-element match is a strict-mode violation.
await expect(page.getByRole('heading', { level: 1, name: /projects/i })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible({ timeout: 10_000 });
});
test('shows empty state or project cards when loaded', async ({ page }) => {
@@ -25,11 +18,23 @@ test.describe('Projects page', () => {
// Wait for loading state to clear
await expect(page.getByText(/loading projects/i)).not.toBeVisible({ timeout: 10_000 });
const cardsOrEmpty = page
const hasProjects = await page
.locator('[class*="grid"]')
.or(page.getByText(/no projects yet/i))
.first();
await expect(cardsOrEmpty).toBeVisible({ timeout: 10_000 });
.isVisible()
.catch(() => false);
const hasEmpty = await page
.getByText(/no projects yet/i)
.isVisible()
.catch(() => false);
expect(hasProjects || hasEmpty).toBe(true);
});
test('shows Active Mission section', async ({ page }) => {
await page.goto('/projects');
await expect(page.getByRole('heading', { name: /active mission/i })).toBeVisible({
timeout: 10_000,
});
});
test('sidebar navigation is present', async ({ page }) => {
+2 -5
View File
@@ -1,14 +1,11 @@
import { test, expect } from '@playwright/test';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { loginAs, TEST_USER } from './helpers/auth.js';
test.describe('Settings page', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
});
test('settings page loads with heading', async ({ page }) => {
+7 -14
View File
@@ -1,30 +1,23 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright E2E configuration for the Mosaic web SPA.
* Playwright E2E configuration for Mosaic web app.
*
* Assumes the NestJS gateway is already running on http://localhost:14242 and
* serving the built SPA bundle (WEB_DIST_DIR pointing at apps/web/dist) — the
* same serving path production uses (Phase P5, #1444). Override the target
* with PLAYWRIGHT_BASE_URL.
*
* global-setup seeds the E2E users through the real bootstrap and admin APIs
* when the database is empty; against an already-populated environment it
* seeds nothing.
* Assumes:
* - Next.js web app running on http://localhost:3000
* - NestJS gateway running on http://localhost:14242
*
* Run with: pnpm --filter @mosaicstack/web test:e2e
*/
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
fullyParallel: true,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
// CI needs the verdict in the step log; the html report is a local tool.
reporter: process.env['CI'] ? 'list' : 'html',
reporter: 'html',
use: {
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:14242',
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
@@ -34,6 +27,6 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] },
},
],
// Do NOT auto-start a server — tests assume the gateway is already running.
// Do NOT auto-start the dev server — tests assume it is already running.
// webServer is intentionally omitted so tests can run against a live env.
});
+3 -13
View File
@@ -57,16 +57,6 @@ function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
return p.value as T;
}
// The reset must not outlive the tab: an uncleared setTimeout fires into a
// torn-down environment (unmount, or jsdom teardown under vitest).
function useSavedBadgeReset(saveState: SaveState, setSaveState: (s: SaveState) => void): void {
useEffect(() => {
if (saveState !== 'saved') return undefined;
const timer = setTimeout(() => setSaveState('idle'), 2000);
return () => clearTimeout(timer);
}, [saveState, setSaveState]);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function SettingsPage(): React.ReactElement {
@@ -121,7 +111,6 @@ function ProfileTab({
const [image, setImage] = useState(session?.user.image ?? '');
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
useSavedBadgeReset(saveState, setSaveState);
// Sync from session when it loads
useEffect(() => {
@@ -142,6 +131,7 @@ function ProfileTab({
return;
}
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update profile';
setErrorMsg(message);
@@ -204,7 +194,6 @@ function AppearanceTab(): React.ReactElement {
const [defaultModel, setDefaultModel] = useState('');
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
useSavedBadgeReset(saveState, setSaveState);
useEffect(() => {
api<Preference[]>('/api/memory/preferences?category=appearance')
@@ -250,6 +239,7 @@ function AppearanceTab(): React.ReactElement {
: []),
]);
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save preferences';
setErrorMsg(message);
@@ -333,7 +323,6 @@ function NotificationsTab(): React.ReactElement {
const [emailDigest, setEmailDigest] = useState(false);
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
useSavedBadgeReset(saveState, setSaveState);
useEffect(() => {
api<Preference[]>('/api/memory/preferences?category=communication')
@@ -380,6 +369,7 @@ function NotificationsTab(): React.ReactElement {
}),
]);
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save preferences';
setErrorMsg(message);
-9
View File
@@ -882,12 +882,3 @@ Objective: for alpha 0.0.50, the release cannot publish, report, or display work
### Out of scope
The canonical dispatcher/control-plane vertical slice (work graph, execution attempts, fenced leases, typed check-in, independent verifier dispatch) is decided post-alpha (SDLC-D-033, option B). Multi-pipeline verification certificates (SDLC-D-034 option B) are post-alpha. Full AF-1..AF-4 objective matrices and Mission Control portfolio surfaces are post-alpha.
## Official CLI Capability and Tool Migration Workstream (T78)
Normative contract on integration trunk `next`:
[docs/requirements/cli-capability-migration.md](./requirements/cli-capability-migration.md):
migrates agent-facing operations from directly invoked scripts into documented, first-class
`mosaic` CLI command groups, together with the central-registry resolver, capability catalog,
adapter boundary, and phased legacy-tool-tree decommission the migration requires. The contract
carries its own implementation hold and delivery stages.
+1 -3
View File
@@ -12,9 +12,7 @@ design; scoping one requires its own PRD section or requirements doc plus
review.
Phases are product phases. The in-flight platform workstreams (KBN-100/101
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, T78 CLI
capability migration
([requirements](./requirements/cli-capability-migration.md)), and the other
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, and the other
Part II contracts in the PRD) run as parallel tracks under their own issues
and are prerequisites where noted.
-1
View File
@@ -18,7 +18,6 @@
- [Active task rollup](TASKS.md) — orchestrator-owned work state; workers do not modify it.
- [MVP mission manifest](MISSION-MANIFEST.md) — control-plane mission rollup; activity and status remain under its authorized owner.
- [Documentation catalog and truth audit](reports/documentation/2026-08-10-docs-catalog-audit.md) — complete baseline inventory, evidence labels, broken-link clusters, and migration recommendations.
- [CLI capability migration requirements](requirements/cli-capability-migration.md): T78 official CLI capability and tool migration contract, normative contract with implementation hold (M0).
## Protected current authority and executable books
-14
View File
@@ -212,20 +212,6 @@ Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts
`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass.
### E2E Gate (#1445, P6)
Trunk publish pipelines run a headless Playwright suite (`e2e` step) before any image publishes: the built gateway `dist` boots on a throwaway embedded PGlite database (isolated via a fresh `HOME`), serves the built SPA bundle through `WEB_DIST_DIR` — the same serving path the gateway image ships — and the suite runs against it inside the pinned `mcr.microsoft.com/playwright` image. `E2E_REQUIRE_SEEDED_AUTH=1` makes login failures hard failures (the skip-when-login-fails guards are a live-environment affordance only). Both image build steps depend on this gate.
Reproduce locally (Ubuntu-based environments; Fedora's headless-shell rendering is broken):
```bash
pnpm build
BETTER_AUTH_SECRET="$(head -c 32 /dev/urandom | base64)" GATEWAY_PORT=14242 \
WEB_DIST_DIR="$PWD/apps/web/dist" HOME="$(mktemp -d)" node apps/gateway/dist/main.js &
E2E_REQUIRE_SEEDED_AUTH=1 PLAYWRIGHT_BASE_URL=http://localhost:14242 \
pnpm --filter @mosaicstack/web exec playwright test
```
---
## Adding New Agent Tools
File diff suppressed because it is too large Load Diff
@@ -1,748 +0,0 @@
---
kind: spec
status: active
source_of_truth: true
---
# Official Mosaic CLI Capability and Tool Migration
- **Workstream:** T78
- **Status:** active requirements contract, implementation held by the M0 gates
- **Decision authority:** Jason Woltje
- **Design owner:** Vision
- **Integration trunk:** `next`
This contract is authoritative only on the integration trunk `next`. Branch copies are proposals.
Publication does not authorize implementation until the M0 milestone, task-graph, interface, and
partition gates pass.
## 1. Purpose
Migrate agent-facing operations from directly invoked scripts into documented, first-class command
groups in the existing TypeScript and Node.js `mosaic` CLI. The CLI becomes the stable interface
for operators, agents, the webUI, future seat containers, and future `mosaicd` execution.
The mission also phases out the installed `~/.config/mosaic/tools` script surface. Existing scripts
may remain private compatibility adapters only while measured consumers still require them.
## 2. Product alignment
Items 1 through 3 implement PRD D8 and D12:
1. The CLI is the primary execution surface.
2. The webUI uses Gateway APIs backed by the same official capability contracts.
3. A missing official capability is built before a webUI bypass is accepted.
This contract adds one explicit extension beyond D8 and D12: no harness, skill, or agent receives a
separate business-logic path around the CLI and Gateway capability contract.
This contract does not replace the fleet north star, issue `#1382`, the fleet configuration
contract `#758`, the exact fleet communications contract `#766`, or future container and `mosaicd`
specifications. It defines the interfaces those tracks consume.
## 3. Fixed decisions
| ID | Decision |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| T78-D1 | Extend the existing official TypeScript and Node.js `mosaic` CLI. A second Python or shell entrypoint is forbidden. |
| T78-D2 | Expose documented groups such as `mosaic git`, `mosaic comms`, and `mosaic ci`. A generic public `mosaic tools` passthrough is forbidden. |
| T78-D3 | Resolve homes, endpoints, sockets, tool locations, and runtime paths through the central registry and one typed resolver. Commands do not hard-code them. |
| T78-D4 | One rootless container per seat is the target sandbox. It has a read-only root filesystem, no container-runtime socket, and lifecycle through future `mosaicd`. |
| T78-D5 | Dispatch is per-site. Localhost `orch-01` alone dispatches USC-seat implementation. Homelab `orch-01` alone dispatches homelab-seat implementation and homelab-owned surfaces. |
| T78-D6 | Tmux and fleet-comms remain temporary communications adapters behind a transport-neutral CLI contract. |
| T78-D7 | Decommissioning is phased and mechanically enforced. Removal requires zero measured consumers and a discriminating planted-reference control. |
Derived security boundary:
- `~/.mosaic/tools` is canonical working source during migration. It is not automatically trusted
runtime installation state.
- Reviewed source is promoted into installed or packaged runtime artifacts.
- A multi-writer brain-repository push must not silently replace credential-bearing executable code
used by every seat.
## 4. Explicitly rejected alternatives
| Alternative | Rejection reason |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Separate Python CLI | Creates a second contract, release path, and policy surface. |
| Public `mosaic tools <script>` passthrough | Preserves script names and paths as the API instead of defining capabilities. |
| CLI allowlists as the sandbox | Parser allowlists do not isolate files, credentials, processes, networks, or container control. |
| Execute the synced working tree as the final runtime | A brain push would become host-wide code execution authority. |
| Big-bang script rewrite and deletion | Mature queue, identity, credential, and uncertainty behavior would be changed without parity evidence. |
| Tmux-shaped communications API | It would force future Matrix or native transports to preserve tmux concepts. |
## 5. Terminology
- **Central registry:** the schema-v1 `config.json` authority filed in issue `#1382`.
- **Registry resolver:** the typed reader that validates and resolves central-registry values.
- **Capability catalog:** the typed inventory of public capability identifiers and behavior. It is
not the central registry.
- **Capability policy:** data that maps verified actor and lane identity to allowed capabilities and
scopes.
- **Local adapter:** a temporary in-process or private-script implementation used before `mosaicd`
is available.
- **Broker adapter:** the future client transport to `mosaicd` outside the seat container.
- **Installed legacy tree:** `~/.config/mosaic/tools`.
- **Canonical working source:** `~/.mosaic/tools` during the migration period.
- **Runtime artifact:** reviewed package or installed bytes actually executed by a seat.
## 6. Public CLI grammar
### CLI-REQ-001: First-class command groups
The official help surface MUST register domain groups directly:
```text
mosaic git ...
mosaic comms ...
mosaic ci ...
```
Future domains MAY include `infra`, `identity`, and other reviewed capability families. They MUST
NOT appear through a generic script dispatcher.
### CLI-REQ-002: Stable command shape
New capability commands use this grammar:
```text
mosaic <domain> <resource> <verb> [target] [options]
```
The first pilot freezes these paths:
```text
mosaic git issue list
mosaic git issue view <number>
mosaic git issue comment <number> --input <path|->
```
Capability identifiers are independent from display text:
| Command | Capability ID | Class |
| -------------------------- | ------------------- | ---------------- |
| `mosaic git issue list` | `git.issue.list` | read |
| `mosaic git issue view` | `git.issue.view` | read |
| `mosaic git issue comment` | `git.issue.comment` | bounded mutation |
Renaming a command path does not silently rename its capability identifier. Either change requires a
versioned compatibility decision.
### CLI-REQ-003: Common targeting options
The pilot supports:
- `--instance <name>` for the configured provider instance.
- `--repo <owner/name>` for the provider repository.
- `--format <table|json>` for output selection.
- `--correlation-id <id>` for a caller-supplied valid identifier. Omission generates one.
- `--idempotency-key <key>` for mutations. Omission generates one and returns it.
An instance may be inferred only when the registry has exactly one valid instance for that domain.
A repository may be inferred only from a validated current repository declaration and an
unambiguous canonical remote. Ambiguity fails closed and names the missing field.
No public option forces local compatibility mode when policy selected broker mode. A caller cannot
downgrade the execution boundary.
### CLI-REQ-004: Mutation input
`git.issue.comment` reads its body from `--input <path>` or stdin with `--input -`. The CLI MUST:
1. reject a missing or empty body.
2. apply a documented byte limit before provider access.
3. never place the body in process arguments, diagnostics, or audit metadata.
4. compute a body digest for read-back verification without exposing the body.
5. avoid automatic retry after an uncertain provider mutation.
### CLI-REQ-005: Structured result envelope
JSON output uses one versioned envelope:
```ts
interface CapabilityResultV1<T> {
schemaVersion: 1;
capabilityId: string;
status: 'succeeded' | 'invalid' | 'denied' | 'failed' | 'uncertain' | 'unavailable';
executionMode: 'local-adapter' | 'mosaicd';
identityTrust: 'local-asserted' | 'runtime-verified';
correlationId: string;
idempotencyKey?: string;
target: Record<string, string | number | boolean | null>;
data?: T;
diagnostics: Array<{
code: string;
message: string;
field?: string;
retryable: boolean;
}>;
audit:
| { authority: 'mosaicd'; recorded: true; eventId: string }
| { authority: 'none'; recorded: false; localEventId?: string };
}
```
`target` and `diagnostics` contain no credentials or unbounded provider body. Table output is a
human view of the same result and cannot carry a different verdict.
### CLI-REQ-006: Exit behavior
| Exit | Meaning |
| ---: | --------------------------------------------------------------------------- |
| 0 | `succeeded` |
| 2 | `invalid`: invalid input, invalid configuration, or unsupported schema |
| 3 | `denied` by capability or scope policy |
| 4 | `failed` with a confirmed non-success outcome |
| 5 | `uncertain`, including a mutation whose provider result cannot be confirmed |
| 6 | `unavailable`, including missing broker, credentials, or required adapter |
A provider HTTP success alone is insufficient. The adapter validates the expected response shape.
A mutation that may have landed but lacks confirmation returns exit 5 and is never described as
failed or safe to retry. For a provider-native idempotent mutation, manual reconciliation MAY retry
the same key. For `uncertain-no-retry`, help directs the caller to a read-back check and forbids
mutation retry.
### CLI-REQ-007: Help and discovery
The capability catalog generates or validates:
- `mosaic --help` command-group listing.
- group and command help.
- stable capability identifiers.
- machine-readable capability discovery.
- documentation tables.
- policy-generation inputs.
- tests that reject undocumented public commands and orphaned capabilities.
## 7. Central registry resolver
### CFG-REQ-001: One distinct resolver
Implement one exported resolver named `MosaicRegistryResolver` or another name explicitly approved
in the contract review. It MUST NOT be named `ConfigService`. The existing
`packages/mosaic/src/config/config-service.ts` exports `ConfigService` for SOUL, USER, and TOOLS
content and remains a separate concern.
### CFG-REQ-002: Frozen schema consumption
The resolver consumes schema v1 from issue `#1382` without creating parallel keys. Every key is
optional. The exact v1 surface is:
- `$schema`, with the known marker `mosaic-config-v1`.
- `mosaicHome`, reserved, null, and without a v1 consumer.
- `brainHome`, default `~/.mosaic`.
- `instances.gitea.<name>.url`.
- `fleet.socket`.
- `harnessConfig.pi.agentDir`.
- `harnessConfig.claude.configDir`.
- `harnessConfig.claude.secureStorageDir`.
Credential values, model and effort defaults, and `fleet.rosterPath` are forbidden. A non-null
`mosaicHome` value fails validation because v1 reserves the field without implementing relocation.
An absent or null `$schema` is interpreted as v1, the exact `mosaic-config-v1` marker is accepted,
and every other non-null marker fails before value resolution.
Absent or null values select the framework default. A `~` path prefix expands at read time and is
never rewritten into the user file. Unknown top-level and nested keys warn loudly and are ignored
for rolling-version compatibility. Every warning and machine-readable diagnostic names the full
ignored key path, so a typo is visible at every read.
Fail-closed read behavior applies to invalid JSON, a failed C1 version check, a known key with an
invalid type or value, and a present but empty or invalid override. An optional
`mosaic registry validate` lint mode MAY reject unknown keys for operator validation, but the normal
resolver read path does not. This top-level group is separate from the existing `mosaic config`
commands backed by `ConfigService`.
### CFG-REQ-003: Resolution precedence
For each supported value, resolution follows exactly:
1. the schema-defined `MOSAIC_<KEY>_OVERRIDE` environment override.
2. validated `config.json` value.
3. one centralized framework default, when the key defines a default.
A present override always wins. An empty or invalid override fails and does not fall through to the
file or default. `$schema` and reserved `mosaicHome` have no environment override. Consumed values
use this collision-free mapping:
| Registry key | Environment override |
| --------------------------------------- | ---------------------------------------------------------- |
| `brainHome` | `MOSAIC_BRAIN_HOME_OVERRIDE` |
| `fleet.socket` | `MOSAIC_FLEET_SOCKET_OVERRIDE` |
| `harnessConfig.pi.agentDir` | `MOSAIC_HARNESS_CONFIG_PI_AGENT_DIR_OVERRIDE` |
| `harnessConfig.claude.configDir` | `MOSAIC_HARNESS_CONFIG_CLAUDE_CONFIG_DIR_OVERRIDE` |
| `harnessConfig.claude.secureStorageDir` | `MOSAIC_HARNESS_CONFIG_CLAUDE_SECURE_STORAGE_DIR_OVERRIDE` |
| `instances.gitea.<name>.url` | `MOSAIC_INSTANCES_GITEA_<NAME>_URL_OVERRIDE` |
Gitea instance names match `[a-z][a-z0-9-]*`. The override name uppercases the instance name and
maps hyphen to underscore. Underscores are not valid in source instance names, so two valid names
cannot flatten to the same override.
A value without a valid result fails before adapter or provider access. Invalid known URLs, socket
names, paths, and value types fail closed. Unknown keys follow CFG-REQ-002.
### CFG-REQ-004: Typed provenance
Every resolved value carries non-secret provenance:
```ts
type RegistryValueSource = 'override' | 'registry' | 'framework-default';
interface ResolvedRegistryValue<T> {
key: string;
value: T;
source: RegistryValueSource;
schemaVersion: 1;
}
```
Machine-readable diagnostics include the full path of every ignored unknown key. Diagnostics may
name a known key and source class. They do not emit credential values or unrelated configuration.
### CFG-REQ-005: Bootstrap and path safety
Registry discovery is the fixed path `~/.config/mosaic/config.json`. It has no v1 search path and no
alternate location. The file is the one user-updatable path inside `~/.config/mosaic` and is
protected by a deny-wins upgrade carve-out. Upgrades never overwrite user edits.
This fixed bootstrap avoids circular dependence on reserved `mosaicHome`. A seat container reads its
own internal `~/.config/mosaic/config.json`, supplied by the container topology, rather than a host
path or a relocation flag. Path values are expanded, normalized, validated, and tested under at
least two distinct home roots.
No command embeds home directories, script locations, provider endpoints, seat paths, or tmux socket
names outside the resolver and its reviewed defaults.
### CFG-REQ-006: Schema evolution
A new key requires:
1. a named consumer.
2. a `#1382` schema amendment.
3. joint ACK from the frozen-schema and resolver-contract custodians until handoff, recorded by
custodian-authored commits rather than relayed tokens alone.
4. parser, invalid-input, default, and two-root tests.
5. documentation in the same reviewed change.
Speculative keys are forbidden.
### CFG-REQ-007: Joint freeze evidence
The v1 resolver contract is jointly frozen:
- Fred, frozen-schema custodian, accepted C1 and C3 through token
`CLI-T78-REGISTRY-FREEZE ACCEPT`, then accepted amended C2 through token
`CLI-T78-REGISTRY-C2 ACCEPT`.
- Homelab `orch-01`, issue and resolver-contract custodian, accepted C1 and C3 and supplied the
adopted C2 rolling-version amendment in the fleet-comms repository, message
`sites/usc/20260827T004509Z__to-vision__from-homelab.orch-01__683e4c.md`, blob
`35a7c4c1e54eb9196abeef3135d211ee1dfc46db`.
Durable lane provenance is recorded in the Mosaic brain repository at
`fleet/lanes/cli-migration/registry-freeze-evidence.md` and the independent custodian-authored
`fleet/lanes/cli-migration/registry-freeze-fred-ack.md`. Schema evolution after this freeze still
follows CFG-REQ-006.
## 8. Capability catalog and policy
### CAP-REQ-001: One typed catalog
Each capability definition records:
```ts
type CapabilityEffect = 'read' | 'bounded-mutation' | 'privileged-mutation';
interface CapabilityDefinitionV1 {
id: string;
commandPath: readonly string[];
effect: CapabilityEffect;
targetSchema: string;
inputSchema: string;
outputSchema: string;
credentialClass: string | null;
requiredScopes: readonly string[];
auditRequired: boolean;
timeoutMs: number;
idempotency: 'read' | 'required-key' | 'provider-native' | 'uncertain-no-retry';
adapterId: string;
deprecation: 'active' | 'deprecated' | 'removed';
}
```
The catalog is data consumed by the parser, help, policy, documentation, and tests. Command handlers
must not maintain independent copies of these facts.
### CAP-REQ-002: Policy is not parser logic
Capability grants map verified actor identity and lane to capability IDs and resource scopes. They
are data. A named seat receives no authority from its name alone.
The user-editable central registry is placement and endpoint configuration, not authorization
policy. It MUST NOT contain lane grants or let a seat self-grant capability scope. Target authority
lives in the `mosaicd` control-plane store outside seat containers and returns a policy revision and
digest with every decision.
Before `mosaicd`, local compatibility mode may evaluate a package-owned policy for behavior and test
parity, but it reports locally asserted identity and makes no broker-grade authorization claim. Mode
selection is declared by topology and policy, never inferred from broker availability. A missing or
unhealthy required broker returns `unavailable`. It never falls back to local mode.
A capability using a shared, service, operator, or admin credential is broker-only. Local mode may
use only the acting seat's own credential against a registry endpoint. Privileged infrastructure,
merge, deployment, identity, authorization, and secret-management cutover requires `mosaicd`. A
future policy-store key or broker endpoint still requires CFG-REQ-006 and the `mosaicd` topology
contract.
### CAP-REQ-003: Identity trust
CLI arguments and ordinary environment variables are actor hints, not authorization identity. The
local adapter reports that identity is locally asserted and MUST NOT claim broker-grade
authorization. `mosaicd` derives or verifies actor identity from the authenticated seat runtime.
### CAP-REQ-004: Positive and denied controls
Every capability test includes:
1. an allowed request with expected result.
2. a denied request differing only in the relevant lane or scope.
3. a malformed target or configuration denial.
4. a credential-redaction assertion.
5. a verdict-discrimination control that proves the test can fail.
## 9. Adapter and broker contract
### EXE-REQ-001: One capability request
```ts
interface CapabilityRequestV1 {
schemaVersion: 1;
capabilityId: string;
actorHint?: { seat?: string; lane?: string };
target: Record<string, string | number | boolean | null>;
arguments: Record<string, string | number | boolean | null>;
correlationId: string;
idempotencyKey?: string;
}
```
Credential values and unbounded comment bodies are not serialized into audit-safe request metadata.
Body content travels through a bounded private input channel appropriate to the adapter.
### EXE-REQ-002: Local compatibility adapter
The local adapter MAY call a reviewed in-process implementation or a private script adapter. It
MUST preserve existing queue guards, wrapper-first behavior, credential resolution, response
validation, and mutation uncertainty. It reports `executionMode: local-adapter` and
`identityTrust: local-asserted`.
Private child adapters receive bodies and credentials only through stdin, owner-only temporary
files, or inherited file descriptors, never child-process arguments. Captured child stderr, shell
trace, and diagnostics are inside the redaction boundary. Local results always use
`audit: { authority: 'none', recorded: false }`. A local event identifier is not authoritative
audit evidence.
The local adapter is compatibility, not a sandbox or authorization claim.
### EXE-REQ-003: `mosaicd` broker adapter
The broker adapter sends the same logical request to `mosaicd` outside the seat container.
`mosaicd` owns:
- authoritative seat identity, recorded in audit from the derived runtime identity rather than
`actorHint`.
- capability and scope authorization.
- credential resolution.
- operation execution.
- output sanitization.
- audit persistence.
- bounded timeout and cancellation behavior.
A contradictory `actorHint` produces a diagnostic and never replaces the derived actor. Broker
results report `identityTrust: runtime-verified`. `audit.recorded: true` is valid only after
`mosaicd` confirms persistence and returns its event ID. Consumers verify authoritative evidence
against the broker trail, not the seat-produced envelope alone.
The transport and endpoint are supplied by immutable container topology and the reviewed central
registry contract. No command hard-codes a daemon socket.
### EXE-REQ-004: Packaged implementation boundary
The initial TypeScript layout is:
- `packages/mosaic/src/central-registry/` for `MosaicRegistryResolver`, schema, and provenance.
- `packages/mosaic/src/capabilities/` for catalog, request, result, policy interfaces, and tests.
- `packages/mosaic/src/capabilities/adapters/local/` for temporary local adapter modules.
- `packages/mosaic/src/capabilities/adapters/mosaicd/` for the broker client seam.
- `packages/mosaic/src/commands/git.ts`, with later first-class domain files following the same
command pattern.
Remaining script implementations may be promoted under `packages/mosaic/framework/tools/` as
private packaged adapters during transition. Their installed paths are resolver-owned and are not
public command contracts. No production adapter imports or executes source from the brain working
tree as the final path.
### EXE-REQ-005: Container boundary
The representative seat container has:
- one seat identity.
- rootless execution.
- read-only root filesystem, with explicit bounded writable mounts.
- a read-only internal `~/.config/mosaic/config.json` supplied by topology.
- no host credential tree.
- no shared host or fleet tmux socket.
- a dedicated per-seat tmux socket only for one named, reviewed temporary adapter with a stated
removal stage.
- no Docker, Podman, or other container-runtime socket.
- no installed legacy tool tree mount.
- network access limited to declared capability paths.
Container implementation is outside this mission. Contract and compatibility tests are inside it.
## 10. Communications portability
### COM-REQ-001: Transport-neutral public contract
Public communications capabilities use logical addresses, messages, correlation IDs, delivery
status, and adapter diagnostics. Tmux pane, socket, retry, and draft details stay below the public
contract.
### COM-REQ-002: Transitional semantics
The tmux adapter preserves the measured `rc=2` behavior: content reached a pane as a draft, so the
operation is not retried automatically. Fleet-comms preserves durable cross-site message identity
and acknowledgment behavior.
### COM-REQ-003: Future transport replacement
A Matrix or native transport implementation passes the same contract tests. Callers do not change
command paths, capability IDs, or result interpretation when the adapter changes.
## 11. Canonical source and runtime integrity
### SRC-REQ-001: Reviewed baseline
The F11 baseline is commit `5be5825`. Inventory report `585f214`, code review `3fe8de7`, and
security review `e270098` are the M0 evidence. Both reviews found no blocker.
### SRC-REQ-002: Required M1 corrections
Before expanding direct execution from the working tree:
1. fix the `check-helper-drift.sh` environment assignment that suppresses version diagnostics.
2. strip 20 dangling Excalidraw `node_modules` symlinks.
3. add `tools/**/node_modules/` to the brain `.gitignore`.
4. keep the reviewed `package-lock.json` as the reproducible dependency contract.
5. correct the baseline report's misleading path-count headline.
6. move `ci-publish-watch.sh` credential headers from process arguments to curl stdin
configuration when that suite is changed.
### SRC-REQ-003: Source is not installation
Runtime code is loaded from reviewed package or installed artifacts, not directly from a mutable
multi-writer checkout as the final design. Any transitional direct execution requires:
- a protected-path review rule.
- an accepted digest anchored outside the synced tree in reviewed package metadata or Stack source.
- verification before execution, including every credential-helper invocation.
- a periodic verifier whose mismatch alert reaches a human.
- a stated removal point.
### SRC-REQ-004: Credential helper integrity
The host-wide git credential helper and its accepted pin cannot be replaceable by the same synced
commit. Transition requires an independently anchored verifier and alert. Final state moves the
helper into the reviewed runtime installation or another explicitly protected location.
## 12. Migration and decommission
### MIG-REQ-001: Consumer census
Inventory every direct caller of `~/.config/mosaic/tools`, grouped as:
- skills and guides.
- hooks and generated harness configuration.
- systemd units and timers.
- launchers and provisioning.
- tests and CI.
- direct agent commands.
- private tool-to-tool calls.
- production consumers.
Each census run creates a fresh randomized planted legacy reference at a unique path and is valid
only when the detector reports that run's exact plant. Every host at every site still running the
installed legacy tree is censused independently. An empty result without the fresh control, or a
zero from only one host, is not evidence.
### MIG-REQ-002: Risk-ordered waves
Migrate in this order:
1. read-only status, health, list, and view.
2. bounded CI and communications.
3. issue, pull-request, and milestone mutation.
4. credentialed infrastructure.
5. merge, deployment, identity, authorization, and secret management.
Each wave proves contract parity before consumer cutover. Waves 1 through 3 may use local mode with
acting-seat credentials. Wave 4 cutover is broker-only when it uses a shared or service credential.
Wave 5 cutover is always broker-only and begins only after the M6 `mosaicd` boundary gate passes.
### MIG-REQ-003: Protected consumers
- M365 credentials, AD status, and six production consumers remain Peggy-owned until exact signoff
and timer-aware tests.
- Fleet-doctor, seat-service, Woodpecker extras, and their units remain Veronica-owned until exact
replacement proof and named handoff.
- Brain guards are excluded from wholesale removal.
- The active A2 hold applies to `tools/seat-service/` and
`fleet/bin/launch-seat-claude.sh` only.
- Fleet configuration issue `#758` retains its own normative contract and delivery DAG. T78 does
not re-scope or absorb its missing `inspect` and `validate` verbs. T78 measures and consumes the
stable fleet surface only after `#758` completion or an explicit owner handoff.
### MIG-REQ-004: Compatibility and deprecation
Compatibility shims are private and time-bounded. Each shim:
- names its public replacement.
- preserves existing safety behavior.
- emits a machine-detectable deprecation diagnostic without corrupting JSON output.
- has a measured consumer and removal issue.
- cannot be used to add new direct callers.
### MIG-REQ-005: Final removal
The installed `~/.config/mosaic/tools` script surface is removed only after:
1. all active consumers use official capabilities.
2. the census reports zero with a firing planted control.
3. Constitution and wrapper-first gates are mechanically enforced by the CLI path.
4. systemd units are regenerated, daemon-reloaded, re-enabled, and behavior-tested.
5. fleet-doctor state is preserved.
6. clean install, upgrade, rollback, and stale-install tests pass.
7. user, admin, developer, API, and migration documentation is current.
## 13. Testing requirements
### TST-REQ-001: Resolver
- exact schema-v1 valid fixture.
- absent, null, exact-v1, and unknown-non-null `$schema` cases.
- unknown top-level and nested key warnings with full-path diagnostics.
- `mosaic registry validate` lint rejection of the same unknown-key fixture.
- invalid URL, path, socket, and type failures.
- every precedence branch, including present-empty and present-invalid override denial without
fallback.
- two valid roots.
- container topology with a read-only internal registry and no host registry path.
- no credential value accepted or emitted.
- control proving the invalid fixture fails.
### TST-REQ-002: Capability catalog
- command and capability ID uniqueness.
- every public command documented.
- no orphan catalog record.
- parser, policy, help, and docs consume the same definition.
- unauthorized lane and scope denial.
- unknown capability denial.
- topology-selected mode never falls back when the required broker is unavailable.
- shared, service, operator, and admin credential classes reject local mode.
### TST-REQ-003: Pilot
- issue list and view against a valid configured instance.
- invalid instance and repository denial.
- comment success with provider response-shape and body-digest confirmation.
- comment denial before provider access.
- post-request uncertainty without retry, plus provider-native same-key and
`uncertain-no-retry` read-back reconciliation cases.
- credential, cookie, token, comment-body, child-argv, captured-stderr, and shell-trace redaction.
- local results prove `identityTrust: local-asserted` and `audit.recorded: false`.
- broker-stub results prove derived-identity precedence and reject unconfirmed
`audit.recorded: true`.
- user-editable endpoint changes cannot redirect a shared or service credential.
- local-adapter and broker-stub request/result seam parity at M3.
- live local-adapter and `mosaicd` contract parity at M6.
### TST-REQ-004: Migration
- fresh randomized consumer-census plant detected independently on every affected host and site.
- compatibility diagnostics in table and JSON modes.
- systemd timer and restart behavior.
- production M365/AD consumer probes.
- fleet-doctor digest-state preservation.
- clean install, upgrade, rollback, stale install, and greenfield operation.
- representative container without legacy tools mounted.
- representative container mounts no shared or fleet tmux socket, and any temporary tmux exception
uses only the named adapter's dedicated per-seat socket.
### TST-REQ-005: Delivery gates
Every source card requires focused tests, repository quality gates, independent code review,
security review for authorization, credentials, transport, or integrity surfaces, reviewed squash
PR to `next`, terminal-green CI, and linked-issue closure.
## 14. Documentation requirements
The workstream updates in the same delivery sequence:
- official CLI help.
- `docs/PRD.md` workstream pointer.
- `docs/ROADMAP.md` parallel-track entry.
- `docs/SITEMAP.md` requirements link.
- user guide commands and deprecation behavior.
- administrator configuration, migration, and recovery.
- developer architecture, capability authoring, schemas, and adapter contracts.
- API and machine-readable result schemas.
- release notes.
- T78 program-map and unified-roadmap records.
No command is public until its help, structured output, authorization behavior, and documentation
are present.
## 15. Delivery stages
| Stage | Scope | Exit gate |
| ----- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| M0 | Register mission, reconcile ownership, establish and review source baseline, publish requirements and tracking | Reviewed contract merged, dedicated milestone and task graph present |
| M1 | Inventory consumers, normalize baseline, freeze registry resolver, capability catalog, policy, and runtime-integrity contracts | Typed interfaces and migration census reviewed |
| M2 | Implement resolver, catalog, common result envelope, and adapter interface | Contract and two-root tests green |
| M3 | Deliver pilot issue list, view, and comment | Allowed and denied controls, uncertainty behavior, docs, review, CI |
| M4 | Migrate waves 1 through 3, prepare wave 4 private adapters without shared-credential cutover | Per-suite owner handoff and parity evidence |
| M5 | Cut eligible consumers and generate harness policy | No new direct references, compatibility callers measured |
| M6 | Prove representative container and `mosaicd` seam, then cut over shared-credential wave 4 and all wave 5 capabilities | Boundary, authorization, audit, and parity tests green |
| M7 | Remove installed legacy script tree | Zero callers, migration and rollback evidence, docs and release gates complete |
## 16. Workstream acceptance
T78 completes only when:
1. the official TypeScript CLI exposes documented first-class capability groups.
2. the central registry resolver and capability catalog are single typed authorities.
3. two-root and container-topology tests prove no command-path hard-coding.
4. authorization has allowed and denied situational evidence.
5. agent-visible output, logs, and process arguments contain no credential values.
6. local and `mosaicd` modes share one request and result contract and report their mode honestly.
7. a representative rootless seat container performs granted operations without legacy tools,
host credentials, or a container-runtime socket.
8. tmux and fleet-comms can be replaced without changing public communications callers.
9. the legacy consumer census reaches zero with a discriminating control.
10. the installed `~/.config/mosaic/tools` script surface is removed.
11. independent review passes for every source partition.
12. all PRs are squash-merged to `next`, terminal CI is green, and linked issues are closed.
## 17. Contract-freeze status
The architecture inputs are frozen for independent review:
1. The central-registry resolver has joint C1, amended C2, and C3 approval.
2. User-editable `config.json` is not authorization policy. Target grant authority belongs to
`mosaicd`. Local mode is explicitly non-authoritative.
3. Registry, capability, and adapter source boundaries are packaged TypeScript modules. Brain tools
remain working source and temporary private adapters, not the final runtime contract.
4. Issue `#758` remains an independent dependency and is not re-scoped into T78.
Provider tracking remains operationally blocked until the `orch-01` Mosaic Stack credential slot is
minted. This does not weaken the contract or authorize implementation before reviewed publication.
-484
View File
@@ -1,484 +0,0 @@
# Hierarchy Schema Contract (D2)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 1 of 9).
Authority: PRD D2/D9/D13 (Part I §4) and the native-kanban SOT Amendment A1
(`docs/requirements/native-kanban-sot.md` §8, ratified 2026-08-25). This
document turns the ratified hierarchy into a concrete schema contract:
tables, cardinalities, constraints, and ownership/transfer semantics. It is
the prerequisite for the hierarchy command family and for the RBAC grant
model (contract 2, `docs/requirements/rbac-grant-model.md`).
Revision 2 (independent review, GPT-5.6 terra): tenancy-FK exemption made
explicit (§1.1); record class extended to include `hierarchy_grants`
(§1.1); provenance corrections on legacy tables and the planning `projects`
table (§1.3, §2 naming note); NOT NULL and `NULLS NOT DISTINCT` grant
uniqueness (§2.6, §3.2); grant FK delete actions split cascade/restrict
(§3.3); transfer transaction includes its audit write (§4.3); ownership
invariant completed via contract 2 with the both-sides rule marked as new
policy (§4.2, §4.4); hierarchy audit brought under REQ-AUD-001-equivalent
guarantees with deletion-safe linkage (§5.2); roll-up never-a-write restored
to full A1 strength (§5.4); §6 rebuilt with bounded observables for every
MUST (allowlist, command surface, audit, corrected cardinality witness).
Revision 3 (terra re-review residuals): §4.3 transfer write inventory
reconciled with §5.2 — the transaction's writes are the single class-row
mutation plus that mutation's §5.2 audit writes (event + outbox record),
not "exactly two writes"; §6.3 extended with a closed writer-coverage
witness so an unregistered internal writer cannot pass a registered-route
inventory. (Terra's finding-8 residual — a stale contract 2 §7.8 backlink
to contract 1 §6.2 — was already fixed in contract 2 revision 2, which
cites §6.5; measured against `origin/contract/rbac-grants` head
`501112d2`.)
Revision 4 (terra r3 residual F7): the §6.3(b) writer-coverage assertion
extended to raw SQL — it now also fails on class-table name literals
inside SQL strings or tagged SQL templates outside the allowlist, so a
raw-SQL writer that touches no schema symbol is still caught.
Revision 5 (terra r4 residual F7): §6.3(b) gains a third prong — any
raw-SQL execution primitive outside the allowlist fails the assertion
regardless of its SQL content, closing the evasion where a
dynamically constructed table name carries neither a schema symbol nor
a class-table literal. The detection claim is now coextensive with
what the three prongs statically see.
Revision 6 (terra r5 residual F7 + new F8): the "two prongs" wording
corrected to three (F8); §6.3(b) gains the allowlist composition rules
(no generic raw-SQL helper is allowlisted; an allowlisted module may
not export caller-supplied-SQL execution) and fails outright on
runtime code-construction primitives; the detection claim is scoped
honestly to the stated syntactic forms, with evasions beyond static
reach assigned to §5.1 review/audit rather than claimed for CI.
Revision 7 (terra r6 new F9): the false-positive remedy no longer
contradicts the composition rules — legitimate non-hierarchy raw
execution (e.g. the db package's migration runner) is dispositioned
onto a second closed enumerated list, the infrastructure register,
exempt from prong (iii) only, still bound by prongs (i)/(ii), barred
from the writer allowlist, and importable only by registered modules
or the operational entry points.
Revision 8 (terra r7 residual F9): the register's import rule made
satisfiable by the live tree — imports are checked re-export-aware
(package barrels followed), and each registered module carries its own
closed importer enumeration, which may name operational entry points
such as the Gateway's startup migration hook; named importers stay
subject to prongs (i)/(ii) and gain no writer standing.
Revision 9 (terra r8 F10): revision 8 called the Gateway database
module the runner's "one live importer today". That was false — the
measured production importer set has four members. The enumeration
example now lists the complete measured set, and the import analysis
is extended to resolve literal dynamic `import()` routes, which two of
the four members use.
Amendment 1 (Ruling 4b, 2026-08-28): company visibility classes. The
directory exists so one shared company can serve many users instead of
each user creating a duplicate private company (Ruling 4b, webui-audit
lane, ruled 2026-08-27). §2.1 gains a `visibility` column; §2.8 defines
the two classes (`private`/`directory`), the directory's existence-only
disclosure, and the pre-binding invariants for the deferred
see-and-ask-to-join flow (no join-request surface is authorized here —
its flow is a follow-up contract); §5.2's mutation
enumeration gains the visibility change; §5.5 defines who may change
visibility (platform admins, plus a company-CRUD capability whose
definition is a follow-up amendment to contract 2 — until it ratifies,
admin-only); §6.1 and §6.9 add the witnesses; §6.7's existence-oracle
rule is scoped around the ratified directory carve-out. Top-level
creation (contract 3 §5.2) is unchanged and always yields a private
company. Upstream, SOT Amendment A2 (native-kanban-sot.md §9, this PR)
expressly extends A1 §8.1.2 to admit the visibility column and A1
§8.1.3 to admit the directory function — this contract relies on that
amendment, not on a reinterpretation of A1.
Scope: the tenancy/authorization structure record class — companies,
estates, platform-projects, workspaces, hierarchy grants, their parentage,
and constraints. Out of scope: the RBAC grant vocabulary and evaluation
semantics (contract 2), roll-up projection semantics (contract 8), kanban
planning entities inside workspaces (SOT §5), migration or retirement of
legacy flat data (future work; see §1.3).
## 1. Record class and placement
1. The **tenancy/authorization structure record class** defined by
Amendment A1 §8.1.2 comprises five tables: the four node tables of §2
AND `hierarchy_grants` (§3) — A1 includes hierarchy-level access grants
in the class. Every rule addressed to "the class" in this contract
(payload prohibition, mutation path, audit) binds all five tables. Class
rows carry parentage, naming, grant, audit-linkage, and visibility-class
data only — never task, plan, or any business/orchestration payload.
Visibility (`companies.visibility`, §2.8) is admitted into that
enumeration by SOT Amendment A2 §9.1.1, which expressly extends A1
§8.1.2 for exactly this one column: it is disclosure data about the
class's own nodes — not a payload field, carries no business content,
and widens the payload prohibition for nothing else.
References from business/orchestration rows into the class are limited
to exactly one form: the canonical `workspace_id` tenancy column that
REQ-TEN-001 requires on every canonical row, referencing
`workspaces.id`. No business/orchestration row may reference a company,
estate, platform-project, or grant id in any position, and no
business/orchestration row may reference a workspace id in any
non-tenancy position (dependency, claim target, work subject).
2. Hierarchy records are NOT workspace-scoped rows: REQ-TEN-001's
`workspace_id` obligation binds business/orchestration rows and does not
apply to this class (A1 §8.1.2). The `workspaces` table itself is the
anchor the obligation points at.
3. The legacy flat tables (`teams`, and the Brain planning `projects` table
in `packages/db/src/schema.ts`) are not part of this class. What A1
§8.1.4 pins is narrower: the planning `projects` table and
`platform_projects` stay distinct tables. This contract adds, as new
policy ratified here: neither `teams` nor `projects` is repurposed as a
hierarchy table. Their eventual migration or retirement is future work
that no existing REQ assigns; it is out of scope here.
## 2. Tables and cardinalities
Naming: the level above workspaces is `platform_projects`, per A1 §8.1.4.
The existing `projects` table is Brain planning data (so labeled in
`packages/db/src/schema.ts`; it carries no `workspace_id`), and the schema
MUST NOT merge the two. (A rename of either remains an implementation-PR
decision under A1; this contract pins only that they stay distinct tables.)
1. `companies` — id (uuid pk), name, slug (unique per deployment),
`visibility` (text NOT NULL, DEFAULT `private`, CHECK constrained to
exactly `private` | `directory`; semantics §2.8), created_at,
updated_at. N per deployment (D2).
2. `estates` — id, name, slug, `company_id` NOT NULL →
`companies.id` ON DELETE RESTRICT. Exactly one company per estate; a
company holds any number of estates.
3. `platform_projects` — id, name, slug, `estate_id` NOT NULL →
`estates.id` ON DELETE RESTRICT. Exactly one estate per
platform-project; an estate holds any number of platform-projects.
4. `workspaces` — id, name, slug, `platform_project_id` NOT NULL →
`platform_projects.id` ON DELETE RESTRICT. Exactly one platform-project
per workspace. This table is the referent of every `workspace_id` column
the SOT requires on canonical rows.
5. **Chain resolution is by construction.** Because every parent FK is NOT
NULL and single-valued (one FK column, no parentage edge tables, no
multi-parent forms, no nullable "detached" states), each workspace
resolves to exactly one platform-project → estate → company chain (A1
§8.3 acceptance 1). One-parent-per-child is the constrained direction;
many children per parent is valid data.
6. **Slug scoping.** All `name` and `slug` columns are NOT NULL.
`estates.slug` is unique within its company, `platform_projects.slug`
within its estate, `workspaces.slug` within its platform-project
(composite unique constraints). Display names are unconstrained beyond
NOT NULL.
7. No hierarchy table carries a `metadata` jsonb column or any
free-form payload field. The columns declared in this section and §3
are exhaustive: a class table's column set is exactly its declared set
(verified per §6.2) — nothing else (A1 §8.1.2).
8. **Company visibility classes (Ruling 4b).** Every company is exactly
one of two classes, carried by `visibility`:
- `private` (the default): the company is disclosed only to subjects
holding a grant on it or on a descendant — the resting state every
company is created in. Open creation under contract 3 §5.2
(Ruling 4) survives unchanged: it creates private companies.
- `directory`: the company is listed in the deployment-wide company
directory. Directory listing discloses **existence, name, and slug
to every authenticated user — nothing else**: no subtree structure,
no roll-up aggregates, no workspace content, no grant or membership
information.
Visibility is disclosure, not authority. Content and structure access
to a directory-listed company still require explicit grants —
contract 2 §3.1 deny-by-default is unchanged, and the ownership model
(§4.4, contract 2 §4.3) is unchanged. Ruling 4b decision 5 wants a
see-and-ask-to-join flow for directory-listed companies. **This
contract authorizes no join-request runtime surface**: the flow in
its entirety — the ability to submit a request, its transport,
storage, and request lifecycle — is a follow-up contract, and until
that contract ratifies, the directory's only function is the
read-only listing above (A2 §9.1.2 admits nothing more). Two
invariants pre-bind that future contract now:
a join request confers no authority of any kind, and approval is
ordinary grant creation by an effective `owner` under contract 2 §4.1
— there is no other acceptance path.
## 3. Grant attachment points
The grant vocabulary (which roles exist, what each permits, how evaluation
and revocation work) is contract 2. This contract pins only the schema
shape contract 2 attaches to:
1. `hierarchy_grants` — id, subject (exactly one of `user_id``users.id`,
`team_id``teams.id`; CHECK-enforced exactly-one-of), target (exactly
one of `company_id`, `estate_id`, `platform_project_id`;
CHECK-enforced exactly-one-of), `role` (text NOT NULL; vocabulary and
its CHECK constraint owned by contract 2 §2), `granted_by` NOT NULL →
`users.id`, created_at.
2. Uniqueness: at most one grant row per (subject, target, role). Because
the subject and target columns are nullable by design, ordinary
PostgreSQL composite uniqueness treats NULLs as distinct and would not
enforce this. The implementation MUST use a single
`UNIQUE NULLS NOT DISTINCT` constraint across (`user_id`, `team_id`,
`company_id`, `estate_id`, `platform_project_id`, `role`) or six
equivalent partial unique indexes (one per subject×target form). The
pinned Drizzle ORM supports `nullsNotDistinct()`.
3. Delete actions are split by column class:
- Target FKs (`company_id`, `estate_id`, `platform_project_id`):
ON DELETE CASCADE — the one permitted cascade in this class. A grant
on a deleted node is meaningless and fail-open if retained. Cascaded
grant deletions are audited per §5.2.
- Principal FKs (`user_id`, `team_id`, `granted_by`): ON DELETE
RESTRICT. The identity contract (§7.3) gates user deletion today and
defines no team-deletion rule; this contract does not invent one.
These FKs stay RESTRICT until an explicit deletion-and-retention
contract ratifies otherwise.
4. Workspace-level access is evaluated, not stored here: a grant at any of
the three levels evaluates down the chain to workspace-scoped
authorization (A1 §8.1.3). No `workspace_id` column exists on
`hierarchy_grants` — workspace membership (REQ-ID-001) remains its own
mechanism inside the SOT schema, and the chain adds where grants can be
declared, never a bypass.
## 4. Ownership and transfer
"Assets are transferable subject to the structure" (PRD Part I §4):
1. A transfer changes exactly one parent FK on exactly one hierarchy row:
workspace → new platform-project, platform-project → new estate, estate
→ new company. Nothing else in the class or the SOT changes: business
and orchestration rows inside affected workspaces are untouched, keep
their `workspace_id`, and never cross a workspace boundary (A1 §8.1.3
"chain maintenance").
2. Transfer authorization requires authority over BOTH the source and the
destination parent. This both-sides predicate is **new policy
introduced by this contract pair** (D2/A1 do not state it); its
evaluation semantics are contract 2 §5. The structural half — that the
transfer command evaluates it before mutating — binds here.
3. A transfer transaction mutates exactly one class-table row — the
single-row parent-FK update — and contains, beyond that, only the
§5.2 audit writes for that mutation (the audit event and its
hierarchy-outbox record, committing in the same transaction). No other
class, business, or orchestration row changes. There are no multi-row
transfer batches at the schema level; bulk moves are N audited
transfers.
4. Hierarchy records have no `owner_id`. Ownership in the hierarchy IS the
grant structure: a "company owner" is a subject with an `owner` grant
on that company or an ancestor (contract 2 §2), not a column. The
ownership invariant across the contract pair: a node may hold zero
direct owner grants (authority can derive from an ancestor grant); node
creation names the initial `owner` grant in the same audited operation
and the wizard seeds the first company's owner the same way (contract 2
§4.3); transfer and revocation semantics are contract 2 §§56. This
avoids column-encoded authority of the kind the legacy schema carries
(`teams.owner_id` and `teams.manager_id` are required user FKs, and
`team_members.role` is a further authority field — none of them
evaluable under a grant model).
## 5. Mutation path, audit, and deletion
1. All hierarchy mutations flow through the same sole-writable-SOT,
fail-closed, audited Gateway command path as everything else (A1 §8.2.3,
REQ-API-001). No direct-DB writers, no raw CRUD endpoints.
2. **Audit parity.** A1 §8.2 leaves every pre-existing REQ binding, so
hierarchy mutations get REQ-AUD-001's guarantees, not a weakened
substitute. Concretely:
- Every create, rename, transfer, visibility change (§5.5), grant
create/change/revoke, and
delete — including every grant deletion cascaded by a node delete —
emits a semantic audit event carrying actor, verb, target, and (for
transfers) source and destination parents, with the correlation,
causation, idempotency, and per-target ordering guarantees REQ-AUD-001
defines.
- The state change and its audit event(s) commit in the same
transaction, delivered through a transactional outbox. Hierarchy
events are not workspace-scoped rows and do not ride the workspace
outbox; they get an equivalent hierarchy outbox under the same
append-only, same-transaction rules.
- **Deletion-safe linkage:** audit events reference their target by an
immutable snapshot (id, slug, and parent chain at event time), never
by a foreign key into the class tables, so append-only events survive
the deletion of their target.
3. Deletion is fail-closed bottom-up: a hierarchy record with children
cannot be deleted (RESTRICT FKs, §2). Deleting a workspace is a SOT-side
operation subject to the kanban SOT's own rules and is not granted any
new semantics by this contract.
4. **Roll-up is never a write** (A1 §8.2.2, preserved at full strength). A
roll-up read mutates nothing — not hierarchy state, and not business or
orchestration state: it must not mutate, claim, order, or gate
workspace work. Contract 8 owns projection details but cannot narrow
this rule. This contract additionally guarantees the chain roll-ups
aggregate over is unique and non-null (§2.5).
5. **Visibility administration (Ruling 4b decisions 23).** Changing
`companies.visibility` is a hierarchy mutation through the §5.1
command path, audited per §5.2 (the event carries the old and new
visibility values as its semantic content). It is authorized for
exactly two actor classes: platform admins (`users.role = 'admin'`)
and subjects holding the company-CRUD capability that a follow-up
amendment to contract 2 will define — until that amendment ratifies,
the capability class is empty and the command is admin-only.
A company `owner` as such may NOT change visibility: standard users
cannot publish a company into the directory. This is the one
hierarchy mutation a platform admin performs without holding a
hierarchy grant, and it is ratified here as instance administration
(directory curation) in contract 2 §1.1's sense, not tenant access:
the command mutates the single `visibility` column, reads no tenant
content, and confers no grant — contract 2 §1.1's
no-implicit-tenant-access rule is otherwise untouched. Top-level
company creation (contract 3 §5.2) always creates
`visibility = 'private'`; the creation command cannot set or change
visibility.
## 6. Verification requirements
Binding on the implementing PRs (extends A1 §8.3):
1. Schema witnesses (real PostgreSQL, §6.8): chain construction — insert
with a null parent FK refused; insert with one valid parent accepted;
two siblings under one parent accepted (the control proving the
constraint rejects only what §2.5 forbids); catalog assertion that each
child table has exactly one parent-FK column and no parentage edge
table exists. Composite slug uniqueness per parent (duplicate slug
under same parent refused; same slug under different parents accepted).
Grant CHECKs: exactly-one-of subject and exactly-one-of target each
witnessed (zero and two set → refused). Grant uniqueness: a duplicate
(subject, target, role) row refused for each of the six subject×target
forms, proving NULLS-NOT-DISTINCT semantics; NOT NULL on `role`,
`granted_by`, and all `name`/`slug` columns witnessed. Company
visibility (§2.8): a value outside `private`/`directory` refused with
both valid values accepted as the control; an insert omitting the
column defaults to `private`.
2. Column allowlist: an information_schema assertion that each class
table's column set is exactly the set declared in §2/§3 — the bounded
observable for no-payload (§2.7) and no-`owner_id` (§4.4).
3. Command surface: two witnesses, both required (§5.1). (a) Route
inventory: an assertion over the Gateway's registered hierarchy
routes/commands proving the registered mutation surface is exactly the
declared hierarchy command family — no generic CRUD endpoint. (b)
Writer coverage — the closed allowlist a route inventory cannot
provide: a static CI assertion over the Gateway and package sources
with three prongs, each bound to one explicitly enumerated allowlist
of hierarchy command/repository modules. (i) Symbol prong: write
references to the class-table schema symbols (insert, update, delete)
occur only in allowlisted modules. (ii) Literal prong: a class-table
name appearing inside a SQL string or tagged SQL template outside the
allowlist fails the assertion — this is what catches a raw-SQL writer
that references no schema symbol. (iii) Raw-execution prong: any call
to a raw-SQL execution primitive (the ORM's raw/unsafe constructors,
driver-level query/execute) outside the allowlist fails the
assertion, regardless of what the SQL string contains or how it is
constructed — the call site is statically detectable even when a
dynamically assembled table name is not, so a raw writer with a
runtime-built identifier is caught by its primitive, not its
payload. Two composition rules keep prong (iii) meaningful: the
allowlist names hierarchy command/repository modules only — a
generic raw-SQL helper or database-utility module is never
allowlisted; and an allowlisted module MUST NOT export a function
that executes caller-supplied SQL (such an export is itself a
raw-execution primitive, and the exporting module is treated as
unallowlisted for prong (iii) if it does). Legitimate raw execution
that is not a hierarchy writer — e.g. the migration runner in the
db package — lives on a second, separately enumerated
**infrastructure register**, distinct from the writer allowlist and
equally closed. A registered module is exempt from prong (iii) only:
prongs (i) and (ii) apply to it with no exemption, so it can hold no
class-table schema symbol or class-table SQL literal, and it can
never appear on the writer allowlist. To close the laundering path,
the same assertion checks imports, and the import analysis is
**re-export-aware**: it follows package barrels and re-exports, so a
route hidden behind an index module is still a route — and it
resolves literal dynamic imports the same way: an
`await import('<literal specifier>')` is an import edge like any
static import, not an evasion of the analysis (a dynamic import of
the db package whose specifier is not a literal fails the assertion
outright, because it makes the import graph unanalyzable). A
registered module may be imported only by other registered modules
or by importers named on that module's own closed importer
enumeration in the register — operational entry points such as the
migration/bootstrap CLI or the Gateway's startup migration hook.
The enumeration names the complete permitted production consumer
set, and completeness is measured, not asserted: the migration
runner's measured production importer set today has four members —
the Gateway database module (reached through the db package
barrel), the storage package's Postgres adapter, and two mosaic CLI
commands, the fleet-backlog command and the gateway verify command,
both routed through literal dynamic imports of the db package — so
its enumeration names those four. A module that only receives the
runner's functions by parameter injection (the gateway schema-check
module takes them as arguments from the verify command) has no
import edge of its own and is not enumerated. Any import route
outside the enumeration fails the assertion. Being a
named importer confers nothing else: the importer stays fully
subject to prongs (i) and (ii), gains no writer-allowlist standing,
and whether it uses the registered module beyond its operational
purpose is a §5.1 review question, not a static claim. Runtime code-construction
primitives (`eval`, `new Function`) anywhere in the scanned sources
fail the assertion outright, allowlist or not. Schema definitions
and generated migrations are excluded from the literal prong; a
false positive is resolved in the same PR by adding the module to
the one enumerated list its role permits — the writer allowlist for
a hierarchy command/repository module, the infrastructure register
for non-hierarchy raw execution — never by weakening the assertion,
and neither list may take a module the composition rules bar from
it. Both lists are closed, and the assertion's detection
claim is exactly its prongs: it statically surfaces every writer
expressed as a schema-symbol reference, a class-table SQL literal, a
raw-execution call site, or runtime code construction. An evasion
engineered outside those syntactic forms is a §5.1 violation that
review and audit own — the witness does not claim to catch what
static analysis cannot see, and any such evasion found later is
corrected as a conformance defect, not grandfathered.
4. Audit witnesses: for each mutation class (create, rename, transfer,
visibility change, grant create/change/revoke, delete) — the event
exists after commit
with actor/verb/target and same-transaction atomicity, and the
event's outbox record exists after the same commit — state row,
audit event, and outbox record are witnessed as one transaction
(REQ-AUD-001); a rolled-back
mutation leaves no event, no outbox record, AND no state effect —
a rolled-back create leaves no row, a rolled-back rename, transfer,
or visibility change leaves the prior values in place, and a
rolled-back delete or grant revoke leaves the row present
(rollback witness on all three legs, per REQ-AUD-001's
commit-or-roll-back-together acceptance); a
node delete's cascaded
grant deletions are each covered by events; events survive deletion of
their target (query the events of a deleted node).
5. Transfer tests: parent-FK update moves the subtree resolution and
modifies zero business/orchestration rows (row-count and content
assertions on workspace contents before/after); transfer without
authority on the source or on the destination side is refused (with
contract 2 §7.8).
6. Deletion tests: delete with children refused at the database level;
delete of a leaf cascades its grants and nothing else; deleting a user
or team that is a grant subject (or `granted_by` referent) is refused
(RESTRICT witnesses for §3.3).
7. Negative tests: no business/orchestration table accepts a company,
estate, platform-project, or grant id in any reference position, and
none accepts a workspace id in any non-tenancy position; the canonical
tenancy FK control — a business row inserted with a valid
`workspace_id` succeeds, with an invalid one is refused; roll-up
endpoints mutate no canonical state anywhere (assert zero writes across
hierarchy AND workspace tables, not hierarchy only); readers see
aggregates only over workspaces they are authorized on, with no
cross-tenant existence oracles (A1 §8.3 acceptance 3, as narrowed by
A2 §9.1.2) beyond the one
ratified carve-out — the §2.8 company directory, witnessed in §6.9.
8. Real-PostgreSQL coverage for every constraint witness (unique/CHECK/
RESTRICT/NULLS NOT DISTINCT behavior), using the `ci-postgres` service
in the `test` CI step; mocked specs cannot witness database constraints.
9. Visibility witnesses (§2.8, §5.5): the directory read returns exactly
the `visibility = 'directory'` companies to any authenticated user,
disclosing existence, name, and slug only (closed-field assertion on
the response shape); a private company never appears in the directory
for a reader without a grant on it (with the control: it appears in
that reader's granted-structure reads); a directory-listed company's
subtree, aggregates, and content remain refused for a non-granted
reader (disclosure ≠ authority); the visibility command is refused
for a non-admin actor — including an effective `owner` of the target
company — with the platform-admin accept control; top-level creation
yields `visibility = 'private'` and accepts no visibility argument;
each visibility change emits its §5.2 audit event carrying old and
new values — the full audit pattern for the mutation class
(same-transaction atomicity of state row, audit event, and outbox
record; rollback leaving no state effect, no event, and no outbox
record; actor/verb/target) is §6.4's, which enumerates
visibility change; this item adds only the old/new-value payload
assertion.
## Ruling request
Ratify sections 16 as written, with one decision embedded: hierarchy
records carry no owner column — ownership is expressed solely through
grants (§4.4) — say "agreed" or name the ownership model you want.
-58
View File
@@ -456,61 +456,3 @@ this line is weakened.
- Negative tests prove roll-up endpoints cannot mutate state and that a
reader sees aggregates only over workspaces they are authorized on
(no cross-tenant existence oracles).
## 9. Amendment A2 — company visibility classes and the company directory
**Status:** amendment to Amendment A1, added by reviewed PR under Ruling 4b
(operator ruling, 2026-08-27; decision owner Jason; recorded in the webui-audit
lane RULINGS.md). Everything in §§18 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.
+4 -21
View File
@@ -397,14 +397,6 @@ seed-workspace-scoped mutant, correctly refusing outside the seed
set, passes branch (c), so the two branches detect distinct
mutants. No other change.
Amendment 1 (Ruling 4b, 2026-08-28): §5.2's embedded decision was RULED
AGREED (Jason, 2026-08-27), and Ruling 4b adds company visibility
classes (hierarchy schema §2.8): open top-level creation always yields
a **private** company; publishing a company into the deployment-wide
directory is a separate, gated visibility mutation (hierarchy schema
§5.5) that is never part of the creation command. §5.2 is amended to
state both.
Scope: the Gateway-backed product onboarding wizard. Out of scope: the
host-local install wizard (`mosaic wizard`, which drives host install and
gateway bootstrap and is not this artifact — audit REPORT.md layer 3);
@@ -1177,18 +1169,15 @@ collects no sensitive category, so v1 ships no custody surface.
party, service actor, or wizard-privileged writer exists in this
flow.
2. **Post-bootstrap top-level company creation** — the "N companies" flow
RULED AGREED (Jason, 2026-08-27): any **eligible platform user** MAY
is decided by the ruling below: any **eligible platform user** MAY
create a top-level company and MUST name an initial `owner` grant in
the same audited operation (contract 2 §4.3); the creator naming
themselves is the default. Eligible means, in identity-contract
terms: an authenticated account (identity §2) that is not banned
(identity §7.1 — deactivation on this platform IS the better-auth
ban; no separate deactivated state exists). No further role or grant
is required. Creation always yields a **private** company
(`visibility = 'private'`, hierarchy schema §2.8, Ruling 4b): the
creation command accepts no visibility argument, and publishing into
the deployment-wide directory is a separate, gated mutation
(hierarchy schema §5.5) that standard users cannot perform.
is required. Until that ruling, deny-by-default holds (contract 2
§3.1): no implicit creation authority exists.
3. Child-node creation inside the wizard (estate, project, workspace
under the seeded company) follows contract 2 §4.3: parent
`owner` authority, no automatic grant needed — for canonical seed
@@ -1943,13 +1932,7 @@ contracts and are not additions:
suffix at all — each contradicting PRD D4's no-lock-in
requirement (§4.4).
## Ruling request — RULED AGREED (Jason, 2026-08-27; Amendment 1)
The §5.2 decision below was ruled agreed: open eligible-user creation
stands (yielding private companies per Amendment 1), and the
"alternative if rejected" did not take effect. The request is retained
below as historical record of what was put to ruling; it is no longer
live.
## Ruling request
Ratify sections 17 as written, with one decision embedded:
-259
View File
@@ -1,259 +0,0 @@
# RBAC Grant Model Contract
Status: DRAFT — awaiting ratification (webui-audit S2, contract 2 of 9).
Authority: PRD Part I §4 ("Granular RBAC: admins restrict access per company,
estate, and project; grants are evaluated down the chain") and the
native-kanban SOT Amendment A1 (§8.1.3 RBAC evaluation, §8.3 acceptance 2).
This document defines the grant vocabulary, evaluation semantics, and
revocation propagation that the hierarchy schema contract
(`docs/requirements/hierarchy-schema.md`, contract 1) attaches to. Contract 1
pins the `hierarchy_grants` table shape and defers the `role` vocabulary and
the meaning of "authority" here; the identity contract
(`docs/requirements/identity-lifecycle.md` §1.4) pins that account creation
grants nothing.
Revision 2 (independent review, GLM 5.3): §1.1 consequence analysis
completed — the two existing platform-admin bypass code paths are named as
non-conformant and §7.4 retires them; team grant subjects suspended pending
a team contract (§1.4, §3.33.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 23 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 17 as written, with one decision embedded and one
interpretive resolution named:
- Decision: platform `admin` confers no implicit tenant access — operators
see tenant content only through explicit, audited grants (§1.1), which
retires the two existing admin bypass paths named there. Say "agreed" or
name the implicit access you want platform admins to keep.
- Interpretive resolution (for visibility, not a separate question): PRD
Part I §4 says "admins restrict access per company, estate, and project";
this contract resolves "admins" as hierarchy `owner`s (§4.1), not
platform admins. A1 §8.1.3 does not attribute grant declaration to
platform admins, and the §1.1 decision above is what makes this reading
binding.
+1
View File
@@ -7,6 +7,7 @@ export default tseslint.config(
ignores: [
'**/dist/**',
'**/node_modules/**',
'**/.next/**',
'**/coverage/**',
'**/drizzle.config.ts',
'**/framework/**',
@@ -1,63 +0,0 @@
CREATE TABLE "companies" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "companies_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "estates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"company_id" uuid NOT NULL,
CONSTRAINT "estates_company_slug_uniq" UNIQUE("company_id","slug")
);
--> statement-breakpoint
CREATE TABLE "hierarchy_grants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text,
"team_id" uuid,
"company_id" uuid,
"estate_id" uuid,
"platform_project_id" uuid,
"role" text NOT NULL,
"granted_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "hierarchy_grants_subject_target_role_uniq" UNIQUE NULLS NOT DISTINCT("user_id","team_id","company_id","estate_id","platform_project_id","role"),
CONSTRAINT "hierarchy_grants_subject_check" CHECK (num_nonnulls(user_id, team_id) = 1),
CONSTRAINT "hierarchy_grants_target_check" CHECK (num_nonnulls(company_id, estate_id, platform_project_id) = 1)
);
--> statement-breakpoint
CREATE TABLE "platform_projects" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"estate_id" uuid NOT NULL,
CONSTRAINT "platform_projects_estate_slug_uniq" UNIQUE("estate_id","slug")
);
--> statement-breakpoint
CREATE TABLE "workspaces" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"platform_project_id" uuid NOT NULL,
CONSTRAINT "workspaces_platform_project_slug_uniq" UNIQUE("platform_project_id","slug")
);
--> statement-breakpoint
ALTER TABLE "estates" ADD CONSTRAINT "estates_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_estate_id_estates_id_fk" FOREIGN KEY ("estate_id") REFERENCES "public"."estates"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_platform_project_id_platform_projects_id_fk" FOREIGN KEY ("platform_project_id") REFERENCES "public"."platform_projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hierarchy_grants" ADD CONSTRAINT "hierarchy_grants_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "platform_projects" ADD CONSTRAINT "platform_projects_estate_id_estates_id_fk" FOREIGN KEY ("estate_id") REFERENCES "public"."estates"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_platform_project_id_platform_projects_id_fk" FOREIGN KEY ("platform_project_id") REFERENCES "public"."platform_projects"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "hierarchy_grants_company_id_idx" ON "hierarchy_grants" USING btree ("company_id");--> statement-breakpoint
CREATE INDEX "hierarchy_grants_estate_id_idx" ON "hierarchy_grants" USING btree ("estate_id");--> statement-breakpoint
CREATE INDEX "hierarchy_grants_platform_project_id_idx" ON "hierarchy_grants" USING btree ("platform_project_id");--> statement-breakpoint
CREATE INDEX "hierarchy_grants_user_id_idx" ON "hierarchy_grants" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "hierarchy_grants_team_id_idx" ON "hierarchy_grants" USING btree ("team_id");--> statement-breakpoint
CREATE INDEX "hierarchy_grants_granted_by_idx" ON "hierarchy_grants" USING btree ("granted_by");
@@ -1,40 +0,0 @@
CREATE TYPE "public"."hierarchy_outbox_status" AS ENUM('pending', 'processing', 'delivered');--> statement-breakpoint
CREATE TABLE "hierarchy_audit_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"seq" bigint GENERATED ALWAYS AS IDENTITY (sequence name "hierarchy_audit_events_seq_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"actor_id" text NOT NULL,
"verb" text NOT NULL,
"target_kind" text NOT NULL,
"target_id" uuid NOT NULL,
"target_snapshot" jsonb NOT NULL,
"transfer_from" jsonb,
"transfer_to" jsonb,
"correlation_id" text NOT NULL,
"causation_id" uuid,
"idempotency_key" text NOT NULL,
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "hierarchy_audit_events_verb_check" CHECK (verb IN ('create', 'rename', 'transfer', 'delete', 'grant_create', 'grant_change', 'grant_revoke')),
CONSTRAINT "hierarchy_audit_events_target_kind_check" CHECK (target_kind IN ('company', 'estate', 'platform_project', 'grant')),
CONSTRAINT "hierarchy_audit_events_transfer_check" CHECK ((verb = 'transfer') = (transfer_from IS NOT NULL AND transfer_to IS NOT NULL))
);
--> statement-breakpoint
CREATE TABLE "hierarchy_outbox" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"event_id" uuid NOT NULL,
"idempotency_key" text NOT NULL,
"correlation_id" text NOT NULL,
"status" "hierarchy_outbox_status" DEFAULT 'pending' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"delivered_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "hierarchy_audit_events" ADD CONSTRAINT "hierarchy_audit_events_causation_id_hierarchy_audit_events_id_fk" FOREIGN KEY ("causation_id") REFERENCES "public"."hierarchy_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hierarchy_outbox" ADD CONSTRAINT "hierarchy_outbox_event_id_hierarchy_audit_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."hierarchy_audit_events"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "hierarchy_audit_events_idempotency_idx" ON "hierarchy_audit_events" USING btree ("idempotency_key");--> statement-breakpoint
CREATE UNIQUE INDEX "hierarchy_audit_events_seq_idx" ON "hierarchy_audit_events" USING btree ("seq");--> statement-breakpoint
CREATE INDEX "hierarchy_audit_events_target_seq_idx" ON "hierarchy_audit_events" USING btree ("target_id","seq");--> statement-breakpoint
CREATE INDEX "hierarchy_audit_events_correlation_idx" ON "hierarchy_audit_events" USING btree ("correlation_id");--> statement-breakpoint
CREATE UNIQUE INDEX "hierarchy_outbox_event_idx" ON "hierarchy_outbox" USING btree ("event_id");--> statement-breakpoint
CREATE UNIQUE INDEX "hierarchy_outbox_idempotency_idx" ON "hierarchy_outbox" USING btree ("idempotency_key");--> statement-breakpoint
CREATE INDEX "hierarchy_outbox_status_created_idx" ON "hierarchy_outbox" USING btree ("status","created_at");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -15
View File
@@ -127,20 +127,6 @@
"when": 1787609223282,
"tag": "0017_accounts_issuer",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1787862158838,
"tag": "0018_clean_cobalt_man",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1787880918208,
"tag": "0019_volatile_killraven",
"breakpoints": true
}
]
}
}
@@ -1,363 +0,0 @@
/**
* Hierarchy audit event + outbox schema witnesses contract 1 §5.2 and the
* schema-level half of §6.4.
*
* Witnesses the guarantees the tables themselves carry: verb/target-kind/
* transfer CHECK constraints, idempotency uniqueness (REQ-AUD-001 duplicate
* suppression at the database level), monotonic append order (`seq`),
* deletion-safe linkage (no foreign key from the events table into any class
* table events survive the deletion of their target), the causation
* self-FK, and the outbox's FK/uniqueness/status shape. The repository-level
* half of §6.4 (same-transaction atomicity, rollback, replay) is witnessed in
* apps/gateway/src/hierarchy/hierarchy-audit.integration.test.ts.
*
* Two legs run the same witness body:
* - PGlite (WASM Postgres): always runs.
* - Real PostgreSQL (§6.8): runs when DATABASE_URL is set the binding
* witness; CI migrates ci-postgres before `pnpm test`.
*/
import { randomUUID } from 'node:crypto';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createDb } from './client.js';
import { createPgliteDb } from './client-pglite.js';
import { runPgliteMigrations } from './migrate.js';
import { companies, hierarchyAuditEvents, hierarchyOutbox } from './schema.js';
type AnyDb = {
db: {
insert: (t: unknown) => { values: (v: unknown) => Promise<unknown> };
execute: (q: unknown) => Promise<{ rows?: unknown[] } | unknown[]>;
};
close: () => Promise<void>;
};
/** Match a constraint failure anywhere along drizzle's cause chain. */
async function expectViolation(p: Promise<unknown>, re: RegExp, label = ''): Promise<void> {
let err: unknown;
try {
await p;
} catch (e) {
err = e;
}
expect(err, label || 'expected the statement to be refused').toBeDefined();
const messages: string[] = [];
let cur: unknown = err;
while (cur instanceof Error) {
messages.push(cur.message);
cur = (cur as { cause?: unknown }).cause;
}
expect(messages.join(' | '), label).toMatch(re);
}
function rows(res: { rows?: unknown[] } | unknown[]): Record<string, unknown>[] {
return (Array.isArray(res) ? res : (res.rows ?? [])) as Record<string, unknown>[];
}
/** Unique per-run prefix so real-PG runs never collide and clean up safely. */
const T = `hier-a-${randomUUID().slice(0, 8)}`;
/** Minimal valid event row; overrides compose the negative cases. */
type EventInsert = typeof hierarchyAuditEvents.$inferInsert;
function eventRow(overrides: Partial<EventInsert> = {}): EventInsert {
return {
actorId: `${T}-actor`,
verb: 'create',
targetKind: 'company',
targetId: randomUUID(),
targetSnapshot: { id: 'x', slug: 'x', name: 'x', parentChain: [] },
correlationId: `${T}-corr`,
idempotencyKey: `${T}-${randomUUID()}`,
...overrides,
};
}
function witnessSuite(getHandle: () => AnyDb): void {
const db = () => getHandle().db as unknown as ReturnType<typeof createDb>['db'];
afterAll(async () => {
const d = db();
await d.execute(sql`DELETE FROM hierarchy_outbox WHERE idempotency_key LIKE ${T + '%'}`);
// Caused events first: the causation self-FK is RESTRICT.
await d.execute(
sql`DELETE FROM hierarchy_audit_events WHERE idempotency_key LIKE ${T + '%'} AND causation_id IS NOT NULL`,
);
await d.execute(sql`DELETE FROM hierarchy_audit_events WHERE idempotency_key LIKE ${T + '%'}`);
await d.execute(sql`DELETE FROM companies WHERE slug LIKE ${T + '%'}`);
});
// ── CHECK constraints ──────────────────────────────────────────────────────
it('accepts every declared verb and refuses an undeclared one', async () => {
for (const verb of [
'create',
'rename',
'delete',
'grant_create',
'grant_change',
'grant_revoke',
]) {
await db().insert(hierarchyAuditEvents).values(eventRow({ verb }));
}
await expectViolation(
db()
.insert(hierarchyAuditEvents)
.values(eventRow({ verb: 'update' })),
/verb_check|violates check/i,
'undeclared verb must be refused',
);
});
it('refuses an undeclared target kind', async () => {
await expectViolation(
db()
.insert(hierarchyAuditEvents)
.values(eventRow({ targetKind: 'workspace' })),
/target_kind_check|violates check/i,
'workspace is not an audited target kind (workspace mutation is SOT-side)',
);
});
it('requires transfer snapshots exactly on transfers', async () => {
const parent = { kind: 'company', id: randomUUID(), slug: 'p' };
await db()
.insert(hierarchyAuditEvents)
.values(
eventRow({
verb: 'transfer',
targetKind: 'estate',
transferFrom: parent,
transferTo: { ...parent, id: randomUUID() },
}),
);
await expectViolation(
db()
.insert(hierarchyAuditEvents)
.values(eventRow({ verb: 'transfer' })),
/transfer_check|violates check/i,
'transfer without source/destination snapshots must be refused',
);
await expectViolation(
db()
.insert(hierarchyAuditEvents)
.values(eventRow({ verb: 'transfer', transferFrom: parent })),
/transfer_check|violates check/i,
'transfer with only the source snapshot must be refused',
);
await expectViolation(
db()
.insert(hierarchyAuditEvents)
.values(eventRow({ verb: 'create', transferFrom: parent, transferTo: parent })),
/transfer_check|violates check/i,
'non-transfer with transfer snapshots must be refused',
);
});
// ── Idempotency and ordering ───────────────────────────────────────────────
it('refuses a duplicate idempotency key', async () => {
const key = `${T}-dup-${randomUUID()}`;
await db()
.insert(hierarchyAuditEvents)
.values(eventRow({ idempotencyKey: key }));
await expectViolation(
db()
.insert(hierarchyAuditEvents)
.values(eventRow({ idempotencyKey: key })),
/duplicate key|unique/i,
);
});
it('assigns strictly increasing seq in insert order for one target', async () => {
const targetId = randomUUID();
const k1 = `${T}-seq-1-${randomUUID()}`;
const k2 = `${T}-seq-2-${randomUUID()}`;
await db()
.insert(hierarchyAuditEvents)
.values(eventRow({ targetId, idempotencyKey: k1 }));
await db()
.insert(hierarchyAuditEvents)
.values(eventRow({ targetId, verb: 'rename', idempotencyKey: k2 }));
const res = rows(
await db().execute(
sql`SELECT idempotency_key, seq FROM hierarchy_audit_events WHERE target_id = ${targetId} ORDER BY seq ASC`,
),
);
expect(res.map((r) => r['idempotency_key'])).toEqual([k1, k2]);
expect(Number(res[1]!['seq'])).toBeGreaterThan(Number(res[0]!['seq']));
});
// ── Deletion-safe linkage (§5.2) ───────────────────────────────────────────
it('has no foreign key into any class table, and events survive target deletion', async () => {
const fks = rows(
await db().execute(sql`
SELECT ccu.table_name AS referenced_table
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'hierarchy_audit_events'
`),
);
// The causation self-FK is the ONLY foreign key on the events table.
expect([...new Set(fks.map((r) => r['referenced_table']))]).toEqual(['hierarchy_audit_events']);
const companyId = randomUUID();
await db()
.insert(companies)
.values({ id: companyId, name: 'Doomed', slug: `${T}-doomed` });
const key = `${T}-survive-${randomUUID()}`;
await db()
.insert(hierarchyAuditEvents)
.values(
eventRow({
verb: 'delete',
targetId: companyId,
targetSnapshot: { id: companyId, slug: `${T}-doomed`, name: 'Doomed', parentChain: [] },
idempotencyKey: key,
}),
);
await db().execute(sql`DELETE FROM companies WHERE id = ${companyId}`);
const after = rows(
await db().execute(
sql`SELECT target_snapshot FROM hierarchy_audit_events WHERE idempotency_key = ${key}`,
),
);
expect(after).toHaveLength(1);
expect((after[0]!['target_snapshot'] as { id: string }).id).toBe(companyId);
});
it('enforces the causation self-FK and RESTRICTs deleting a cause', async () => {
await expectViolation(
db()
.insert(hierarchyAuditEvents)
.values(eventRow({ causationId: randomUUID() })),
/foreign key/i,
'causation must reference an existing event',
);
const causeKey = `${T}-cause-${randomUUID()}`;
await db()
.insert(hierarchyAuditEvents)
.values(eventRow({ verb: 'delete', idempotencyKey: causeKey }));
const cause = rows(
await db().execute(
sql`SELECT id FROM hierarchy_audit_events WHERE idempotency_key = ${causeKey}`,
),
)[0]!;
await db()
.insert(hierarchyAuditEvents)
.values(
eventRow({ verb: 'grant_revoke', targetKind: 'grant', causationId: cause['id'] as string }),
);
await expectViolation(
db().execute(sql`DELETE FROM hierarchy_audit_events WHERE id = ${cause['id'] as string}`),
/foreign key/i,
'a cause with dependent events must not be deletable',
);
});
// ── Outbox shape ───────────────────────────────────────────────────────────
it('outbox rows require an existing event, one outbox row per event, unique idempotency', async () => {
await expectViolation(
db()
.insert(hierarchyOutbox)
.values({
eventId: randomUUID(),
idempotencyKey: `${T}-ob-${randomUUID()}`,
correlationId: `${T}-corr`,
}),
/foreign key/i,
'outbox must reference an existing event',
);
const key = `${T}-ob-${randomUUID()}`;
await db()
.insert(hierarchyAuditEvents)
.values(eventRow({ idempotencyKey: key }));
const event = rows(
await db().execute(sql`SELECT id FROM hierarchy_audit_events WHERE idempotency_key = ${key}`),
)[0]!;
const eventId = event['id'] as string;
await db()
.insert(hierarchyOutbox)
.values({ eventId, idempotencyKey: key, correlationId: `${T}-corr` });
await expectViolation(
db()
.insert(hierarchyOutbox)
.values({ eventId, idempotencyKey: `${T}-ob2-${randomUUID()}`, correlationId: `${T}-c` }),
/duplicate key|unique/i,
'one outbox record per event',
);
await expectViolation(
db().execute(
sql`INSERT INTO hierarchy_outbox (event_id, idempotency_key, correlation_id, status)
VALUES (${eventId}, ${`${T}-ob3-${randomUUID()}`}, 'c', 'failed')`,
),
/invalid input value for enum|22P02/i,
'status outside pending/processing/delivered must be refused',
);
});
it('outbox FK RESTRICTs event deletion while the outbox row exists', async () => {
const key = `${T}-obr-${randomUUID()}`;
await db()
.insert(hierarchyAuditEvents)
.values(eventRow({ idempotencyKey: key }));
const event = rows(
await db().execute(sql`SELECT id FROM hierarchy_audit_events WHERE idempotency_key = ${key}`),
)[0]!;
await db()
.insert(hierarchyOutbox)
.values({
eventId: event['id'] as string,
idempotencyKey: key,
correlationId: `${T}-corr`,
});
await expectViolation(
db().execute(sql`DELETE FROM hierarchy_audit_events WHERE id = ${event['id'] as string}`),
/foreign key/i,
);
});
}
// ── Leg 1: PGlite (always runs — local witness signal) ───────────────────────
describe('hierarchy audit witnesses — PGlite', () => {
let dir: string;
let handle: ReturnType<typeof createPgliteDb>;
beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'hier-audit-witness-'));
handle = createPgliteDb(dir);
await runPgliteMigrations(handle);
});
afterAll(async () => {
await handle.close();
rmSync(dir, { recursive: true, force: true });
});
witnessSuite(() => handle as unknown as AnyDb);
});
// ── Leg 2: real PostgreSQL (§6.8 — binding witness, ci-postgres in CI) ───────
const hasPostgres = Boolean(process.env['DATABASE_URL']);
describe.skipIf(!hasPostgres)('hierarchy audit witnesses — real PostgreSQL', () => {
let handle: ReturnType<typeof createDb>;
beforeAll(() => {
handle = createDb(process.env['DATABASE_URL']!);
});
afterAll(async () => {
await handle.close();
});
witnessSuite(() => handle as unknown as AnyDb);
});
@@ -1,501 +0,0 @@
/**
* Hierarchy schema witnesses contract 1 (docs/requirements/hierarchy-schema.md) §6.
*
* Witnesses §6.1 (chain construction, slug scoping, grant CHECKs, grant
* uniqueness, NOT NULLs), §6.2 (column allowlist), the database-level parts of
* §6.6 (RESTRICT/cascade deletion behavior), and §6.7's catalog half (no
* foreign keys from outside the class into class tables).
*
* Two legs run the same witness body:
* - PGlite (WASM Postgres): always runs, so the witnesses execute locally
* with no database configured.
* - Real PostgreSQL (§6.8): runs when DATABASE_URL is set in CI that is
* the ci-postgres service, migrated by the pipeline before `pnpm test`.
* This leg is the contract's binding witness; the PGlite leg is the local
* development signal.
*/
import { randomUUID } from 'node:crypto';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createDb } from './client.js';
import { createPgliteDb } from './client-pglite.js';
import { runPgliteMigrations } from './migrate.js';
import {
companies,
estates,
hierarchyGrants,
platformProjects,
workspaces,
teams,
users,
} from './schema.js';
type AnyDb = {
db: {
insert: (t: unknown) => { values: (v: unknown) => Promise<unknown> };
delete: (t: unknown) => { where?: unknown } & PromiseLike<unknown>;
execute: (q: unknown) => Promise<{ rows?: unknown[] } | unknown[]>;
};
close: () => Promise<void>;
};
/** Column allowlist — the exact declared sets of §2/§3. Nothing else. */
const COLUMN_ALLOWLIST: Record<string, string[]> = {
companies: ['id', 'name', 'slug', 'created_at', 'updated_at'],
estates: ['id', 'name', 'slug', 'company_id'],
platform_projects: ['id', 'name', 'slug', 'estate_id'],
workspaces: ['id', 'name', 'slug', 'platform_project_id'],
hierarchy_grants: [
'id',
'user_id',
'team_id',
'company_id',
'estate_id',
'platform_project_id',
'role',
'granted_by',
'created_at',
],
};
const NODE_TABLES = ['companies', 'estates', 'platform_projects', 'workspaces'];
const CLASS_TABLES = [...NODE_TABLES, 'hierarchy_grants'];
/**
* Drizzle wraps constraint failures ("Failed query: ...") with the driver
* error attached as `cause`. Match the pattern anywhere along the cause chain.
*/
async function expectViolation(p: Promise<unknown>, re: RegExp, label = ''): Promise<void> {
let err: unknown;
try {
await p;
} catch (e) {
err = e;
}
expect(err, label || 'expected the statement to be refused').toBeDefined();
const messages: string[] = [];
let cur: unknown = err;
while (cur instanceof Error) {
messages.push(cur.message);
cur = (cur as { cause?: unknown }).cause;
}
expect(messages.join(' | '), label).toMatch(re);
}
function rows(res: { rows?: unknown[] } | unknown[]): Record<string, unknown>[] {
return (Array.isArray(res) ? res : (res.rows ?? [])) as Record<string, unknown>[];
}
/** Unique per-run prefix so real-PG runs never collide and clean up safely. */
const T = `hier-w-${randomUUID().slice(0, 8)}`;
function witnessSuite(getHandle: () => AnyDb): void {
const db = () => getHandle().db as unknown as ReturnType<typeof createDb>['db'];
const userA = `${T}-user-a`;
const userB = `${T}-user-b`;
let teamId: string;
let companyId: string;
let company2Id: string;
let estateId: string;
let estate2Id: string;
let ppId: string;
let workspaceId: string;
beforeAll(async () => {
await db()
.insert(users)
.values([
{ id: userA, name: 'Witness A', email: `${userA}@example.com` },
{ id: userB, name: 'Witness B', email: `${userB}@example.com` },
]);
teamId = randomUUID();
await db()
.insert(teams)
.values({
id: teamId,
name: `${T}-team`,
slug: `${T}-team`,
ownerId: userA,
managerId: userA,
});
});
afterAll(async () => {
// Bottom-up, fail-closed order; grants cascade with their targets.
const d = db();
await d.execute(sql`DELETE FROM hierarchy_grants WHERE granted_by LIKE ${T + '%'}`);
await d.execute(sql`DELETE FROM workspaces WHERE slug LIKE ${T + '%'}`);
await d.execute(sql`DELETE FROM platform_projects WHERE slug LIKE ${T + '%'}`);
await d.execute(sql`DELETE FROM estates WHERE slug LIKE ${T + '%'}`);
await d.execute(sql`DELETE FROM companies WHERE slug LIKE ${T + '%'}`);
await d.execute(sql`DELETE FROM teams WHERE slug LIKE ${T + '%'}`);
await d.execute(sql`DELETE FROM users WHERE id LIKE ${T + '%'}`);
});
// ── §6.1 chain construction ────────────────────────────────────────────────
it('accepts a full valid chain: company → estate → platform-project → workspace', async () => {
companyId = randomUUID();
estateId = randomUUID();
ppId = randomUUID();
workspaceId = randomUUID();
await db()
.insert(companies)
.values({ id: companyId, name: 'Acme', slug: `${T}-acme` });
await db()
.insert(estates)
.values({ id: estateId, name: 'Estate 1', slug: `${T}-e1`, companyId });
await db()
.insert(platformProjects)
.values({ id: ppId, name: 'PP 1', slug: `${T}-pp1`, estateId });
await db()
.insert(workspaces)
.values({ id: workspaceId, name: 'WS 1', slug: `${T}-ws1`, platformProjectId: ppId });
});
it('accepts two siblings under one parent (the §2.5 control)', async () => {
estate2Id = randomUUID();
await db()
.insert(estates)
.values({ id: estate2Id, name: 'Estate 2', slug: `${T}-e2`, companyId });
});
it('refuses inserts with a null parent FK', async () => {
await expectViolation(
db().execute(
sql`INSERT INTO estates (id, name, slug, company_id) VALUES (${randomUUID()}, 'x', ${T + '-null-e'}, NULL)`,
),
/null value|not-null/i,
);
await expectViolation(
db().execute(
sql`INSERT INTO platform_projects (id, name, slug, estate_id) VALUES (${randomUUID()}, 'x', ${T + '-null-p'}, NULL)`,
),
/null value|not-null/i,
);
await expectViolation(
db().execute(
sql`INSERT INTO workspaces (id, name, slug, platform_project_id) VALUES (${randomUUID()}, 'x', ${T + '-null-w'}, NULL)`,
),
/null value|not-null/i,
);
});
it('refuses inserts with a dangling parent FK', async () => {
await expectViolation(
db()
.insert(estates)
.values({ id: randomUUID(), name: 'x', slug: `${T}-dangle`, companyId: randomUUID() }),
/foreign key/i,
);
});
it('catalog: each child table has exactly one parent-FK column and no parentage edge table exists', async () => {
const res = rows(
await db().execute(sql`
SELECT tc.table_name, kcu.column_name, ccu.table_name AS ref_table
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'
`),
);
const nodeSet = new Set(NODE_TABLES);
// Exactly one parent FK per child node table.
for (const [child, parent] of [
['estates', 'companies'],
['platform_projects', 'estates'],
['workspaces', 'platform_projects'],
] as const) {
const parentFks = res.filter(
(r) => r['table_name'] === child && nodeSet.has(String(r['ref_table'])),
);
expect(parentFks.map((r) => `${r['column_name']}->${r['ref_table']}`)).toEqual([
`${{ estates: 'company_id', platform_projects: 'estate_id', workspaces: 'platform_project_id' }[child]}->${parent}`,
]);
}
// No table outside the class references a node table (also §6.7's catalog
// half for companies/estates/platform_projects/workspaces), and the only
// multi-FK referencer is hierarchy_grants (grant attachment, not
// parentage).
const referencers = new Map<string, number>();
for (const r of res) {
if (nodeSet.has(String(r['ref_table']))) {
const t = String(r['table_name']);
referencers.set(t, (referencers.get(t) ?? 0) + 1);
}
}
for (const [table, count] of referencers) {
expect(CLASS_TABLES, `unexpected referencer of a node table: ${table}`).toContain(table);
if (count > 1) expect(table).toBe('hierarchy_grants');
}
// No FK anywhere references hierarchy_grants.
expect(res.filter((r) => r['ref_table'] === 'hierarchy_grants')).toEqual([]);
});
// ── §6.1 slug scoping ──────────────────────────────────────────────────────
it('refuses a duplicate slug under the same parent, accepts it under another parent', async () => {
company2Id = randomUUID();
await db()
.insert(companies)
.values({ id: company2Id, name: 'Beta', slug: `${T}-beta` });
await expectViolation(
db()
.insert(estates)
.values({ id: randomUUID(), name: 'dup', slug: `${T}-e1`, companyId }),
/duplicate key|unique/i,
);
// Same slug, different company — accepted.
await db()
.insert(estates)
.values({ id: randomUUID(), name: 'ok', slug: `${T}-e1`, companyId: company2Id });
// companies.slug is unique per deployment.
await expectViolation(
db()
.insert(companies)
.values({ id: randomUUID(), name: 'dup', slug: `${T}-acme` }),
/duplicate key|unique/i,
);
});
it('scopes platform_projects and workspaces slugs per parent (refuse same-parent duplicate, accept cross-parent)', async () => {
// Dedicated parent estate so this test leaves estate2 a leaf (the §3.4
// cascade witness depends on that).
const estate3Id = randomUUID();
await db()
.insert(estates)
.values({ id: estate3Id, name: 'Estate 3', slug: `${T}-e3`, companyId });
// platform_projects: (estate_id, slug) unique.
await expectViolation(
db()
.insert(platformProjects)
.values({ id: randomUUID(), name: 'dup', slug: `${T}-pp1`, estateId }),
/duplicate key|unique/i,
);
const pp2Id = randomUUID();
await db()
.insert(platformProjects)
.values({ id: pp2Id, name: 'ok', slug: `${T}-pp1`, estateId: estate3Id });
// workspaces: (platform_project_id, slug) unique.
await expectViolation(
db()
.insert(workspaces)
.values({ id: randomUUID(), name: 'dup', slug: `${T}-ws1`, platformProjectId: ppId }),
/duplicate key|unique/i,
);
await db()
.insert(workspaces)
.values({ id: randomUUID(), name: 'ok', slug: `${T}-ws1`, platformProjectId: pp2Id });
});
// ── §6.2 column allowlist ──────────────────────────────────────────────────
it('column allowlist: each class table has exactly its declared columns (no payload, no owner_id)', async () => {
for (const [table, allow] of Object.entries(COLUMN_ALLOWLIST)) {
const res = rows(
await db().execute(
sql`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ${table}`,
),
);
const actual = res.map((r) => String(r['column_name'])).sort();
expect(actual, `column set of ${table}`).toEqual([...allow].sort());
}
});
// ── §6.1 grant CHECKs ──────────────────────────────────────────────────────
it('accepts one valid grant per subject×target form', async () => {
// All six forms; also the base rows for the §6.1 uniqueness witness below.
const forms = [
{ userId: userA, companyId },
{ userId: userA, estateId },
{ userId: userA, platformProjectId: ppId },
{ teamId, companyId },
{ teamId, estateId },
{ teamId, platformProjectId: ppId },
];
for (const form of forms) {
await db()
.insert(hierarchyGrants)
.values({ ...form, role: 'owner', grantedBy: userA });
}
});
it('refuses a grant with zero or two subjects (exactly-one-of CHECK)', async () => {
await expectViolation(
db().insert(hierarchyGrants).values({ companyId, role: 'viewer', grantedBy: userA }),
/check constraint/i,
);
await expectViolation(
db()
.insert(hierarchyGrants)
.values({ userId: userA, teamId, companyId, role: 'viewer', grantedBy: userA }),
/check constraint/i,
);
});
it('refuses a grant with zero or two targets (exactly-one-of CHECK)', async () => {
await expectViolation(
db().insert(hierarchyGrants).values({ userId: userA, role: 'viewer', grantedBy: userA }),
/check constraint/i,
);
await expectViolation(
db()
.insert(hierarchyGrants)
.values({ userId: userA, companyId, estateId, role: 'viewer', grantedBy: userA }),
/check constraint/i,
);
});
// ── §6.1 grant uniqueness (NULLS NOT DISTINCT) ─────────────────────────────
it('refuses a duplicate (subject, target, role) for each of the six forms', async () => {
const forms = [
{ userId: userA, companyId },
{ userId: userA, estateId },
{ userId: userA, platformProjectId: ppId },
{ teamId, companyId },
{ teamId, estateId },
{ teamId, platformProjectId: ppId },
];
for (const form of forms) {
await expectViolation(
db()
.insert(hierarchyGrants)
.values({ ...form, role: 'owner', grantedBy: userB }),
/duplicate key|unique/i,
`duplicate form ${JSON.stringify(form)} must be refused`,
);
}
// Control: same subject and target with a different role is a new grant.
await db()
.insert(hierarchyGrants)
.values({ userId: userA, companyId, role: `${T}-other-role`, grantedBy: userA });
});
// ── §6.1 NOT NULLs ─────────────────────────────────────────────────────────
it('refuses null role, granted_by, and null name/slug columns', async () => {
await expectViolation(
db().execute(
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, NULL, ${userA})`,
),
/null value|not-null/i,
);
await expectViolation(
db().execute(
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, 'x', NULL)`,
),
/null value|not-null/i,
);
await expectViolation(
db().execute(sql`INSERT INTO companies (name, slug) VALUES (NULL, ${T + '-nn'})`),
/null value|not-null/i,
);
await expectViolation(
db().execute(sql`INSERT INTO companies (name, slug) VALUES ('x', NULL)`),
/null value|not-null/i,
);
await expectViolation(
db().execute(
sql`INSERT INTO estates (name, slug, company_id) VALUES ('x', NULL, ${companyId})`,
),
/null value|not-null/i,
);
});
// ── §6.6 deletion (database-level witnesses) ───────────────────────────────
it('refuses deleting a node with children (fail-closed bottom-up)', async () => {
await expectViolation(
db().execute(sql`DELETE FROM companies WHERE id = ${companyId}`),
/foreign key/i,
);
await expectViolation(
db().execute(sql`DELETE FROM estates WHERE id = ${estateId}`),
/foreign key/i,
);
await expectViolation(
db().execute(sql`DELETE FROM platform_projects WHERE id = ${ppId}`),
/foreign key/i,
);
});
it('cascades a deleted leaf nodes 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
-213
View File
@@ -3,8 +3,6 @@
* drizzle-kit reads this file directly (avoids CJS/ESM extension issues).
*/
import { sql } from 'drizzle-orm';
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
import {
pgTable,
pgEnum,
@@ -15,8 +13,6 @@ import {
jsonb,
index,
uniqueIndex,
unique,
check,
real,
integer,
bigint,
@@ -1052,212 +1048,3 @@ export const federationEnrollmentTokens = pgTable('federation_enrollment_tokens'
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
// ─── Hierarchy (tenancy/authorization structure record class) ────────────────
// Contract: docs/requirements/hierarchy-schema.md (D2, ratified 2026-08-27).
// Five tables: companies → estates → platform_projects → workspaces, plus
// hierarchy_grants. Class rows carry parentage, naming, grant, and
// audit-linkage data only — the column sets below are exhaustive (§2.7) and
// witnessed against information_schema (§6.2). No owner_id: ownership is the
// grant structure (§4.4). All writes flow through the Gateway hierarchy
// command family only (§5.1), enforced by the writer-coverage assertion
// (§6.3b) — do not add writers outside that allowlist.
export const companies = pgTable('companies', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const estates = pgTable(
'estates',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').notNull(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'restrict' }),
},
(t) => [unique('estates_company_slug_uniq').on(t.companyId, t.slug)],
);
export const platformProjects = pgTable(
'platform_projects',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').notNull(),
estateId: uuid('estate_id')
.notNull()
.references(() => estates.id, { onDelete: 'restrict' }),
},
(t) => [unique('platform_projects_estate_slug_uniq').on(t.estateId, t.slug)],
);
export const workspaces = pgTable(
'workspaces',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').notNull(),
platformProjectId: uuid('platform_project_id')
.notNull()
.references(() => platformProjects.id, { onDelete: 'restrict' }),
},
(t) => [unique('workspaces_platform_project_slug_uniq').on(t.platformProjectId, t.slug)],
);
export const hierarchyGrants = pgTable(
'hierarchy_grants',
{
id: uuid('id').primaryKey().defaultRandom(),
// Subject: exactly one of user/team (CHECK below). Principal FKs are
// RESTRICT until a deletion-and-retention contract rules otherwise (§3.3).
userId: text('user_id').references(() => users.id, { onDelete: 'restrict' }),
teamId: uuid('team_id').references(() => teams.id, { onDelete: 'restrict' }),
// Target: exactly one of the three grantable levels (CHECK below).
// Target FKs CASCADE — the one permitted cascade in the class (§3.3);
// cascaded grant deletions are audited by the command family (§5.2).
companyId: uuid('company_id').references(() => companies.id, { onDelete: 'cascade' }),
estateId: uuid('estate_id').references(() => estates.id, { onDelete: 'cascade' }),
platformProjectId: uuid('platform_project_id').references(() => platformProjects.id, {
onDelete: 'cascade',
}),
// Role vocabulary and its CHECK constraint are contract 2 §2 (M4-2).
role: text('role').notNull(),
grantedBy: text('granted_by')
.notNull()
.references(() => users.id, { onDelete: 'restrict' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
check('hierarchy_grants_subject_check', sql`num_nonnulls(user_id, team_id) = 1`),
check(
'hierarchy_grants_target_check',
sql`num_nonnulls(company_id, estate_id, platform_project_id) = 1`,
),
// At most one grant per (subject, target, role) across all six
// subject×target forms — NULLS NOT DISTINCT so nullable columns
// participate (§3.2).
unique('hierarchy_grants_subject_target_role_uniq')
.on(t.userId, t.teamId, t.companyId, t.estateId, t.platformProjectId, t.role)
.nullsNotDistinct(),
index('hierarchy_grants_company_id_idx').on(t.companyId),
index('hierarchy_grants_estate_id_idx').on(t.estateId),
index('hierarchy_grants_platform_project_id_idx').on(t.platformProjectId),
index('hierarchy_grants_user_id_idx').on(t.userId),
index('hierarchy_grants_team_id_idx').on(t.teamId),
index('hierarchy_grants_granted_by_idx').on(t.grantedBy),
],
);
// ─── Hierarchy audit events + outbox (contract 1 §5.2) ──────────────────────
// NOT part of the record class (the class is exactly the five tables above).
// Append-only semantic audit log for hierarchy mutations, with a dedicated
// transactional outbox — hierarchy events are not workspace-scoped rows and
// do not ride the workspace outbox. Deletion-safe linkage: events reference
// their target by an immutable snapshot (id, slug, parent chain at event
// time), never by a foreign key into the class tables, so append-only events
// survive the deletion of their target. Append-only is enforced at the
// application layer (the hierarchy audit repository exposes no update/delete
// path for events); REQ-AUD-001's INSERT/SELECT-only database role is a
// deployment concern outside this schema.
export const HIERARCHY_AUDIT_VERBS = [
'create',
'rename',
'transfer',
'delete',
'grant_create',
'grant_change',
'grant_revoke',
] as const;
export const HIERARCHY_AUDIT_TARGET_KINDS = [
'company',
'estate',
'platform_project',
'grant',
] as const;
export const hierarchyAuditEvents = pgTable(
'hierarchy_audit_events',
{
id: uuid('id').primaryKey().defaultRandom(),
// Global append order; per-target ordering (REQ-AUD-001) is a filter on
// target_id ordered by seq.
seq: bigint('seq', { mode: 'number' }).notNull().generatedAlwaysAsIdentity(),
// No FK: audit events outlive every principal and every target (§5.2).
actorId: text('actor_id').notNull(),
verb: text('verb').notNull(),
targetKind: text('target_kind').notNull(),
targetId: uuid('target_id').notNull(),
// Immutable snapshot at event time. Node events: { id, slug, name,
// parentChain: [{ kind, id, slug }, …] root-first }. Grant events:
// { id, subject: { userId | teamId }, target: { kind, id }, role }
// (subject and role per contract 2 §4.4).
targetSnapshot: jsonb('target_snapshot').notNull(),
// Present exactly on transfers: snapshot of the source/destination
// parent ({ kind, id, slug }), CHECK-enforced below.
transferFrom: jsonb('transfer_from'),
transferTo: jsonb('transfer_to'),
correlationId: text('correlation_id').notNull(),
// Prior event in the causal chain (e.g. cascaded grant_revoke events
// caused by a node delete). Self-FK RESTRICT keeps the chain intact.
causationId: uuid('causation_id').references((): AnyPgColumn => hierarchyAuditEvents.id, {
onDelete: 'restrict',
}),
idempotencyKey: text('idempotency_key').notNull(),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('hierarchy_audit_events_idempotency_idx').on(t.idempotencyKey),
uniqueIndex('hierarchy_audit_events_seq_idx').on(t.seq),
index('hierarchy_audit_events_target_seq_idx').on(t.targetId, t.seq),
index('hierarchy_audit_events_correlation_idx').on(t.correlationId),
check(
'hierarchy_audit_events_verb_check',
sql`verb IN ('create', 'rename', 'transfer', 'delete', 'grant_create', 'grant_change', 'grant_revoke')`,
),
check(
'hierarchy_audit_events_target_kind_check',
sql`target_kind IN ('company', 'estate', 'platform_project', 'grant')`,
),
check(
'hierarchy_audit_events_transfer_check',
sql`(verb = 'transfer') = (transfer_from IS NOT NULL AND transfer_to IS NOT NULL)`,
),
],
);
export const hierarchyOutboxStatusEnum = pgEnum('hierarchy_outbox_status', [
'pending',
'processing',
'delivered',
]);
export const hierarchyOutbox = pgTable(
'hierarchy_outbox',
{
id: uuid('id').primaryKey().defaultRandom(),
// FK into the append-only events table (not a class table): never
// dangles, so RESTRICT is safe and keeps event/outbox integrity.
eventId: uuid('event_id')
.notNull()
.references(() => hierarchyAuditEvents.id, { onDelete: 'restrict' }),
idempotencyKey: text('idempotency_key').notNull(),
correlationId: text('correlation_id').notNull(),
status: hierarchyOutboxStatusEnum('status').notNull().default('pending'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
},
(t) => [
uniqueIndex('hierarchy_outbox_event_idx').on(t.eventId),
uniqueIndex('hierarchy_outbox_idempotency_idx').on(t.idempotencyKey),
index('hierarchy_outbox_status_created_idx').on(t.status, t.createdAt),
],
);
@@ -3,7 +3,6 @@
# Usage: issue-assign.sh -i ISSUE_NUMBER [-a assignee] [-l labels] [-m milestone]
set -e
set -o pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
@@ -34,36 +33,25 @@ Examples:
$(basename "$0") -i 42 -l "in-progress" -m "0.2.0"
$(basename "$0") -i 42 -a @me
EOF
exit "${1:-2}"
}
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
usage >&2
exit "${1:-1}"
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE="$2"
shift 2
;;
-a|--assignee)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ASSIGNEE="$2"
shift 2
;;
-l|--labels)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LABELS="$2"
shift 2
;;
-m|--milestone)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
MILESTONE="$2"
shift 2
;;
@@ -91,35 +79,20 @@ PLATFORM=$(detect_platform)
case "$PLATFORM" in
github)
if [[ -n "$ASSIGNEE" ]]; then
prov_rc=0
gh issue edit "$ISSUE" --add-assignee "$ASSIGNEE" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
gh issue edit "$ISSUE" --add-assignee "$ASSIGNEE"
fi
if [[ "$REMOVE_ASSIGNEE" == true ]]; then
# Get current assignees and remove them
# pipefail preserves the provider status through the pipeline;
# a FAILED lookup exits here instead of reading as a silent
# no-assignees skip (codex PR #1464). A successful lookup with
# zero assignees still skips the edit below.
CURRENT=$(gh issue view "$ISSUE" --json assignees -q '.assignees[].login' 2>/dev/null | tr '\n' ',') || {
echo "Error: could not read current assignees (provider lookup failed)" >&2
exit 1
}
CURRENT=$(gh issue view "$ISSUE" --json assignees -q '.assignees[].login' 2>/dev/null | tr '\n' ',')
if [[ -n "$CURRENT" ]]; then
prov_rc=0
gh issue edit "$ISSUE" --remove-assignee "${CURRENT%,}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
gh issue edit "$ISSUE" --remove-assignee "${CURRENT%,}"
fi
fi
if [[ -n "$LABELS" ]]; then
prov_rc=0
gh issue edit "$ISSUE" --add-label "$LABELS" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
gh issue edit "$ISSUE" --add-label "$LABELS"
fi
if [[ -n "$MILESTONE" ]]; then
prov_rc=0
gh issue edit "$ISSUE" --milestone "$MILESTONE" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
gh issue edit "$ISSUE" --milestone "$MILESTONE"
fi
echo "Issue #$ISSUE updated successfully"
;;
@@ -158,9 +131,7 @@ case "$PLATFORM" in
fi
if [[ "$NEEDS_EDIT" == true ]]; then
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
"${CMD[@]}"
echo "Issue #$ISSUE updated successfully"
else
echo "No changes specified"
@@ -1,7 +1,6 @@
#!/bin/bash
# issue-close.sh - Close an issue on GitHub or Gitea
# Usage: issue-close.sh -i <issue_number> [-b <comment>]
# (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28)
# Usage: issue-close.sh -i <issue_number> [-c <comment>]
set -e
@@ -13,49 +12,35 @@ source "$SCRIPT_DIR/detect-platform.sh"
ISSUE_NUMBER=""
COMMENT=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
# caller or stop gate can tell an invocation defect from a delivery blocker.
usage_error() {
echo "Error: $*" >&2
echo "Usage: issue-close.sh -i <issue_number> [-b <comment>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE_NUMBER="$2"
shift 2
;;
-b|--body|-c|--comment)
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
# a backward-compatible alias.
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
-c|--comment)
COMMENT="$2"
shift 2
;;
-h|--help)
echo "Usage: issue-close.sh -i <issue_number> [-b <comment>]"
echo "Usage: issue-close.sh -i <issue_number> [-c <comment>]"
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo " -b, --body Comment to add before closing (optional; canonical)"
echo " -c, --comment Alias for --body"
echo " -c, --comment Comment to add before closing (optional)"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
exit 0
;;
*)
usage_error "unknown option: $1"
echo "Unknown option: $1"
exit 1
;;
esac
done
if [[ -z "$ISSUE_NUMBER" ]]; then
usage_error "issue number is required (-i/--issue)"
echo "Error: Issue number is required (-i)"
exit 1
fi
# Detect platform and close issue
@@ -97,22 +82,10 @@ gitea_issue_close_api() {
}
if [[ "$PLATFORM" == "github" ]]; then
# R4: normalize provider failures to exit 1 (gh's own usage errors exit 2
# and would collide with the reserved usage-error status).
if [[ -n "$COMMENT" ]]; then
gh_rc=0
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
if [[ "$gh_rc" -ne 0 ]]; then
echo "Error: GitHub comment before close failed (gh exit $gh_rc)" >&2
exit 1
fi
fi
gh_rc=0
gh issue close "$ISSUE_NUMBER" || gh_rc=$?
if [[ "$gh_rc" -ne 0 ]]; then
echo "Error: GitHub issue close failed (gh exit $gh_rc)" >&2
exit 1
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
fi
gh issue close "$ISSUE_NUMBER"
echo "Closed GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
GITEA_LOGIN_NAME=$(get_gitea_login || true)
@@ -134,9 +107,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
exit 1
}
fi
prov_rc=0
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
else
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
if [[ -n "$COMMENT" ]]; then
@@ -1,7 +1,6 @@
#!/bin/bash
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
# Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]
# (-c/--comment is a backward-compatible alias for -b/--body; R1, 2026-08-28)
# Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]
#
# tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`);
# the non-existent `tea issue comment ...` form does not error — tea silently
@@ -33,61 +32,45 @@ ISSUE_NUMBER=""
COMMENT=""
LOGIN_OVERRIDE=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
# caller or stop gate can tell an invocation defect from a delivery blocker
# (CONSTITUTION gate 8 as amended; E2E-DELIVERY).
usage_error() {
echo "Error: $*" >&2
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE_NUMBER="$2"
shift 2
;;
-b|--body|-c|--comment)
# R1 (2026-08-28): --body is the canonical flag, matching
# issue-create/issue-edit/pr-create/pr-edit; -c/--comment stays a
# backward-compatible alias.
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
-c|--comment)
COMMENT="$2"
shift 2
;;
-l|--login)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LOGIN_OVERRIDE="$2"
shift 2
;;
-h|--help)
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]"
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo " -b, --body Comment text (required; canonical)"
echo " -c, --comment Alias for --body"
echo " -c, --comment Comment text (required)"
echo " -l, --login Override the detected Gitea tea login for this call"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
exit 0
;;
*)
usage_error "unknown option: $1"
echo "Unknown option: $1"
exit 1
;;
esac
done
if [[ -z "$ISSUE_NUMBER" ]]; then
usage_error "issue number is required (-i/--issue)"
echo "Error: Issue number is required (-i)"
exit 1
fi
if [[ -z "$COMMENT" ]]; then
usage_error "comment is required (-b/--body, or the -c/--comment alias)"
echo "Error: Comment is required (-c)"
exit 1
fi
detect_platform >/dev/null
@@ -357,15 +340,7 @@ PY
}
if [[ "$PLATFORM" == "github" ]]; then
# R4 exit-code contract: normalize provider failures to exit 1. gh's own
# usage errors exit 2, which would collide with this wrapper's reserved
# usage-error status if propagated raw (codex review of 08a00149).
gh_rc=0
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
if [[ "$gh_rc" -ne 0 ]]; then
echo "Error: GitHub comment write failed (gh exit $gh_rc; provider/credential failure — usage errors are exit 2)" >&2
exit 1
fi
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
# A --login override selects a NAMED tea credential and is the only way to
@@ -74,39 +74,26 @@ Examples:
$(basename "$0") -t "Fix login bug" -l "bug,priority-high"
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
$(basename "$0") -i
Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure.
EOF
exit "${1:-2}"
exit "${1:-1}"
}
# Parse arguments
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
usage >&2
}
while [[ $# -gt 0 ]]; do
case $1 in
-t|--title)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
TITLE="$2"
shift 2
;;
-b|--body)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
BODY="$2"
shift 2
;;
-l|--labels)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LABELS="$2"
shift 2
;;
-m|--milestone)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
MILESTONE="$2"
shift 2
;;
@@ -144,9 +131,7 @@ case "$PLATFORM" in
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
"${CMD[@]}"
;;
gitea)
if command -v tea >/dev/null 2>&1; then
@@ -14,39 +14,25 @@ BODY=""
LABELS=""
MILESTONE=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
# caller or stop gate can tell an invocation defect from a delivery blocker.
usage_error() {
echo "Error: $*" >&2
echo "Usage: issue-edit.sh -i <issue_number> [-t <title>] [-b <body>] [-l <labels>] [-m <milestone>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE_NUMBER="$2"
shift 2
;;
-t|--title)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
TITLE="$2"
shift 2
;;
-b|--body)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
BODY="$2"
shift 2
;;
-l|--labels)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LABELS="$2"
shift 2
;;
-m|--milestone)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
MILESTONE="$2"
shift 2
;;
@@ -60,18 +46,18 @@ while [[ $# -gt 0 ]]; do
echo " -l, --labels Labels (comma-separated, replaces existing)"
echo " -m, --milestone Milestone name"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
exit 0
;;
*)
usage_error "unknown option: $1"
echo "Unknown option: $1"
exit 1
;;
esac
done
if [[ -z "$ISSUE_NUMBER" ]]; then
usage_error "issue number is required (-i/--issue)"
echo "Error: Issue number is required (-i)"
exit 1
fi
detect_platform >/dev/null
@@ -82,9 +68,7 @@ if [[ "$PLATFORM" == "github" ]]; then
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
[[ -n "$LABELS" ]] && CMD+=(--add-label "$LABELS")
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
"${CMD[@]}"
echo "Updated GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
REPO_SLUG=$(get_repo_slug) || {
@@ -100,9 +84,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
[[ -n "$LABELS" ]] && CMD+=(--add-labels "$LABELS")
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
"${CMD[@]}"
echo "Updated Gitea issue #$ISSUE_NUMBER"
else
echo "Error: Unknown platform"
@@ -36,46 +36,33 @@ Examples:
$(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0
$(basename "$0") --repo ddk/ai-bma # List issues from anywhere
EOF
exit "${1:-2}"
exit "${1:-1}"
}
# Parse arguments
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
usage >&2
}
while [[ $# -gt 0 ]]; do
case $1 in
-s|--state)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
STATE="$2"
shift 2
;;
-l|--label)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LABEL="$2"
shift 2
;;
-m|--milestone)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
MILESTONE="$2"
shift 2
;;
-a|--assignee)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ASSIGNEE="$2"
shift 2
;;
-n|--limit)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LIMIT="$2"
shift 2
;;
-r|--repo)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
REPO_OVERRIDE="$2"
shift 2
;;
@@ -108,9 +95,7 @@ case "$PLATFORM" in
[[ -n "$LABEL" ]] && CMD+=(--label "$LABEL")
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
[[ -n "$ASSIGNEE" ]] && CMD+=(--assignee "$ASSIGNEE")
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
"${CMD[@]}"
;;
gitea)
if [[ -n "$REPO_OVERRIDE" ]]; then
@@ -129,9 +114,7 @@ case "$PLATFORM" in
[[ -n "$MILESTONE" ]] && CMD+=(--milestones "$MILESTONE")
# Note: tea may not support assignee filter directly in all versions.
[[ -n "$ASSIGNEE" ]] && echo "Note: Assignee filtering may require manual review for Gitea" >&2
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
"${CMD[@]}"
;;
*)
echo "Error: Could not detect git platform" >&2
@@ -1,7 +1,6 @@
#!/bin/bash
# issue-reopen.sh - Reopen a closed issue on GitHub or Gitea
# Usage: issue-reopen.sh -i <issue_number> [-b <comment>]
# (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28)
# Usage: issue-reopen.sh -i <issue_number> [-c <comment>]
set -e
@@ -12,49 +11,35 @@ source "$SCRIPT_DIR/detect-platform.sh"
ISSUE_NUMBER=""
COMMENT=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
# caller or stop gate can tell an invocation defect from a delivery blocker.
usage_error() {
echo "Error: $*" >&2
echo "Usage: issue-reopen.sh -i <issue_number> [-b <comment>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE_NUMBER="$2"
shift 2
;;
-b|--body|-c|--comment)
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
# a backward-compatible alias.
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
-c|--comment)
COMMENT="$2"
shift 2
;;
-h|--help)
echo "Usage: issue-reopen.sh -i <issue_number> [-b <comment>]"
echo "Usage: issue-reopen.sh -i <issue_number> [-c <comment>]"
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo " -b, --body Comment to add when reopening (optional; canonical)"
echo " -c, --comment Alias for --body"
echo " -c, --comment Comment to add when reopening (optional)"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
exit 0
;;
*)
usage_error "unknown option: $1"
echo "Unknown option: $1"
exit 1
;;
esac
done
if [[ -z "$ISSUE_NUMBER" ]]; then
usage_error "issue number is required (-i/--issue)"
echo "Error: Issue number is required (-i)"
exit 1
fi
detect_platform >/dev/null
@@ -95,34 +80,18 @@ gitea_issue_reopen_api() {
}
if [[ "$PLATFORM" == "github" ]]; then
# R4: normalize provider failures to exit 1 (gh's own usage errors exit 2
# and would collide with the reserved usage-error status).
if [[ -n "$COMMENT" ]]; then
gh_rc=0
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
if [[ "$gh_rc" -ne 0 ]]; then
echo "Error: GitHub comment before reopen failed (gh exit $gh_rc)" >&2
exit 1
fi
fi
gh_rc=0
gh issue reopen "$ISSUE_NUMBER" || gh_rc=$?
if [[ "$gh_rc" -ne 0 ]]; then
echo "Error: GitHub issue reopen failed (gh exit $gh_rc)" >&2
exit 1
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
fi
gh issue reopen "$ISSUE_NUMBER"
echo "Reopened GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
REPO_ARGS=$(get_gitea_repo_args || true)
if [[ -n "$REPO_ARGS" ]]; then
if [[ -n "$COMMENT" ]]; then
prov_rc=0
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $REPO_ARGS || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $REPO_ARGS
fi
prov_rc=0
tea issue reopen "$ISSUE_NUMBER" $REPO_ARGS || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
tea issue reopen "$ISSUE_NUMBER" $REPO_ARGS
else
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
if [[ -n "$COMMENT" ]]; then
@@ -8,14 +8,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
echo "Usage: issue-view.sh -i <issue_number> (see --help)" >&2
exit 2
}
ISSUE_NUMBER=""
# get_remote_host and get_gitea_token are provided by detect-platform.sh
@@ -82,7 +74,6 @@ if comments:
while [[ $# -gt 0 ]]; do
case $1 in
-i|--issue)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE_NUMBER="$2"
shift 2
;;
@@ -97,21 +88,21 @@ while [[ $# -gt 0 ]]; do
exit 0
;;
*)
usage_error "unknown option: $1"
echo "Unknown option: $1"
exit 1
;;
esac
done
if [[ -z "$ISSUE_NUMBER" ]]; then
usage_error "Issue number is required"
echo "Error: Issue number is required (-i)"
exit 1
fi
detect_platform >/dev/null
if [[ "$PLATFORM" == "github" ]]; then
prov_rc=0
gh issue view "$ISSUE_NUMBER" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
gh issue view "$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
if command -v tea >/dev/null 2>&1; then
# --comments is what makes tea print the comment bodies (#1357 F3).
@@ -28,25 +28,18 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
REPO="" MILESTONE="" LABEL="" LOGIN="" LIMIT=100
# R2 (2026-08-28): long-flag aliases with the same usage-error contract the
# wrapper family shares (rc 2, stderr). getopts could not take long flags.
usage_error() {
echo "Error: $*" >&2
echo "Usage: lane-brief.sh -r <owner/repo> [-m milestone] [-l label] [-L login] [-n limit]" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case "$1" in
-r|--repo) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; REPO="$2"; shift 2 ;;
-m|--milestone) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; MILESTONE="$2"; shift 2 ;;
-l|--label|--labels) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LABEL="$2"; shift 2 ;;
-L|--login) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LOGIN="$2"; shift 2 ;;
-n|--limit) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LIMIT="$2"; shift 2 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \?//'; exit 0 ;;
*) usage_error "unknown option: $1" ;;
while getopts "r:m:l:L:n:h" opt; do
case "$opt" in
r) REPO="$OPTARG" ;;
m) MILESTONE="$OPTARG" ;;
l) LABEL="$OPTARG" ;;
L) LOGIN="$OPTARG" ;;
n) LIMIT="$OPTARG" ;;
h) grep '^#' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "see -h" >&2; exit 2 ;;
esac
done
[[ -n "$REPO" ]] || usage_error "-r/--repo <owner/repo> required"
[[ -n "$REPO" ]] || { echo "FATAL: -r <owner/repo> required" >&2; exit 2; }
# Resolve login: explicit -L, then $GITEA_LOGIN, then owner inference, then the
# shared default-login resolver. Owner inference comes before the shared fallback
@@ -79,7 +72,7 @@ if [[ -z "$LOGIN" ]]; then
fi
fi
fi
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 1; }
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 2; }
command -v tea >/dev/null || { echo "FATAL: tea not found" >&2; exit 1; }
command -v jq >/dev/null || { echo "FATAL: jq not found" >&2; exit 1; }
@@ -8,20 +8,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
echo "Usage: milestone-close.sh -t <title> (see --help)" >&2
exit 2
}
TITLE=""
while [[ $# -gt 0 ]]; do
case $1 in
-t|--title)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
TITLE="$2"
shift 2
;;
@@ -34,30 +25,28 @@ while [[ $# -gt 0 ]]; do
exit 0
;;
*)
usage_error "unknown option: $1"
echo "Unknown option: $1"
exit 1
;;
esac
done
if [[ -z "$TITLE" ]]; then
usage_error "Milestone title is required"
echo "Error: Milestone title is required (-t)"
exit 1
fi
detect_platform >/dev/null
if [[ "$PLATFORM" == "github" ]]; then
prov_rc=0
gh api -X PATCH "/repos/{owner}/{repo}/milestones/$(gh api "/repos/{owner}/{repo}/milestones" --jq ".[] | select(.title==\"$TITLE\") | .number")" -f state=closed || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
gh api -X PATCH "/repos/{owner}/{repo}/milestones/$(gh api "/repos/{owner}/{repo}/milestones" --jq ".[] | select(.title==\"$TITLE\") | .number")" -f state=closed
echo "Closed GitHub milestone: $TITLE"
elif [[ "$PLATFORM" == "gitea" ]]; then
REPO_ARGS=$(get_gitea_repo_args) || {
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
exit 1
}
prov_rc=0
tea milestone close "$TITLE" $REPO_ARGS || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
tea milestone close "$TITLE" $REPO_ARGS
echo "Closed Gitea milestone: $TITLE"
else
echo "Error: Unknown platform"
@@ -3,7 +3,6 @@
# Usage: milestone-create.sh -t "Title" [-d "Description"] [--due "YYYY-MM-DD"]
set -e
set -o pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
@@ -38,31 +37,21 @@ Examples:
$(basename "$0") -t "0.0.1" -d "Pre-MVP Foundation Sprint"
$(basename "$0") -t "0.1.0" -d "MVP Release" --due "2025-03-01"
EOF
exit "${1:-2}"
exit "${1:-1}"
}
# Parse arguments
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
usage >&2
}
while [[ $# -gt 0 ]]; do
case $1 in
-t|--title)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
TITLE="$2"
shift 2
;;
-d|--desc)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
DESCRIPTION="$2"
shift 2
;;
--due)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
DUE_DATE="$2"
shift 2
;;
@@ -85,18 +74,14 @@ PLATFORM=$(detect_platform)
if [[ "$LIST_ONLY" == true ]]; then
case "$PLATFORM" in
github)
prov_rc=0
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)\t\(.title)\t\(.state)\t\(.open_issues)/\(.closed_issues) issues"' || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)\t\(.title)\t\(.state)\t\(.open_issues)/\(.closed_issues) issues"'
;;
gitea)
REPO_ARGS=$(get_gitea_repo_args) || {
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
exit 1
}
prov_rc=0
tea milestones list $REPO_ARGS || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
tea milestones list $REPO_ARGS
;;
*)
echo "Error: Could not detect git platform" >&2
@@ -107,7 +92,8 @@ if [[ "$LIST_ONLY" == true ]]; then
fi
if [[ -z "$TITLE" ]]; then
usage_error "Title is required (-t) for creating milestones"
echo "Error: Title is required (-t) for creating milestones" >&2
usage
fi
case "$PLATFORM" in
@@ -123,9 +109,7 @@ case "$PLATFORM" in
+ (if $d != "" then {"description": $d} else {} end)
+ (if $due != "" then {"due_on": ($due + "T00:00:00Z")} else {} end)')
prov_rc=0
gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD"
echo "Milestone '$TITLE' created successfully"
;;
gitea)
@@ -136,9 +120,7 @@ case "$PLATFORM" in
CMD=(tea milestones create --title "$TITLE")
[[ -n "$DESCRIPTION" ]] && CMD+=(--description "$DESCRIPTION")
[[ -n "$DUE_DATE" ]] && CMD+=(--deadline "$DUE_DATE")
prov_rc=0
"${CMD[@]}" $REPO_ARGS || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
"${CMD[@]}" $REPO_ARGS
echo "Milestone '$TITLE' created successfully"
;;
*)
@@ -8,20 +8,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
echo "Usage: milestone-list.sh [-s <state>] (see --help)" >&2
exit 2
}
STATE="open"
while [[ $# -gt 0 ]]; do
case $1 in
-s|--state)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
STATE="$2"
shift 2
;;
@@ -34,7 +25,8 @@ while [[ $# -gt 0 ]]; do
exit 0
;;
*)
usage_error "unknown option: $1"
echo "Unknown option: $1"
exit 1
;;
esac
done
@@ -42,17 +34,13 @@ done
detect_platform >/dev/null
if [[ "$PLATFORM" == "github" ]]; then
prov_rc=0
gh api "/repos/{owner}/{repo}/milestones?state=$STATE" --jq '.[] | "\(.title) (\(.state)) - \(.open_issues) open, \(.closed_issues) closed"' || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
gh api "/repos/{owner}/{repo}/milestones?state=$STATE" --jq '.[] | "\(.title) (\(.state)) - \(.open_issues) open, \(.closed_issues) closed"'
elif [[ "$PLATFORM" == "gitea" ]]; then
REPO_ARGS=$(get_gitea_repo_args) || {
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
exit 1
}
prov_rc=0
tea milestone list $REPO_ARGS || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
tea milestone list $REPO_ARGS
else
echo "Error: Unknown platform"
exit 1
+12 -43
View File
@@ -1,7 +1,6 @@
#!/bin/bash
# pr-close.sh - Close a pull request without merging on GitHub or Gitea
# Usage: pr-close.sh -n <pr_number> [-b <comment>]
# (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28)
# Usage: pr-close.sh -n <pr_number> [-c <comment>]
set -e
@@ -12,80 +11,50 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
COMMENT=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
# caller or stop gate can tell an invocation defect from a delivery blocker.
usage_error() {
echo "Error: $*" >&2
echo "Usage: pr-close.sh -n <pr_number> [-b <comment>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-n|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
PR_NUMBER="$2"
shift 2
;;
-b|--body|-c|--comment)
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
# a backward-compatible alias.
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
-c|--comment)
COMMENT="$2"
shift 2
;;
-h|--help)
echo "Usage: pr-close.sh -n <pr_number> [-b <comment>]"
echo "Usage: pr-close.sh -n <pr_number> [-c <comment>]"
echo ""
echo "Options:"
echo " -n, --number PR number (required)"
echo " -b, --body Comment before closing (optional; canonical)"
echo " -c, --comment Alias for --body"
echo " -c, --comment Comment before closing (optional)"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
exit 0
;;
*)
usage_error "unknown option: $1"
echo "Unknown option: $1"
exit 1
;;
esac
done
if [[ -z "$PR_NUMBER" ]]; then
usage_error "PR number is required (-n/--number)"
echo "Error: PR number is required (-n)"
exit 1
fi
detect_platform >/dev/null
if [[ "$PLATFORM" == "github" ]]; then
# R4: normalize provider failures to exit 1 (gh's own usage errors exit 2
# and would collide with the reserved usage-error status).
if [[ -n "$COMMENT" ]]; then
gh_rc=0
gh pr comment "$PR_NUMBER" --body "$COMMENT" || gh_rc=$?
if [[ "$gh_rc" -ne 0 ]]; then
echo "Error: GitHub PR comment before close failed (gh exit $gh_rc)" >&2
exit 1
fi
fi
gh_rc=0
gh pr close "$PR_NUMBER" || gh_rc=$?
if [[ "$gh_rc" -ne 0 ]]; then
echo "Error: GitHub PR close failed (gh exit $gh_rc)" >&2
exit 1
gh pr comment "$PR_NUMBER" --body "$COMMENT"
fi
gh pr close "$PR_NUMBER"
echo "Closed GitHub PR #$PR_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
if [[ -n "$COMMENT" ]]; then
prov_rc=0
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args) || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
fi
prov_rc=0
tea pr close "$PR_NUMBER" $(get_gitea_repo_args) || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
tea pr close "$PR_NUMBER" $(get_gitea_repo_args)
echo "Closed Gitea PR #$PR_NUMBER"
else
echo "Error: Unknown platform"
@@ -135,51 +135,37 @@ Examples:
$(basename "$0") -i 42 -b "Implements the feature described in #42"
$(basename "$0") -t "WIP: New feature" --draft
EOF
exit "${1:-2}"
}
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
usage >&2
exit "${1:-1}"
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-t|--title)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
TITLE="$2"
shift 2
;;
-b|--body)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
BODY="$2"
shift 2
;;
-B|--base)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
BASE_BRANCH="$2"
shift 2
;;
-H|--head)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
HEAD_BRANCH="$2"
shift 2
;;
-l|--labels)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LABELS="$2"
shift 2
;;
-m|--milestone)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
MILESTONE="$2"
shift 2
;;
-i|--issue)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ISSUE="$2"
shift 2
;;
@@ -280,9 +266,7 @@ case "$PLATFORM" in
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
[[ "$DRAFT" == true ]] && CMD+=(--draft)
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
"${CMD[@]}"
;;
gitea)
# tea pull create syntax. Always pass --repo because tea repo inference
+18 -31
View File
@@ -50,45 +50,38 @@ Options:
-H, --host HOST Explicit Gitea host (required with --repo off-host)
-h, --help Show this help message
EOF
exit "${1:-2}"
}
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1).
usage_error() {
echo "Error: $*" >&2
usage >&2
exit "${1:-1}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--number) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; PR_NUMBER="${2:-}"; shift 2 ;;
-t|--title) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; TITLE="${2:-}"; shift 2 ;;
-b|--body) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BODY="${2:-}"; shift 2 ;;
-B|--base) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BASE_BRANCH="${2:-}"; shift 2 ;;
-n|--number) PR_NUMBER="${2:-}"; shift 2 ;;
-t|--title) TITLE="${2:-}"; shift 2 ;;
-b|--body) BODY="${2:-}"; shift 2 ;;
-B|--base) BASE_BRANCH="${2:-}"; shift 2 ;;
--draft)
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
DRAFT_MODE="draft"; shift ;;
--ready)
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
DRAFT_MODE="ready"; shift ;;
-l|--login) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
-r|--repo) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; REPO_OVERRIDE="${2:-}"; shift 2 ;;
-H|--host) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; HOST_OVERRIDE="${2:-}"; shift 2 ;;
-l|--login) LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
-r|--repo) REPO_OVERRIDE="${2:-}"; shift 2 ;;
-H|--host) HOST_OVERRIDE="${2:-}"; shift 2 ;;
-h|--help) usage 0 ;;
*) echo "Unknown option: $1" >&2; usage ;;
esac
done
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 2; }
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 2; }
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 1; }
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 1; }
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
echo "Error: At least one edit option is required" >&2
exit 2
exit 1
fi
[[ -z "$REPO_OVERRIDE" || "$REPO_OVERRIDE" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || {
echo "Error: --repo must be OWNER/REPO" >&2
exit 2
exit 1
}
if [[ -n "$HOST_OVERRIDE" || -n "$REPO_OVERRIDE" ]]; then
@@ -99,24 +92,18 @@ fi
case "$PLATFORM" in
github)
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 2; }
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 1; }
if [[ -n "$TITLE" || -n "$BODY" || -n "$BASE_BRANCH" ]]; then
CMD=(gh pr edit "$PR_NUMBER")
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
prov_rc=0
"${CMD[@]}" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
"${CMD[@]}"
fi
if [[ "$DRAFT_MODE" == "draft" ]]; then
prov_rc=0
gh pr ready "$PR_NUMBER" --undo || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
gh pr ready "$PR_NUMBER" --undo
elif [[ "$DRAFT_MODE" == "ready" ]]; then
prov_rc=0
gh pr ready "$PR_NUMBER" || prov_rc=$?
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
gh pr ready "$PR_NUMBER"
fi
;;
gitea)
@@ -43,92 +43,60 @@ LOGIN_OVERRIDE=""
REPO_OVERRIDE=""
HOST_OVERRIDE=""
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
# distinct from provider, credential, and verification failures (exit 1), so a
# caller or stop gate can tell an invocation defect from a delivery blocker.
usage_error() {
echo "Error: $*" >&2
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-b <comment>] (see --help)" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case $1 in
-n|--number)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
PR_NUMBER="$2"
shift 2
;;
-a|--action)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
ACTION="$2"
shift 2
;;
-b|--body|-c|--comment)
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
# a backward-compatible alias.
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
-c|--comment)
COMMENT="$2"
shift 2
;;
-l|--login)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
LOGIN_OVERRIDE="$2"
shift 2
;;
-r|--repo)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
REPO_OVERRIDE="$2"
shift 2
;;
-H|--host)
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
HOST_OVERRIDE="$2"
shift 2
;;
-h|--help)
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-b <comment>] [--login <name>] [-r owner/repo] [-H host]"
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>] [-r owner/repo] [-H host]"
echo ""
echo "Options:"
echo " -n, --number PR number (required)"
echo " -a, --action Review action: approve, request-changes, comment (required)"
echo " -b, --body Review comment (required for request-changes; canonical)"
echo " -c, --comment Alias for --body"
echo " -c, --comment Review comment (required for request-changes)"
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
echo " -h, --help Show this help"
echo ""
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
exit 0
;;
*)
usage_error "unknown option: $1"
echo "Unknown option: $1"
exit 1
;;
esac
done
if [[ -z "$PR_NUMBER" ]]; then
usage_error "PR number is required (-n/--number)"
echo "Error: PR number is required (-n)"
exit 1
fi
if [[ -z "$ACTION" ]]; then
usage_error "Action is required (-a/--action): approve, request-changes, comment"
fi
# Validate the action BEFORE any provider contact (codex review of PR #1464:
# an unsupported --action previously reached platform detection and could
# touch the provider before failing with a provider-class status).
case "$ACTION" in
approve|request-changes|comment) ;;
*) usage_error "unknown action '$ACTION': approve, request-changes, comment" ;;
esac
# Body-required actions fail fast too (codex follow-up on PR #1464):
# request-changes and comment both require a body; validate before any
# provider contact.
if [[ ( "$ACTION" == "request-changes" || "$ACTION" == "comment" ) && -z "$COMMENT" ]]; then
usage_error "comment required for $ACTION (-b/--body)"
echo "Error: Action is required (-a): approve, request-changes, comment"
exit 1
fi
if [[ -n "$REPO_OVERRIDE" ]]; then
@@ -711,18 +679,15 @@ PY
if [[ "$PLATFORM" == "github" ]]; then
case $ACTION in
approve)
gh_rc=0
gh pr review "$PR_NUMBER" --approve ${COMMENT:+--body "$COMMENT"} || gh_rc=$?
[[ "$gh_rc" -eq 0 ]] || { echo "Error: GitHub approve failed (gh exit $gh_rc; provider failure, not a usage error)" >&2; exit 1; }
gh pr review "$PR_NUMBER" --approve ${COMMENT:+--body "$COMMENT"}
echo "Approved GitHub PR #$PR_NUMBER"
;;
request-changes)
if [[ -z "$COMMENT" ]]; then
usage_error "comment required for request-changes (-b/--body)"
echo "Error: Comment required for request-changes"
exit 1
fi
gh_rc=0
gh pr review "$PR_NUMBER" --request-changes --body "$COMMENT" || gh_rc=$?
[[ "$gh_rc" -eq 0 ]] || { echo "Error: GitHub request-changes failed (gh exit $gh_rc; provider failure, not a usage error)" >&2; exit 1; }
gh pr review "$PR_NUMBER" --request-changes --body "$COMMENT"
echo "Requested changes on GitHub PR #$PR_NUMBER"
;;
comment)
@@ -730,13 +695,12 @@ if [[ "$PLATFORM" == "github" ]]; then
echo "Error: Comment required"
exit 1
fi
gh_rc=0
gh pr review "$PR_NUMBER" --comment --body "$COMMENT" || gh_rc=$?
[[ "$gh_rc" -eq 0 ]] || { echo "Error: GitHub review comment failed (gh exit $gh_rc; provider failure, not a usage error)" >&2; exit 1; }
gh pr review "$PR_NUMBER" --comment --body "$COMMENT"
echo "Added review comment to GitHub PR #$PR_NUMBER"
;;
*)
usage_error "unknown action: $ACTION"
echo "Error: Unknown action: $ACTION"
exit 1
;;
esac
elif [[ "$PLATFORM" == "gitea" ]]; then
@@ -774,7 +738,8 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
;;
request-changes)
if [[ -z "$COMMENT" ]]; then
usage_error "comment required for request-changes (-b/--body)"
echo "Error: Comment required for request-changes"
exit 1
fi
# Best-effort host for credential resolution only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
@@ -829,7 +794,8 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
;;
*)
usage_error "unknown action: $ACTION"
echo "Error: Unknown action: $ACTION"
exit 1
;;
esac
else
@@ -1,154 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for issue-assign.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-assign-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-assign.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-assign.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-assign.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -i exits 2"
expect_stderr "Issue number is required" "missing -i message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -i -a -l -m --issue --assignee --labels --milestone; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -i 5 -a --help
expect_rc 2 "short flag value rejected" -i 5 -a -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -a; do
rc=0
run_wrapper_sandboxed -i 5 "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
# 6b. Provider-exit normalization (codex PR #1464): a provider stub exiting
# 2 (its own usage-error status) must surface as wrapper exit 1, never 2.
GH_REPO="$WORK_DIR/repo-gh"
mkdir -p "$GH_REPO"
git -C "$GH_REPO" init -q
git -C "$GH_REPO" remote add origin https://github.com/acme/widgets.git
cat > "$BIN_DIR/gh" <<GHSTUB
#!/usr/bin/env bash
echo "gh \$*" >> "$PROBE_LOG"
if [[ "\$1 \$2" == "issue edit" ]]; then exit 2; fi
exit 0
GHSTUB
chmod +x "$BIN_DIR/gh"
rc=0
(
cd "$GH_REPO"
PATH="$BIN_DIR:$PATH" MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-assign.sh" -i 5 -a someone >"$OUT_FILE" 2>"$ERR_FILE"
) || rc=$?
[[ "$rc" -eq 1 ]] || fail "GitHub path: gh exit 2 must normalize to wrapper exit 1 (got $rc)"
grep -q "provider" "$ERR_FILE" || fail "GitHub path: normalized provider error missing from stderr"
echo "issue-assign.sh usage-contract regression passed (R1/R4)"
@@ -1,136 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for issue-close.sh (R1/R4, 2026-08-28).
#
# R4: usage errors print to STDERR and exit 2, distinct from provider,
# credential, and verification failures (exit 1). R1: -b/--body is the
# canonical comment flag; -c/--comment remains a compatible alias.
# The comment is OPTIONAL here (an issue may close without one), so unlike
# issue-comment there is no missing-comment arm.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-close-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 0
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-close.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-close.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-close.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "unknown option" "unknown option names itself on stderr"
# 3. Missing required issue number: rc 2, stderr.
expect_rc 2 "missing -i exits 2"
expect_stderr "issue number is required" "missing -i message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -i -b -c --issue --body --comment; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -i 5 -b --help
expect_rc 2 "short flag value rejected" -i 5 -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b -c; do
rc=0
run_wrapper_sandboxed -i 5 "$flag" "closing note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Sandbox arms may issue DETECTION reads only (tea login list via the
# stub); no gh/curl write or read may occur.
if grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" | grep -q .; then
echo "FAIL: a sandbox arm performed a non-detection provider request:" >&2
grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" >&2
exit 1
fi
if grep -qE '^(gh|curl)' "$PROBE_LOG"; then
echo "FAIL: gh or curl was invoked during a sandbox arm:" >&2
grep -E '^(gh|curl)' "$PROBE_LOG" >&2
exit 1
fi
echo "issue-close.sh usage-contract regression passed (R1/R4)"
@@ -42,8 +42,6 @@
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
# success or the failure path — nested function-scoped RETURN traps do not
# clobber each other and every scratch file is removed on all exit paths.
# 11. accepts the canonical -b/--body flag exactly like the -c/--comment alias
# (R1, 2026-08-28): a full verified write via -b alone.
set -euo pipefail
@@ -411,28 +409,11 @@ run_comment() {
seed_state "$mode"
(
cd "$REPO_DIR"
# Provisioned seats export MOSAIC_GIT_IDENTITY and MOSAIC_BRAIN_HOME
# seat-wide (launcher), and both escape this harness's sandboxed HOME:
# detect-platform.sh consults MOSAIC_GIT_IDENTITY BEFORE the repo-local
# mosaic.gitIdentity pin, and resolves the brain home (whose
# fleet/agents presence arms the no-identity fail-loud branch) from
# MOSAIC_BRAIN_HOME before $HOME. Without these explicit empties the
# wrapper either resolves the REAL seat-slot token (stub curl rejects
# it: the documented HTTP 401) or fails loud before any request.
# Set-but-empty reads as unset to detect-platform's "${VAR:-}" forms.
# NOTE: keep this comment block ABOVE the assignment chain — a comment
# inside a backslash-continued prefix chain terminates the command and
# silently demotes every earlier assignment to an unexported subshell
# assignment (measured 2026-08-28: the wrapper then ran without
# MOSAIC_CREDENTIALS_FILE and the suite died at credential resolution
# with zero diagnostic output).
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
HOME="$HOME_DIR" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_GIT_IDENTITY="" \
MOSAIC_BRAIN_HOME="" \
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
@@ -449,7 +430,7 @@ run_comment() {
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
ISSUE_COMMENT_API_BASE="$API_BASE" \
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" "${BODY_FLAG:--c}" "$BODY" "$@"
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" "$@"
) > "$OUTPUT_FILE" 2>&1
}
@@ -633,21 +614,4 @@ done
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
# is not rejecting genuine writes.
# Case 11 (R1, 2026-08-28): -b/--body is the canonical comment flag and must
# drive a full verified write exactly like the -c/--comment alias. BODY_FLAG
# swaps only the flag spelling; every assertion below is case 1's contract.
BODY_FLAG="-b"
run_comment fresh-success
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
echo "FAIL: --body write went through tea instead of REST" >&2
exit 1
fi
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
assert_no_temp_leak "fresh-success-body-flag"
assert_token_not_in_argv "fresh-success-body-flag"
unset BODY_FLAG
echo "issue-comment.sh REST create + exact-id read-back regression passed"
@@ -1,172 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for issue-comment.sh (R1/R4 remediation, 2026-08-28).
#
# R4: usage errors print to STDERR and exit 2, distinct from provider,
# credential, and verification failures (exit 1), so a caller (or a stop gate)
# can tell an invocation defect from a delivery blocker. Before this contract
# the wrapper exited 1 for usage errors with messages on STDOUT, and a
# value-less flag (-c with no value) died SILENTLY at rc=1 because set -e
# killed the failed `shift 2`. That silent shape is what full-stopped a fleet
# seat: a caller could not distinguish "I invoked it wrong" from "delivery is
# blocked".
#
# R1: -b/--body is the canonical comment flag (matching issue-create,
# issue-edit, pr-create, pr-edit); -c/--comment remains a backward-compatible
# alias.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. Missing required comment exits 2 (stderr).
# 5. A value-less flag (-i -b -c -l and long forms) exits 2 with a
# "requires a value" message on stderr (the former silent-death class).
# 6. -b and -c both pass parsing (the run then fails at platform detection
# in this non-repo fixture, nonzero and NOT 2), proving alias acceptance
# without any provider fixture.
# 7. No arm performs any provider request: PATH shims for gh/tea/curl
# record every invocation and the probe log must stay empty.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Provider shims: any invocation is recorded and fails the run at the end.
# Usage-error arms must exit during argument parsing, before detect_platform,
# so these prove "no provider request on parser failure".
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
# gh doubles as platform probe AND write path in arm 6b: probes exit 0; the
# comment write exits 2 (gh's own usage-error status) to prove the wrapper
# normalizes provider failures to exit 1 instead of propagating 2.
if [[ "\$1 \$2" == "issue comment" ]]; then exit 2; fi
exit 0
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-comment.sh" "$@" )
}
# Hermetic variant for parse-acceptance arms: neutralizes every identity/
# credential source the wrapper consults (seat env vars, HOME, XDG tea config)
# so the arm fails at credential resolution in ANY cwd repo, never reading a
# real token or contacting a provider. Measured 2026-08-28: without this, the
# arm's outcome depended on incidental URL-resolution state (brain cwd died at
# URL-not-found; a stack worktree cwd resolved a configured URL, read the real
# seat token, and invoked the curl stub — the suite then failed its own
# no-provider-contact check, correctly).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-comment.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage on stdout.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-comment.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, message on stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "unknown option" "unknown option names itself on stderr"
# 3. Missing required issue number: rc 2, stderr.
expect_rc 2 "missing -i exits 2"
expect_stderr "issue number is required" "missing -i message on stderr"
# 4. Missing required comment: rc 2, stderr.
expect_rc 2 "missing comment exits 2" -i 5
expect_stderr "comment is required" "missing comment message on stderr"
# 5. Value-less flags: rc 2 with "requires a value" on stderr. The old parser
# died here silently (set -e on the failed shift 2).
for flag in -i -b -c -l --issue --body --comment --login; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -i 5 -b --help
expect_rc 2 "short flag value rejected" -i 5 -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 6. Alias acceptance at parse level: both -b and -c carry a value past
# parsing; the wrapper then fails at platform detection (not a git repo)
# nonzero but NOT as a usage error (rc must not be 2).
for flag in -b -c; do
rc=0
run_wrapper_sandboxed -i 5 "$flag" "some text" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6b. GitHub-path exit normalization (codex blocker on 08a00149): gh's own
# usage errors exit 2; the wrapper must NOT propagate that status (reserved
# for the wrapper's usage-error contract). With a github remote and a gh stub
# whose comment write exits 2, the wrapper must exit 1 with the normalized
# error on stderr.
GH_REPO="$WORK_DIR/repo-gh"
mkdir -p "$GH_REPO"
git -C "$GH_REPO" init -q
git -C "$GH_REPO" remote add origin https://github.com/acme/widgets.git
git -C "$GH_REPO" config mosaic.gitIdentity ""
rc=0
(
cd "$GH_REPO"
PATH="$BIN_DIR:$PATH" MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-comment.sh" -i 5 -b "text" >"$OUT_FILE" 2>"$ERR_FILE"
) || rc=$?
[[ "$rc" -eq 1 ]] || fail "GitHub path: gh exit 2 must normalize to wrapper exit 1 (got $rc)"
grep -q "GitHub comment write failed" "$ERR_FILE" || fail "GitHub path: normalized error missing from stderr"
grep -q "^gh issue comment" "$PROBE_LOG" || fail "GitHub path: gh write was not invoked"
# 7. No provider contact from any usage-error arm (arm 6b's deliberate gh
# invocation is the only permitted entry in the probe log).
if grep -v '^gh issue comment' "$PROBE_LOG" | grep -q .; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
grep -v '^gh issue comment' "$PROBE_LOG" >&2
exit 1
fi
echo "issue-comment.sh usage-contract regression passed (R1/R4)"
@@ -1,132 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for issue-create.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-create.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-create.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-create.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -t exits 2"
expect_stderr "Title is required" "missing -t message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -t -b -l -m --title --body --labels --milestone; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -t smoke -b --help
expect_rc 2 "short flag value rejected" -t smoke -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b; do
rc=0
run_wrapper_sandboxed -t "smoke" "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "issue-create.sh usage-contract regression passed (R1/R4)"
@@ -1,132 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for issue-edit.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-edit-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-edit.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-edit.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-edit.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "unknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -i exits 2"
expect_stderr "issue number is required" "missing -i message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -i -t -b -l -m --issue --title --body --labels --milestone; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -i 5 -b --help
expect_rc 2 "short flag value rejected" -i 5 -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b; do
rc=0
run_wrapper_sandboxed -i 5 "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "issue-edit.sh usage-contract regression passed (R1/R4)"
@@ -1,130 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for issue-list.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-list-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-list.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-list.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-list.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -s -l -m -a -n -r --state --label --milestone --assignee --limit --repo; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -s --help
expect_rc 2 "short flag value rejected" -s -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -s; do
rc=0
run_wrapper_sandboxed -s open >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "issue-list.sh usage-contract regression passed (R1/R4)"
@@ -1,136 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for issue-reopen.sh (R1/R4, 2026-08-28).
#
# R4: usage errors print to STDERR and exit 2, distinct from provider,
# credential, and verification failures (exit 1). R1: -b/--body is the
# canonical comment flag; -c/--comment remains a compatible alias.
# The comment is OPTIONAL here (an issue may close without one), so unlike
# issue-comment there is no missing-comment arm.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-reopen-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 0
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-reopen.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-reopen.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-reopen.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "unknown option" "unknown option names itself on stderr"
# 3. Missing required issue number: rc 2, stderr.
expect_rc 2 "missing -i exits 2"
expect_stderr "issue number is required" "missing -i message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -i -b -c --issue --body --comment; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -i 5 -b --help
expect_rc 2 "short flag value rejected" -i 5 -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b -c; do
rc=0
run_wrapper_sandboxed -i 5 "$flag" "closing note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Sandbox arms may issue DETECTION reads only (tea login list via the
# stub); no gh/curl write or read may occur.
if grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" | grep -q .; then
echo "FAIL: a sandbox arm performed a non-detection provider request:" >&2
grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" >&2
exit 1
fi
if grep -qE '^(gh|curl)' "$PROBE_LOG"; then
echo "FAIL: gh or curl was invoked during a sandbox arm:" >&2
grep -E '^(gh|curl)' "$PROBE_LOG" >&2
exit 1
fi
echo "issue-reopen.sh usage-contract regression passed (R1/R4)"
@@ -1,132 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for issue-view.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-view-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-view.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/issue-view.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: issue-view.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -i exits 2"
expect_stderr "Issue number is required" "missing -i message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -i --issue; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -i --help
expect_rc 2 "short flag value rejected" -i -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -i; do
rc=0
run_wrapper_sandboxed -i 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "issue-view.sh usage-contract regression passed (R1/R4)"
View File
@@ -1,132 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for lane-brief.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/lane-brief-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/lane-brief.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/lane-brief.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "owner/repo" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -r exits 2"
expect_stderr "required" "missing -r message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -r -m -l -L -n --repo --milestone --label --login --limit; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -r owner/repo -m --help
expect_rc 2 "short flag value rejected" -r owner/repo -m -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -r; do
rc=0
run_wrapper_sandboxed -r owner/repo >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "lane-brief.sh usage-contract regression passed (R1/R4)"
@@ -1,132 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for milestone-close.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/milestone-close-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/milestone-close.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/milestone-close.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: milestone-close.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -t exits 2"
expect_stderr "Milestone title is required" "missing -t message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -t --title; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -t --help --help
expect_rc 2 "short flag value rejected" -t --help -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -t; do
rc=0
run_wrapper_sandboxed -t "smoke" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "milestone-close.sh usage-contract regression passed (R1/R4)"
@@ -1,132 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for milestone-create.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/milestone-create-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/milestone-create.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/milestone-create.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: milestone-create.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -t exits 2"
expect_stderr "Title is required" "missing -t message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -t -d --due --title --desc; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -t smoke -d --help
expect_rc 2 "short flag value rejected" -t smoke -d -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -t; do
rc=0
run_wrapper_sandboxed -t "smoke" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "milestone-create.sh usage-contract regression passed (R1/R4)"
@@ -1,153 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for milestone-list.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/milestone-list-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/milestone-list.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/milestone-list.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: milestone-list.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -s --state; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -s --help
expect_rc 2 "short flag value rejected" -s -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -s; do
rc=0
run_wrapper_sandboxed -s open >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
# 6b. Provider-exit normalization (codex PR #1464): a provider stub exiting
# 2 (its own usage-error status) must surface as wrapper exit 1, never 2.
GH_REPO="$WORK_DIR/repo-gh"
mkdir -p "$GH_REPO"
git -C "$GH_REPO" init -q
git -C "$GH_REPO" remote add origin https://github.com/acme/widgets.git
cat > "$BIN_DIR/gh" <<GHSTUB
#!/usr/bin/env bash
echo "gh \$*" >> "$PROBE_LOG"
if [[ "\$1" == "api" ]]; then exit 2; fi
exit 0
GHSTUB
chmod +x "$BIN_DIR/gh"
rc=0
(
cd "$GH_REPO"
PATH="$BIN_DIR:$PATH" MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/milestone-list.sh" >"$OUT_FILE" 2>"$ERR_FILE"
) || rc=$?
[[ "$rc" -eq 1 ]] || fail "GitHub path: gh exit 2 must normalize to wrapper exit 1 (got $rc)"
grep -q "provider" "$ERR_FILE" || fail "GitHub path: normalized provider error missing from stderr"
echo "milestone-list.sh usage-contract regression passed (R1/R4)"
@@ -1,133 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for pr-close.sh (R1/R4, 2026-08-28).
#
# R4: usage errors print to STDERR and exit 2, distinct from provider,
# credential, and verification failures (exit 1). R1: -b/--body is the
# canonical comment flag; -c/--comment remains a compatible alias.
# The comment is OPTIONAL here (an issue may close without one), so unlike
# issue-comment there is no missing-comment arm.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-close-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-close.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-close.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-close.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "unknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -n exits 2"
expect_stderr "PR number is required" "missing -n message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -n -b -c --number --body --comment; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -n 5 -b --help
expect_rc 2 "short flag value rejected" -n 5 -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b -c; do
rc=0
run_wrapper_sandboxed -n 5 "$flag" "closing note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-close.sh usage-contract regression passed (R1/R4)"
@@ -1,132 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for pr-create.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-create-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-create.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-create.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-create.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -t exits 2"
expect_stderr "Title is required" "missing -t message on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -t -b -B -H -l -m -i --title --body --base --head --labels --milestone --issue; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -t smoke -b --help
expect_rc 2 "short flag value rejected" -t smoke -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b; do
rc=0
run_wrapper_sandboxed -t "smoke" "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-create.sh usage-contract regression passed (R1/R4)"
@@ -1,140 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for pr-edit.sh (R4, 2026-08-28).
#
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
# contract, value checks, and the no-provider-contact proof. Required: -i.
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-edit-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-edit.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-edit.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-edit.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -n exits 2"
expect_stderr "Pull request number is required" "missing -n message on stderr"
expect_rc 2 "no edit option exits 2" -n 5
expect_stderr "At least one edit option is required" "no-edit-option message on stderr"
expect_rc 2 "non-integer PR number exits 2" -n abc -t x
expect_stderr "positive integer" "integer check on stderr"
expect_rc 2 "mutually exclusive draft/ready exits 2" -n 5 --draft --ready
expect_stderr "mutually exclusive" "mutual exclusion on stderr"
expect_rc 2 "bad repo format exits 2" -n 5 -t x -r not-a-slug
expect_stderr "OWNER/REPO" "repo format on stderr"
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -n -t -b -B -l -r -H --number --title --body --base --login --repo --host; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -n 5 -t smoke -b --help
expect_rc 2 "short flag value rejected" -n 5 -t smoke -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b; do
rc=0
run_wrapper_sandboxed -n 5 "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-edit.sh usage-contract regression passed (R1/R4)"
View File
@@ -218,7 +218,7 @@ if grep -q 'Unknown option' "$OUTPUT_FILE"; then
cat "$OUTPUT_FILE" >&2
exit 1
fi
grep -q "unknown action 'bogus-action'" "$OUTPUT_FILE"
grep -q 'Unknown action: bogus-action' "$OUTPUT_FILE"
# --- Case 2: -h/--help documents both overrides.
HELP_TEXT="$("$SCRIPT_DIR/pr-review.sh" -h)"
@@ -1,148 +0,0 @@
#!/usr/bin/env bash
# Usage-error contract for pr-review.sh (R1/R4, 2026-08-28).
#
# R4: usage errors print to STDERR and exit 2, distinct from provider,
# credential, and verification failures (exit 1). R1: -b/--body is the
# canonical comment flag; -c/--comment remains a compatible alias.
# Required: -n AND -a. The comment is required only for the
# request-changes action (semantic usage check, also rc 2).
#
# Arms:
# 1. --help and -h exit 0 and print usage.
# 2. Unknown option exits 2 with the message on stderr.
# 3. Missing required -i exits 2 (stderr).
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
# at credential resolution, nonzero and NOT 2) — no real token is
# ever read and no provider is contacted.
# 6. No arm performs any provider request (PATH shims record every
# invocation; the probe log must stay empty).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-usage}"
BIN_DIR="$WORK_DIR/bin"
PROBE_LOG="$WORK_DIR/provider-probes.log"
OUT_FILE="$WORK_DIR/out.log"
ERR_FILE="$WORK_DIR/err.log"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$BIN_DIR"
: > "$PROBE_LOG"
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
# API fallback that treats a successful curl as a closed PR, so exit-0
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
for tool in gh tea curl; do
cat > "$BIN_DIR/$tool" <<STUB
#!/usr/bin/env bash
echo "$tool \$*" >> "$PROBE_LOG"
exit 99
STUB
chmod +x "$BIN_DIR/$tool"
done
run_wrapper() {
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-review.sh" "$@" )
}
# Hermetic variant: neutralizes every identity/credential source the wrapper
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
run_wrapper_sandboxed() {
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
(
cd "$WORK_DIR"
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
"$SCRIPT_DIR/pr-review.sh" "$@"
)
}
fail() {
echo "FAIL: $*" >&2
echo "--- stderr ---" >&2
cat "$ERR_FILE" >&2
exit 1
}
expect_rc() { # expect_rc <want> <desc> <args...>
local want="$1" desc="$2" rc=0
shift 2
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
}
expect_stderr() { # expect_stderr <pattern> <desc>
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
}
# 1. Help exits 0 and prints usage.
expect_rc 0 "--help exits 0" --help
grep -q "Usage: pr-review.sh" "$OUT_FILE" || fail "--help did not print usage"
expect_rc 0 "-h exits 0" -h
# 2. Unknown option: rc 2, stderr.
expect_rc 2 "unknown option exits 2" --bogus
expect_stderr "unknown option" "unknown option names itself on stderr"
# 3. Missing required PR number: rc 2, stderr.
expect_rc 2 "missing -n exits 2"
expect_stderr "PR number is required" "missing -n message on stderr"
expect_rc 2 "missing -a exits 2" -n 5
expect_stderr "Action is required" "missing -a message on stderr"
expect_rc 2 "request-changes without comment exits 2" -n 5 -a request-changes
expect_stderr "comment required for request-changes" "request-changes message on stderr"
expect_rc 2 "comment without body exits 2 pre-detection" -n 5 -a comment
expect_stderr "comment required for comment" "comment-without-body message on stderr"
expect_rc 2 "invalid action exits 2 pre-detection" -n 5 -a bogus
expect_stderr "unknown action" "invalid action message on stderr"
# Invalid-action arms must not contact any provider (validation precedes
# detect_platform): probe log empty at this point.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: an invalid-action arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
for flag in -n -a -b -c -l -r --number --action --body --comment --login --repo; do
expect_rc 2 "value-less $flag exits 2" "$flag"
expect_stderr "requires a value" "value-less $flag message on stderr"
done
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
# -b --help previously consumed --help as the body and performed the write).
expect_rc 2 "option-like value rejected" -n 5 -a comment -b --help
expect_rc 2 "short flag value rejected" -n 5 -a comment -b -h
expect_stderr "requires a value" "short flag value message on stderr"
expect_stderr "requires a value" "option-like value message on stderr"
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
if [[ -s "$PROBE_LOG" ]]; then
echo "FAIL: a parser-failure arm contacted a provider:" >&2
cat "$PROBE_LOG" >&2
exit 1
fi
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
# parsing; the run fails at credential resolution nonzero and NOT 2.
for flag in -b -c; do
rc=0
run_wrapper_sandboxed -n 5 -a comment "$flag" "review note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
done
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
# comment parses, then falls back to the API. Hermeticity for this
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
# the arm above proves only parse acceptance and non-usage classification.
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
echo "pr-review.sh usage-contract regression passed (R1/R4)"
@@ -14,6 +14,7 @@
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh | resolves real credentials (#1007 census, fifth entry); joins CI after the wrapper-half hermeticity fix
# --- tools/git: push guards — measured green locally, CI-image fitness unverified ---
packages/mosaic/framework/tools/git/test-push-guard.sh | measured green at 826a8b3b (46 passed / 0 failed, one run, 2026-07-31); CI-image fitness unverified; #1017 burndown
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-issue-comment-usage-contract.sh && bash framework/tools/git/test-issue-comment-readback.sh && bash framework/tools/git/test-issue-close-usage-contract.sh && bash framework/tools/git/test-issue-reopen-usage-contract.sh && bash framework/tools/git/test-pr-close-usage-contract.sh && bash framework/tools/git/test-pr-review-usage-contract.sh && bash framework/tools/git/test-issue-edit-usage-contract.sh && bash framework/tools/git/test-issue-create-usage-contract.sh && bash framework/tools/git/test-pr-edit-usage-contract.sh && bash framework/tools/git/test-pr-create-usage-contract.sh && bash framework/tools/git/test-issue-assign-usage-contract.sh && bash framework/tools/git/test-milestone-close-usage-contract.sh && bash framework/tools/git/test-milestone-list-usage-contract.sh && bash framework/tools/git/test-issue-view-usage-contract.sh && bash framework/tools/git/test-issue-list-usage-contract.sh && bash framework/tools/git/test-milestone-create-usage-contract.sh && bash framework/tools/git/test-lane-brief-usage-contract.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -75,7 +75,7 @@ def run_pi_registry_command(
runner=subprocess.run,
sleeper=time.sleep,
) -> subprocess.CompletedProcess[str]:
"""Run a Pi probe command with bounded retries for concurrent-Pi stalls."""
"""Run the registry probe with bounded retries for concurrent-Pi stalls."""
for attempt in range(1, PI_PROBE_ATTEMPTS + 1):
try:
@@ -106,7 +106,13 @@ def probe_pi_registry() -> list[dict[str, object]]:
if pi is None:
raise AssertionError("installed Pi runtime is required for Invariant R")
version = run_pi_registry_command([pi, "--version"], dict(os.environ))
version = subprocess.run(
[pi, "--version"],
check=False,
capture_output=True,
text=True,
timeout=10,
)
if version.returncode != 0:
raise AssertionError(f"Pi version probe failed: {version.stderr.strip()}")
if version.stdout.strip() != PI_VERSION:
+5 -5
View File
@@ -21,10 +21,10 @@
// lint | lint | pnpm lint
// format | format | pnpm format:check
// test | test | pnpm test
// build | build (#1445, P6) | pnpm build (also publish.yml build)
// build | publish.yml build | pnpm build
// quality-rails | (canonical-only) | the TS quality-rails evaluator
// | | (RI-N4, QC-19 monorepo subject).
// | | The one stage with no ci.yml
// | | (RI-N4, QC-19 monorepo subject). Like
// | | `build`, this stage has no ci.yml
// | | mirror; it is implemented by
// | | importing the evaluator CLI rather
// | | than duplicating its presence logic.
@@ -106,8 +106,8 @@ export const STAGES = [
{
// RI-N4 (QC-19, card RI-3-002): the typed quality-rails evaluator, invoked
// as the implementation of the check it owns instead of a duplicated
// presence loop here. Canonical-only stage (no ci.yml mirror; `build`
// gained one in #1445); runs AFTER build so the evaluator's dist/ exists. Subject
// presence loop here. Canonical-only stage (no ci.yml mirror — same shape
// as `build`); runs AFTER build so the evaluator's dist/ exists. Subject
// is this repository (`.` → monorepo subject kind, per-subject check set).
name: 'quality-rails',
commands: ['node packages/quality-rails/dist/cli.js quality-rails evaluate --project .'],
+4 -5
View File
@@ -228,10 +228,9 @@ steps:
function assertStagesMirrorCi(stages, ci) {
const canonical = Object.fromEntries(stages.map((stage) => [stage.name, stage.commands]));
// The complete mandatory set, in gate order. `quality-rails` is the one
// canonical-only stage (RI-N4, QC-19) with no ci.yml mirror to match — its
// contract is asserted separately below. `build` gained a ci.yml mirror in
// #1445 (P6) and is enforced with the other pnpm stages.
// The complete mandatory set, in gate order. `quality-rails` is a
// canonical-only stage (RI-N4, QC-19): like `build`, it has no ci.yml
// mirror to match — its contract is asserted separately below.
assert.deepEqual(
stages.map((stage) => stage.name),
[
@@ -258,7 +257,7 @@ function assertStagesMirrorCi(stages, ci) {
// pnpm stages: ci.yml commands minus `corepack enable` must be exactly the
// canonical stage commands.
for (const stepName of ['typecheck', 'lint', 'format', 'build']) {
for (const stepName of ['typecheck', 'lint', 'format']) {
assert.deepEqual(
ci.steps[stepName].commands.filter((command) => command !== 'corepack enable'),
canonical[stepName],