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
192 changed files with 1719 additions and 46172 deletions
+155 -15
View File
@@ -1,16 +1,156 @@
# Mosaic Stack standalone deployment (compose `stack` profile)
# Copy to .env and adjust. Port overrides exist because the defaults
# collide with common host services (and with the dev compose itself).
PG_HOST_PORT=5433
VALKEY_HOST_PORT=6380
GATEWAY_HOST_PORT=14242
# Registry image override (defaults to a local build of docker/gateway.Dockerfile):
# GATEWAY_IMAGE=git.mosaicstack.dev/mosaicstack/stack/gateway:sha-acf640d
# ─────────────────────────────────────────────────────────────────────────────
# Mosaic — Environment Variables Reference
# Copy this file to .env and fill in the values for your deployment.
# Lines beginning with # are comments; optional vars are commented out.
# ─────────────────────────────────────────────────────────────────────────────
# Optional explicit dogfood overlay (docker-compose.dogfood.yml).
# All three paths are required when that overlay is used. Use a dedicated
# next-based worktree, its canonical clone's .git directory, and the external
# home of the unprivileged code-dogfood-01 functional seat.
# MOSAIC_DOGFOOD_WORKTREE=/home/example/src/mosaic-stack-worktrees/dogfood-1487
# MOSAIC_DOGFOOD_COMMON_GIT_DIR=/home/example/src/mosaic-stack/.git
# MOSAIC_DOGFOOD_SEAT_HOME=/home/example/.mosaic/fleet/agents/code-dogfood-01
# ─── Database (PostgreSQL 17 + pgvector) ─────────────────────────────────────
# Full connection string used by the gateway, ORM, and migration runner.
# Port 5433 avoids conflict with a host-side PostgreSQL instance.
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
# Docker Compose host-port override for the PostgreSQL container (default: 5433)
# PG_HOST_PORT=5433
# ─── Queue (Valkey 8 / Redis-compatible) ─────────────────────────────────────
# Port 6380 avoids conflict with a host-side Redis/Valkey instance.
VALKEY_URL=redis://localhost:6380
# Docker Compose host-port override for the Valkey container (default: 6380)
# VALKEY_HOST_PORT=6380
# ─── Gateway ─────────────────────────────────────────────────────────────────
# TCP port the NestJS/Fastify gateway listens on (default: 14242)
GATEWAY_PORT=14242
# Comma-separated list of allowed CORS origins.
# Must include the web app origin in production.
GATEWAY_CORS_ORIGIN=http://localhost:3000
# ─── Auth (BetterAuth) ───────────────────────────────────────────────────────
# REQUIRED — random secret used to sign sessions and tokens.
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
# Public base URL of the gateway (used by BetterAuth for callback URLs)
BETTER_AUTH_URL=http://localhost:14242
# ─── Web App (SPA) ───────────────────────────────────────────────────────────
# Directory holding the built SPA bundle (vite build output). When set, the
# gateway serves the SPA same-origin; when unset (dev), run the Vite dev
# server (pnpm --filter @mosaicstack/web dev), which proxies to the gateway.
# safe-default: unset in dev — SPA serving is an opt-in production concern
#WEB_DIST_DIR=apps/web/dist
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
# OTLP HTTP endpoint (otel-collector or any OpenTelemetry-compatible backend)
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# Service name shown in traces
OTEL_SERVICE_NAME=mosaic-gateway
# ─── AI Providers ────────────────────────────────────────────────────────────
# Ollama (local models — set OLLAMA_BASE_URL to enable)
# OLLAMA_BASE_URL=http://localhost:11434
# OLLAMA_HOST is a legacy alias for OLLAMA_BASE_URL
# OLLAMA_HOST=http://localhost:11434
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
# OLLAMA_MODELS=llama3.2,codellama,mistral
# Anthropic (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5)
# ANTHROPIC_API_KEY=sk-ant-...
# OpenAI (gpt-4o, gpt-4o-mini, o3-mini)
# OPENAI_API_KEY=sk-...
# Z.ai / GLM (glm-4.5, glm-4.5-air, glm-4.5-flash)
# ZAI_API_KEY=...
# Custom providers — JSON array of provider configs
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
# MOSAIC_CUSTOM_PROVIDERS=
# ─── Embedding Service ───────────────────────────────────────────────────────
# OpenAI-compatible embeddings endpoint (default: OpenAI)
# EMBEDDING_API_URL=https://api.openai.com/v1
# EMBEDDING_MODEL=text-embedding-3-small
# ─── Log Summarization Service ───────────────────────────────────────────────
# OpenAI-compatible chat completions endpoint for log summarization (default: OpenAI)
# SUMMARIZATION_API_URL=https://api.openai.com/v1
# SUMMARIZATION_MODEL=gpt-4o-mini
# Cron schedule for summarization job (default: every 6 hours)
# SUMMARIZATION_CRON=0 */6 * * *
# Cron schedule for log tier management (default: daily at 03:00)
# TIER_MANAGEMENT_CRON=0 3 * * *
# ─── Agent ───────────────────────────────────────────────────────────────────
# Filesystem sandbox root for agent file tools (default: process.cwd())
# AGENT_FILE_SANDBOX_DIR=/var/lib/mosaic/sandbox
# Comma-separated list of tool names available to non-admin users.
# Leave unset to allow all tools for all authenticated users.
# AGENT_USER_TOOLS=read_file,list_directory,search_files
# System prompt injected into every agent session (optional)
# AGENT_SYSTEM_PROMPT=You are a helpful assistant.
# ─── MCP Servers ─────────────────────────────────────────────────────────────
# JSON array of MCP server configs — set to enable MCP tool integration.
# Each entry: {"name":"<id>","url":"<http-or-sse-url>"}
# MCP_SERVERS=[{"name":"my-mcp","url":"http://localhost:3100/sse"}]
# ─── Coordinator ─────────────────────────────────────────────────────────────
# Root directory used to scope coordinator (worktree/repo) operations.
# Defaults to the monorepo root auto-detected from process.cwd().
# MOSAIC_WORKSPACE_ROOT=/home/user/projects/mosaic
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
# DISCORD_BOT_TOKEN=
# DISCORD_GUILD_ID=
# DISCORD_GATEWAY_URL=http://localhost:14242
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
# TELEGRAM_BOT_TOKEN=
# TELEGRAM_GATEWAY_URL=http://localhost:14242
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
# AUTHENTIK_CLIENT_ID=
# AUTHENTIK_CLIENT_SECRET=
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
# WORKOS_ISSUER=https://your-company.authkit.app
# WORKOS_CLIENT_ID=client_...
# WORKOS_CLIENT_SECRET=sk_live_...
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
# KEYCLOAK_ISSUER=https://auth.example.com/realms/master
# Legacy alternative if you prefer to compose the issuer from separate vars:
# KEYCLOAK_URL=https://auth.example.com
# KEYCLOAK_REALM=master
# KEYCLOAK_CLIENT_ID=mosaic
# KEYCLOAK_CLIENT_SECRET=
# The web login page discovers configured providers dynamically from
# GET /api/sso/providers. No NEXT_PUBLIC_* provider feature flag is required.
-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
-45
View File
@@ -208,51 +208,6 @@ mosaic telemetry upload # Dry-run unless opted in
Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`.
## Standalone container deployment
The `stack` profile runs PostgreSQL, Valkey, the gateway, and the bundled webUI. Copy
`.env.example` to `.env`, generate `BETTER_AUTH_SECRET`, then start the profile:
```bash
cp .env.example .env
printf 'BETTER_AUTH_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
docker compose --profile stack up -d
```
The optional dogfood overlay gives one dedicated in-stack agent a writable stack
worktree and its own read-only credential slot. It does not mount the fleet brain or
any other seat. Prepare a `next`-based worktree and an unprivileged
`code-dogfood-01` functional seat outside the container, then set these paths in
`.env`:
```dotenv
MOSAIC_DOGFOOD_WORKTREE=/path/to/mosaic-stack-worktrees/dogfood-1487
MOSAIC_DOGFOOD_COMMON_GIT_DIR=/path/to/mosaic-stack/.git
MOSAIC_DOGFOOD_SEAT_HOME=/path/to/.mosaic/fleet/agents/code-dogfood-01
```
The common Git directory must match the worktree's `.git` pointer. The seat home
must contain only that seat's credential at
`secrets/gitea-mosaicstack-code-dogfood-01.token`. Never place the token value in
`.env`. Start the overlay with:
```bash
docker compose \
-f docker-compose.yml \
-f docker-compose.dogfood.yml \
--profile stack up -d
```
The overlay removes the general shell tool for every session, including admins.
File tools stay inside the mounted checkout. Two dedicated delivery tools stage
explicit paths, run the CI queue guard, push through `git-credential-mosaic`, and
open PRs through `pr-create.sh`. They resolve only the `code-dogfood-01` slot and fail
if it is absent. The overlay enables Docker's init process so the R4 helper can
establish the gateway's seat lineage below PID 1.
This deployment route is separate from the local source-development restrictions
below.
## Development
### Prerequisites
@@ -1,133 +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 (contract 1 §6.3).
*
* The hierarchy command family is a CLOSED enumeration asserted here, not a
* prose claim: every hierarchy-flavored route the AppModule graph declares
* must appear in HIERARCHY_COMMAND_FAMILY, and vice versa. Adding or
* removing a hierarchy route without updating this inventory (and its
* witnesses) fails CI first. This replaces the M4-1b-i zero-routes
* baseline.
*/
interface RouteEntry {
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;
}
/**
* The closed command family (contract 1 §5, M4-1b-ii). Every entry is a
* mutation audited via the M4-1b-i path or one of the two ratified reads
* (granted companies, the §2.8 directory carve-out).
*/
const HIERARCHY_COMMAND_FAMILY = [
'POST /api/hierarchy/companies',
'GET /api/hierarchy/companies',
'GET /api/hierarchy/companies/directory',
'POST /api/hierarchy/companies/:id/rename',
'POST /api/hierarchy/companies/:id/visibility',
'DELETE /api/hierarchy/companies/:id',
'POST /api/hierarchy/estates',
'POST /api/hierarchy/estates/:id/rename',
'POST /api/hierarchy/estates/:id/transfer',
'DELETE /api/hierarchy/estates/:id',
'POST /api/hierarchy/platform-projects',
'POST /api/hierarchy/platform-projects/:id/rename',
'POST /api/hierarchy/platform-projects/:id/transfer',
'DELETE /api/hierarchy/platform-projects/:id',
'POST /api/hierarchy/grants',
'POST /api/hierarchy/grants/:id/change',
'DELETE /api/hierarchy/grants/:id',
] as const;
describe('hierarchy route inventory (§6.3)', () => {
const inventory = collectControllers(AppModule).flatMap(routesOf);
it('control: the enumeration sees the known route surface', () => {
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('the hierarchy surface is exactly the declared command family', () => {
const hierarchyRoutes = inventory
.filter((r) => /hierarch|compan|estate|platform[-_]?project/i.test(r.path))
.map((r) => `${r.method} ${r.path}`)
.sort();
expect(hierarchyRoutes).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
});
it('every command-family route lives on HierarchyController inside HierarchyModule', () => {
const controllers = collectControllers(HierarchyModule);
expect(controllers.map((c) => c.name)).toEqual(['HierarchyController']);
const declared = controllers
.flatMap(routesOf)
.map((r) => `${r.method} ${r.path}`)
.sort();
expect(declared).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
});
});
+2 -4
View File
@@ -27,11 +27,10 @@ import { McpClientService } from '../mcp-client/mcp-client.service.js';
import { SkillLoaderService } from './skill-loader.service.js';
import { createBrainTools } from './tools/brain-tools.js';
import { createCoordTools } from './tools/coord-tools.js';
import { createDeliveryTools } from './tools/delivery-tools.js';
import { createMemoryTools } from './tools/memory-tools.js';
import { createFileTools } from './tools/file-tools.js';
import { createGitTools } from './tools/git-tools.js';
import { createShellToolsIfEnabled } from './tools/shell-tools.js';
import { createShellTools } from './tools/shell-tools.js';
import { createWebTools } from './tools/web-tools.js';
import { createSearchTools } from './tools/search-tools.js';
import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
@@ -168,8 +167,7 @@ export class AgentService implements OnModuleDestroy {
),
...createFileTools(sandboxDir),
...createGitTools(sandboxDir),
...createShellToolsIfEnabled(sandboxDir),
...createDeliveryTools(sandboxDir),
...createShellTools(sandboxDir),
...createWebTools(),
...createSearchTools(),
];
@@ -1,210 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import { createFileTools } from './file-tools.js';
import { createShellTools, createShellToolsIfEnabled } from './shell-tools.js';
import {
createDeliveryTools,
type DeliveryToolEnvironment,
type ProcessResult,
type ProcessRunner,
} from './delivery-tools.js';
const tempDirs: string[] = [];
function tempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function textOf(result: unknown): string {
const typed = result as { content: Array<{ text: string }> };
return typed.content.map((item) => item.text).join('\n');
}
async function execute(tool: ToolDefinition, params: Record<string, unknown>): Promise<unknown> {
return (
tool.execute as unknown as (id: string, input: Record<string, unknown>) => Promise<unknown>
)('test-call', params);
}
function ok(stdout = ''): ProcessResult {
return { exitCode: 0, stdout, stderr: '', timedOut: false };
}
function deliveryEnv(extra: Partial<DeliveryToolEnvironment> = {}): DeliveryToolEnvironment {
return {
AGENT_DELIVERY_ENABLED: 'true',
MOSAIC_GIT_TOOLS_DIR: '/opt/mosaic/tools/git',
MOSAIC_GIT_IDENTITY: 'code-dogfood-01',
MOSAIC_AGENT_NAME: 'code-dogfood-01',
MOSAIC_BRAIN_HOME: '/opt/mosaic/brain',
MOSAIC_INTEGRATION_TRUNK: 'next',
HOME: '/home/node',
PATH: '/usr/bin:/bin',
...extra,
};
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('dogfood execution boundary', () => {
it('removes shell_exec mechanically while its first-token bypass red control stays live', async () => {
const sandbox = tempDir('mosaic-shell-boundary-');
expect(createShellToolsIfEnabled(sandbox, { AGENT_SHELL_ENABLED: 'false' })).toEqual([]);
const redControl = createShellTools(sandbox)[0]!;
const result = await execute(redControl, { command: 'env printf FIRST_TOKEN_BYPASS' });
expect(textOf(result)).toContain('FIRST_TOKEN_BYPASS');
});
it('refuses an outside-sandbox token-shaped read and proves the path guard is the enforcement', async () => {
const root = tempDir('mosaic-file-boundary-');
const sandbox = path.join(root, 'workspace', 'stack');
const token = path.join(
root,
'brain',
'fleet',
'agents',
'code-dogfood-01',
'secrets',
'gitea-mosaicstack-code-dogfood-01.token',
);
fs.mkdirSync(sandbox, { recursive: true });
fs.mkdirSync(path.dirname(token), { recursive: true });
fs.writeFileSync(token, 'OUTSIDE_SANDBOX_SENTINEL');
const read = createFileTools(sandbox).find((tool) => tool.name === 'fs_read_file')!;
const refused = await execute(read, { path: token });
expect(textOf(refused)).toContain('Path escape attempt blocked');
expect(textOf(refused)).not.toContain('OUTSIDE_SANDBOX_SENTINEL');
fs.symlinkSync(token, path.join(sandbox, 'credential.token'));
const symlinkRefused = await execute(read, { path: 'credential.token' });
expect(textOf(symlinkRefused)).toContain('Path escape attempt blocked');
expect(textOf(symlinkRefused)).not.toContain('OUTSIDE_SANDBOX_SENTINEL');
const redRead = createFileTools(root).find((tool) => tool.name === 'fs_read_file')!;
const redControl = await execute(redRead, { path: token });
expect(textOf(redControl)).toContain('OUTSIDE_SANDBOX_SENTINEL');
});
});
describe('delivery tools', () => {
it('stay absent unless explicitly enabled and reject identity mismatch', () => {
const sandbox = tempDir('mosaic-delivery-disabled-');
expect(createDeliveryTools(sandbox, {})).toEqual([]);
expect(() =>
createDeliveryTools(sandbox, deliveryEnv({ MOSAIC_AGENT_NAME: 'another-seat' })),
).toThrow('matching safe MOSAIC agent and git identities');
});
it('publishes through execFile-only git and queue operations with a scrubbed environment', async () => {
const sandbox = tempDir('mosaic-delivery-publish-');
fs.writeFileSync(path.join(sandbox, 'change.md'), 'change');
const calls: Array<{ file: string; args: readonly string[]; env: NodeJS.ProcessEnv }> = [];
const runner: ProcessRunner = async (file, args, options) => {
calls.push({ file, args, env: options.env });
if (args[0] === 'branch') return ok('feat/1487-dogfood-proof\n');
return ok();
};
const hostile = {
...deliveryEnv(),
BASH_ENV: '/tmp/injected',
'BASH_FUNC_read%%': '() { :; }',
GITEA_TOKEN: 'must-not-cross',
} as DeliveryToolEnvironment;
const publish = createDeliveryTools(sandbox, hostile, runner).find(
(tool) => tool.name === 'git_publish_branch',
)!;
const result = await execute(publish, {
issue: 1487,
paths: ['change.md'],
commitMessage: 'docs: dogfood proof (#1487)',
});
expect(textOf(result)).toBe('Published branch feat/1487-dogfood-proof as code-dogfood-01.');
expect(calls.map((call) => call.file)).toEqual([
'/usr/bin/git',
'/usr/bin/git',
'/usr/bin/git',
'/opt/mosaic/tools/git/ci-queue-wait.sh',
'/usr/bin/git',
]);
expect(calls[3]!.args).toEqual(['--purpose', 'push', '-B', 'feat/1487-dogfood-proof']);
expect(calls[4]!.args).toEqual(['push', '--set-upstream', 'origin', 'feat/1487-dogfood-proof']);
for (const call of calls) {
expect(call.file).not.toMatch(/(?:^|\/)sh$/);
expect(call.env).not.toHaveProperty('BASH_ENV');
expect(Object.keys(call.env).some((key) => key.startsWith('BASH_FUNC_'))).toBe(false);
expect(call.env).not.toHaveProperty('GITEA_TOKEN');
expect(call.env.MOSAIC_GIT_IDENTITY).toBe('code-dogfood-01');
}
});
it('opens PRs only through pr-create.sh against next', async () => {
const sandbox = tempDir('mosaic-delivery-pr-');
const calls: Array<{ file: string; args: readonly string[] }> = [];
const runner: ProcessRunner = async (file, args) => {
calls.push({ file, args });
if (args[0] === 'branch') return ok('feat/1487-dogfood-proof\n');
return ok('https://git.mosaicstack.dev/mosaicstack/stack/pulls/999\n');
};
const openPr = createDeliveryTools(sandbox, deliveryEnv(), runner).find(
(tool) => tool.name === 'git_open_pull_request',
)!;
const result = await execute(openPr, {
issue: 1487,
title: 'docs: dogfood proof',
body: 'Measured from the in-stack agent.',
});
expect(textOf(result)).toContain('/pulls/999');
expect(calls[1]!.file).toBe('/opt/mosaic/tools/git/pr-create.sh');
expect(calls[1]!.args).toEqual([
'-t',
'docs: dogfood proof',
'-b',
'Measured from the in-stack agent.',
'-B',
'next',
'-H',
'feat/1487-dogfood-proof',
'-i',
'1487',
]);
});
it('blocks publish paths outside the sandbox before staging', async () => {
const root = tempDir('mosaic-delivery-path-');
const sandbox = path.join(root, 'sandbox');
const outside = path.join(root, 'outside.md');
fs.mkdirSync(sandbox);
fs.writeFileSync(outside, 'OUTSIDE_DELIVERY_SENTINEL');
const calls: Array<{ file: string; args: readonly string[] }> = [];
const runner: ProcessRunner = async (file, args) => {
calls.push({ file, args });
return args[0] === 'branch' ? ok('feat/1487-dogfood-proof\n') : ok();
};
const publish = createDeliveryTools(sandbox, deliveryEnv(), runner).find(
(tool) => tool.name === 'git_publish_branch',
)!;
const result = await execute(publish, {
issue: 1487,
paths: [outside],
commitMessage: 'docs: must not publish',
});
expect(textOf(result)).toContain('Path escape attempt blocked');
expect(textOf(result)).not.toContain('OUTSIDE_DELIVERY_SENTINEL');
expect(calls).toHaveLength(1);
});
});
@@ -1,282 +0,0 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { guardPath, SandboxEscapeError } from './path-guard.js';
const PROCESS_TIMEOUT_MS = 120_000;
const MAX_OUTPUT_BYTES = 100 * 1024;
const SAFE_IDENTITY = /^[a-z0-9][a-z0-9-]{0,62}$/;
const SAFE_BRANCH = /^(?:feat|fix|docs|test)\/[a-z0-9][a-z0-9._/-]*$/i;
export interface ProcessResult {
exitCode: number | null;
stdout: string;
stderr: string;
timedOut: boolean;
}
export type ProcessRunner = (
file: string,
args: readonly string[],
options: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number },
) => Promise<ProcessResult>;
export interface DeliveryToolEnvironment {
AGENT_DELIVERY_ENABLED?: string;
MOSAIC_GIT_TOOLS_DIR?: string;
MOSAIC_GIT_IDENTITY?: string;
MOSAIC_AGENT_NAME?: string;
MOSAIC_BRAIN_HOME?: string;
MOSAIC_CREDENTIAL_SPOOL?: string;
MOSAIC_CREDENTIAL_LINEAGE_FENCE?: string;
MOSAIC_INTEGRATION_TRUNK?: string;
HOME?: string;
PATH?: string;
LANG?: string;
LC_ALL?: string;
}
function runProcess(
file: string,
args: readonly string[],
options: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number },
): Promise<ProcessResult> {
return new Promise((resolve) => {
const child = spawn(file, [...args], {
cwd: options.cwd,
env: options.env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let timedOut = false;
let outputBytes = 0;
const append = (current: string, chunk: Buffer): string => {
const remaining = MAX_OUTPUT_BYTES - outputBytes;
if (remaining <= 0) return current;
outputBytes += chunk.length;
return current + chunk.subarray(0, remaining).toString();
};
child.stdout.on('data', (chunk: Buffer) => {
stdout = append(stdout, chunk);
});
child.stderr.on('data', (chunk: Buffer) => {
stderr = append(stderr, chunk);
});
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
}, options.timeoutMs);
child.on('error', (error) => {
clearTimeout(timer);
resolve({ exitCode: null, stdout, stderr: `${stderr}${String(error)}`, timedOut });
});
child.on('close', (exitCode) => {
clearTimeout(timer);
resolve({ exitCode, stdout, stderr, timedOut });
});
});
}
function cleanEnvironment(env: DeliveryToolEnvironment): NodeJS.ProcessEnv {
const clean: NodeJS.ProcessEnv = {
GIT_TERMINAL_PROMPT: '0',
};
for (const key of [
'HOME',
'PATH',
'LANG',
'LC_ALL',
'MOSAIC_GIT_IDENTITY',
'MOSAIC_AGENT_NAME',
'MOSAIC_BRAIN_HOME',
'MOSAIC_CREDENTIAL_SPOOL',
'MOSAIC_CREDENTIAL_LINEAGE_FENCE',
] as const) {
const value = env[key];
if (value !== undefined) clean[key] = value;
}
return clean;
}
function textResult(text: string): {
content: Array<{ type: 'text'; text: string }>;
details: undefined;
} {
return { content: [{ type: 'text', text }], details: undefined };
}
function describeFailure(label: string, result: ProcessResult): string {
if (result.timedOut) return `${label} timed out`;
const diagnostic = result.stderr.trim() || result.stdout.trim() || 'no diagnostic output';
return `${label} failed (exit ${result.exitCode ?? 'null'}): ${diagnostic}`;
}
function currentBranchPattern(issue: number): RegExp {
return new RegExp(`^(?:feat|fix|docs|test)/${issue}(?:[-/].+)$`, 'i');
}
export function createDeliveryTools(
sandboxDir: string,
sourceEnv: DeliveryToolEnvironment = process.env,
runner: ProcessRunner = runProcess,
): ToolDefinition[] {
if (sourceEnv.AGENT_DELIVERY_ENABLED !== 'true') return [];
const identity = sourceEnv.MOSAIC_GIT_IDENTITY ?? '';
const agentName = sourceEnv.MOSAIC_AGENT_NAME ?? '';
const toolsDir = sourceEnv.MOSAIC_GIT_TOOLS_DIR ?? '';
const baseBranch = sourceEnv.MOSAIC_INTEGRATION_TRUNK ?? 'next';
if (!SAFE_IDENTITY.test(identity) || identity !== agentName) {
throw new Error('Delivery tools require matching safe MOSAIC agent and git identities');
}
if (!path.isAbsolute(toolsDir)) {
throw new Error('Delivery tools require an absolute MOSAIC_GIT_TOOLS_DIR');
}
if (!SAFE_BRANCH.test(`feat/${baseBranch}`) || baseBranch.includes('/')) {
throw new Error('Delivery tools require a safe integration branch name');
}
const env = cleanEnvironment(sourceEnv);
const queueGuard = path.join(toolsDir, 'ci-queue-wait.sh');
const prCreate = path.join(toolsDir, 'pr-create.sh');
const run = (file: string, args: readonly string[], timeoutMs = PROCESS_TIMEOUT_MS) =>
runner(file, args, { cwd: sandboxDir, env, timeoutMs });
const readBranch = async (): Promise<{ branch?: string; error?: string }> => {
const result = await run('/usr/bin/git', ['branch', '--show-current'], 15_000);
if (result.exitCode !== 0) return { error: describeFailure('git branch', result) };
const branch = result.stdout.trim();
if (!SAFE_BRANCH.test(branch))
return { error: `Unsafe delivery branch: ${branch || '<empty>'}` };
if (branch === baseBranch || branch === 'main') {
return { error: `Refusing delivery from protected branch ${branch}` };
}
return { branch };
};
const publish: ToolDefinition = {
name: 'git_publish_branch',
label: 'Publish Git Branch',
description:
'Stage explicit files in the current sandbox branch, commit them as the dedicated dogfood identity, run the CI queue guard, and push the branch. No shell or raw provider API is used.',
parameters: Type.Object({
issue: Type.Integer({ minimum: 1, description: 'Tracking issue number' }),
paths: Type.Array(Type.String(), {
minItems: 1,
maxItems: 100,
description: 'Files to stage, relative to the sandbox root',
}),
commitMessage: Type.String({ minLength: 1, maxLength: 4000 }),
}),
async execute(_toolCallId, params) {
const { issue, paths, commitMessage } = params as {
issue: number;
paths: string[];
commitMessage: string;
};
const branchResult = await readBranch();
if (!branchResult.branch) return textResult(`Error: ${branchResult.error}`);
const branch = branchResult.branch;
if (!currentBranchPattern(issue).test(branch)) {
return textResult(`Error: branch ${branch} does not carry issue ${issue}`);
}
const relativePaths: string[] = [];
try {
const sandboxRoot = guardPath('.', sandboxDir);
for (const candidate of paths) {
const resolved = guardPath(candidate, sandboxDir);
const relative = path.relative(sandboxRoot, resolved);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new SandboxEscapeError(candidate, sandboxDir, resolved);
}
relativePaths.push(relative);
}
} catch (error) {
return textResult(`Error: ${error instanceof Error ? error.message : String(error)}`);
}
const add = await run('/usr/bin/git', ['add', '--', ...relativePaths], 30_000);
if (add.exitCode !== 0) return textResult(`Error: ${describeFailure('git add', add)}`);
const commit = await run(
'/usr/bin/git',
[
'-c',
`user.name=${identity}`,
'-c',
`user.email=${identity}@mosaic.invalid`,
'commit',
'-m',
commitMessage,
'--',
...relativePaths,
],
60_000,
);
if (commit.exitCode !== 0)
return textResult(`Error: ${describeFailure('git commit', commit)}`);
const queue = await run(queueGuard, ['--purpose', 'push', '-B', branch]);
if (queue.exitCode !== 0) {
return textResult(`Error: ${describeFailure('CI queue guard', queue)}`);
}
const push = await run(
'/usr/bin/git',
['push', '--set-upstream', 'origin', branch],
PROCESS_TIMEOUT_MS,
);
if (push.exitCode !== 0) return textResult(`Error: ${describeFailure('git push', push)}`);
return textResult(`Published branch ${branch} as ${identity}.`);
},
};
const openPr: ToolDefinition = {
name: 'git_open_pull_request',
label: 'Open Pull Request',
description:
'Open a pull request from the current sandbox branch through the Mosaic pr-create wrapper. The wrapper targets the configured integration branch and links the tracking issue.',
parameters: Type.Object({
issue: Type.Integer({ minimum: 1, description: 'Tracking issue number' }),
title: Type.String({ minLength: 1, maxLength: 240 }),
body: Type.String({ maxLength: 20_000 }),
}),
async execute(_toolCallId, params) {
const { issue, title, body } = params as { issue: number; title: string; body: string };
const branchResult = await readBranch();
if (!branchResult.branch) return textResult(`Error: ${branchResult.error}`);
const branch = branchResult.branch;
if (!currentBranchPattern(issue).test(branch)) {
return textResult(`Error: branch ${branch} does not carry issue ${issue}`);
}
const result = await run(prCreate, [
'-t',
title,
'-b',
body,
'-B',
baseBranch,
'-H',
branch,
'-i',
String(issue),
]);
if (result.exitCode !== 0) {
return textResult(`Error: ${describeFailure('pr-create wrapper', result)}`);
}
return textResult(result.stdout.trim() || `Pull request opened from ${branch}.`);
},
};
return [publish, openPr];
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
import { guardPath, guardWritePath, SandboxEscapeError } from './path-guard.js';
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit
const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit
@@ -92,7 +92,7 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
};
let safePath: string;
try {
safePath = guardWritePath(path, baseDir);
safePath = guardPathUnsafe(path, baseDir);
} catch (err) {
if (err instanceof SandboxEscapeError) {
return {
+1 -2
View File
@@ -1,9 +1,8 @@
export { createBrainTools } from './brain-tools.js';
export { createCoordTools } from './coord-tools.js';
export { createDeliveryTools } from './delivery-tools.js';
export { createFileTools } from './file-tools.js';
export { createGitTools } from './git-tools.js';
export { createSearchTools } from './search-tools.js';
export { createShellTools, createShellToolsIfEnabled } from './shell-tools.js';
export { createShellTools } from './shell-tools.js';
export { createWebTools } from './web-tools.js';
export { createSkillTools } from './skill-tools.js';
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { guardPath, guardPathUnsafe, guardWritePath, SandboxEscapeError } from './path-guard.js';
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
@@ -101,55 +101,4 @@ describe('guardPath', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('rejects a symlink inside the sandbox that resolves outside it', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-outside-'));
try {
const target = path.join(outside, 'credential.token');
fs.writeFileSync(target, 'OUTSIDE_SYMLINK_SENTINEL');
fs.symlinkSync(target, path.join(tmpDir, 'credential.token'));
expect(() => guardPath('credential.token', tmpDir)).toThrow(SandboxEscapeError);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
});
describe('guardWritePath', () => {
it('allows a new file under an existing real sandbox directory', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
try {
expect(guardWritePath('new.txt', sandbox)).toBe(path.join(sandbox, 'new.txt'));
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}
});
it('rejects writes through a file symlink that resolves outside the sandbox', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-outside-'));
try {
const target = path.join(outside, 'credential.token');
fs.writeFileSync(target, 'OUTSIDE_WRITE_SENTINEL');
fs.symlinkSync(target, path.join(sandbox, 'credential.token'));
expect(() => guardWritePath('credential.token', sandbox)).toThrow(SandboxEscapeError);
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
it('rejects new files under a directory symlink that leaves the sandbox', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-outside-'));
try {
fs.symlinkSync(outside, path.join(sandbox, 'outside'));
expect(() => guardWritePath('outside/new.txt', sandbox)).toThrow(SandboxEscapeError);
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
});
+32 -48
View File
@@ -1,63 +1,47 @@
import path from 'node:path';
import fs from 'node:fs';
function isContained(candidate: string, root: string): boolean {
return candidate === root || candidate.startsWith(root + path.sep);
}
function assertLexicalContainment(userPath: string, sandboxDir: string): string {
/**
* Resolves a user-provided path and verifies it is inside the allowed sandbox directory.
* Throws SandboxEscapeError if the resolved path is outside the sandbox.
*
* Uses realpathSync to resolve symlinks in the sandbox root. The user-supplied path
* is checked for containment AFTER lexical resolution but BEFORE resolving any symlinks
* within the user path — so symlink escape attempts are caught too.
*
* @param userPath - The path provided by the agent (may be relative or absolute)
* @param sandboxDir - The allowed root directory (already validated on session creation)
* @returns The resolved absolute path, guaranteed to be within sandboxDir
*/
export function guardPath(userPath: string, sandboxDir: string): string {
const resolved = path.resolve(sandboxDir, userPath);
const sandboxAbsolute = path.resolve(sandboxDir);
if (!isContained(resolved, sandboxAbsolute)) {
const sandboxResolved = fs.realpathSync.native(sandboxDir);
// Normalize both paths to resolve any symlinks in the sandbox root itself.
// For the user path, we check containment BEFORE resolving symlinks in the path
// (so we catch symlink escape attempts too — the resolved path must still be under sandbox)
if (!resolved.startsWith(sandboxResolved + path.sep) && resolved !== sandboxResolved) {
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
}
return resolved;
}
/**
* Resolve an existing path and verify both its lexical path and real symlink
* target remain inside the sandbox.
*/
export function guardPath(userPath: string, sandboxDir: string): string {
const resolved = assertLexicalContainment(userPath, sandboxDir);
const sandboxReal = fs.realpathSync.native(sandboxDir);
const resolvedReal = fs.realpathSync.native(resolved);
if (!isContained(resolvedReal, sandboxReal)) {
throw new SandboxEscapeError(userPath, sandboxDir, resolvedReal);
}
return resolvedReal;
}
/**
* Resolve a writable file path whose parent already exists. Existing targets
* are resolved fully. New targets use the real parent directory, which blocks
* writes through a parent symlink that leaves the sandbox.
*/
export function guardWritePath(userPath: string, sandboxDir: string): string {
const resolved = assertLexicalContainment(userPath, sandboxDir);
const sandboxReal = fs.realpathSync.native(sandboxDir);
let writableReal: string;
try {
writableReal = fs.realpathSync.native(resolved);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') throw error;
const parentReal = fs.realpathSync.native(path.dirname(resolved));
writableReal = path.join(parentReal, path.basename(resolved));
}
if (!isContained(writableReal, sandboxReal)) {
throw new SandboxEscapeError(userPath, sandboxDir, writableReal);
}
return writableReal;
}
/**
* Lexical-only validation for non-filesystem pathspecs such as `git diff --`
* targets, where the path may name a deleted file and Git does not dereference
* a tracked symlink.
* Validates a path without resolving symlinks in the user-provided portion.
* Use for paths that may not exist yet (creates, writes).
*
* Performs a lexical containment check only using path.resolve.
*/
export function guardPathUnsafe(userPath: string, sandboxDir: string): string {
return assertLexicalContainment(userPath, sandboxDir);
const resolved = path.resolve(sandboxDir, userPath);
const sandboxAbs = path.resolve(sandboxDir);
if (!resolved.startsWith(sandboxAbs + path.sep) && resolved !== sandboxAbs) {
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
}
return resolved;
}
export class SandboxEscapeError extends Error {
@@ -128,14 +128,6 @@ function runCommand(
});
}
export function createShellToolsIfEnabled(
sandboxDir: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): ToolDefinition[] {
if (env['AGENT_SHELL_ENABLED'] === 'false') return [];
return createShellTools(sandboxDir);
}
export function createShellTools(sandboxDir?: string): ToolDefinition[] {
const defaultCwd = sandboxDir ?? process.cwd();
-4
View File
@@ -24,8 +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 { EnrollmentModule } from './enrollment/enrollment.module.js';
import { QueueModule } from './queue/queue.module.js';
import { FederationModule } from './federation/federation.module.js';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
@@ -67,8 +65,6 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
QueueModule,
ReloadModule,
WorkspaceModule,
HierarchyModule,
EnrollmentModule,
...(federationEnabled ? [FederationModule] : []),
],
controllers: [HealthController],
@@ -61,33 +61,6 @@ describe('CommandAuthorizationService', () => {
).toBe(false);
});
it('denies non-admin scopes to a platform admin (contract 2 §1.1 bypass retirement)', async (): Promise<void> => {
const service = createService('admin');
for (const scope of ['core', 'agent', 'skill', 'plugin'] as const) {
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
expect(
(await service.authorize(command, { ...payload, command: command.name }, 'admin-1'))
.allowed,
).toBe(false);
}
});
it('allows member core/agent scopes and denies skill/plugin (deny-by-default)', async (): Promise<void> => {
const service = createService('member');
for (const [scope, allowed] of [
['core', true],
['agent', true],
['skill', false],
['plugin', false],
] as const) {
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
expect(
(await service.authorize(command, { ...payload, command: command.name }, 'member-1'))
.allowed,
).toBe(allowed);
}
});
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
const entries = new Map<string, string>();
const action = {
@@ -154,15 +154,8 @@ export class CommandAuthorizationService {
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
}
/**
* Contract 2 §1.1: platform admin confers instance administration only —
* the former admin-passes-every-scope short-circuit is retired. Admin
* reaches exactly the admin scope; core/agent scopes belong to the member
* role; skill/plugin scopes stay deny-for-all until a grant mapping names
* them (§3.1 deny-by-default).
*/
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
if (scope === 'admin') return role === 'admin';
if (role === 'admin') return true;
return role === 'member' && (scope === 'core' || scope === 'agent');
}
@@ -1,508 +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, vi } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import { Logger, ValidationPipe, type ExecutionContext } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import supertest from 'supertest';
import { unseal } from '@mosaicstack/auth';
import {
agentAuditEvents,
agentIdempotencyFence,
agentOutbox,
agents,
and,
createPgliteDb,
eq,
providerCredentials,
runPgliteMigrations,
sql,
users,
type DbHandle,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { HarnessRegistry } from '../harness/harness.registry.js';
import { HARNESS_REGISTRY } from '../harness/harness.tokens.js';
import { FakeHarnessAdapter } from '../harness/testing/fake-harness.adapter.js';
import { EnrollmentController } from './enrollment.controller.js';
import {
EnrollmentRepository,
type EnrollAgentInput,
type EnrollmentResult,
type EnrolledAgentView,
} from './enrollment.repository.js';
import { EnrollmentService } from './enrollment.service.js';
/**
* Command-level witnesses for the agent enrollment family (M4-4b) — design
* docs/plans/2026-08-29-agent-enrollment-command-design.md §5 items 19 and
* 11 (item 10, CLI parity, lives in packages/mosaic). Schema-level
* constraints are witnessed in packages/db/src/agent-enrollment.witness.test.ts.
*
* The suite runs the REAL repository/service/controller graph over PGlite,
* with only AuthGuard overridden (a session store is out of scope; the
* override binds request.user exactly as the real guard does). The §6.3
* static companions — no `any`-typed boundary pass-through, a single audit
* emitter (EnrollmentRepository.appendEvent) — are code-surface properties
* reviewed on the PR, not runtime probes.
*/
describe('enrollment commands integration', (): void => {
let dataDir: string;
let handle: DbHandle;
let moduleRef: TestingModule;
let app: NestFastifyApplication;
let http: ReturnType<typeof supertest>;
let repo: EnrollmentRepository;
let previousAuthSecret: string | undefined;
const OWNER = 'enr-owner';
const ADMIN = 'enr-admin';
const STRANGER = 'enr-stranger';
const HARNESS = 'fake-harness';
/** Never-echo probe value (§5.1). Unique enough that any leak is unambiguous. */
const SECRET = `enr-secret-value-${randomUUID()}`;
/** The HTTP-leg acting user; the overridden guard binds it per request. */
let currentUserId = OWNER;
const enrollInput = (overrides: Partial<EnrollAgentInput> = {}): EnrollAgentInput => ({
actorId: OWNER,
harness: HARNESS,
name: `Agent ${randomUUID().slice(0, 8)}`,
persona: null,
model: 'anthropic/claude-test',
provider: `prov-${randomUUID().slice(0, 8)}`,
credential: { mode: 'intake', type: 'api_key', value: SECRET },
idempotencyKey: randomUUID(),
...overrides,
});
function expectOk<T>(result: EnrollmentResult<T>): { ok: true; correlationId: string } & T {
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
return result;
}
function expectFail<T>(
result: EnrollmentResult<T>,
error: string,
): { ok: false; error: string; message: string; correlationId: string } {
if (result.ok) throw new Error(`expected ${error}, got ok`);
expect(result.error).toBe(error);
return result;
}
const fenceForKey = (key: string) =>
handle.db
.select()
.from(agentIdempotencyFence)
.where(eq(agentIdempotencyFence.idempotencyKey, key));
const eventsForAgent = (agentId: string) =>
handle.db.select().from(agentAuditEvents).where(eq(agentAuditEvents.agentId, agentId));
const agentsNamed = (name: string) =>
handle.db.select().from(agents).where(eq(agents.name, name));
const credentialsFor = (userId: string, provider: string) =>
handle.db
.select()
.from(providerCredentials)
.where(
and(eq(providerCredentials.userId, userId), eq(providerCredentials.provider, provider)),
);
const allOutbox = () => handle.db.select().from(agentOutbox);
beforeAll(async (): Promise<void> => {
previousAuthSecret = process.env['BETTER_AUTH_SECRET'];
process.env['BETTER_AUTH_SECRET'] = 'enrollment-witness-sealing-key';
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-enrollment-commands-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
const registry = new HarnessRegistry();
registry.register(new FakeHarnessAdapter({ id: HARNESS }));
moduleRef = await Test.createTestingModule({
controllers: [EnrollmentController],
providers: [
EnrollmentRepository,
EnrollmentService,
{ provide: DB, useValue: handle.db },
{ provide: HARNESS_REGISTRY, useValue: registry },
],
})
.overrideGuard(AuthGuard)
.useValue({
canActivate: (ctx: ExecutionContext): boolean => {
const request = ctx.switchToHttp().getRequest<{ user?: unknown }>();
request.user = { id: currentUserId };
return true;
},
})
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
// Mirror main.ts exactly — the closure witnesses depend on these options.
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
http = supertest(app.getHttpServer());
repo = moduleRef.get(EnrollmentRepository);
await handle.db.insert(users).values([
{ id: OWNER, name: 'Owner', email: `${OWNER}@example.com` },
{ id: ADMIN, name: 'Admin', email: `${ADMIN}@example.com`, role: 'admin' },
{ id: STRANGER, name: 'Stranger', email: `${STRANGER}@example.com` },
]);
});
afterAll(async (): Promise<void> => {
await app?.close();
await handle.close();
await rm(dataDir, { recursive: true, force: true });
if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET'];
else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret;
});
// ── §5.7 wizard-facing zero-mutation (runs FIRST: no call → zero rows) ────
it('zero-mutation: with no enrollment invocation the family tables hold zero rows', async () => {
expect(await handle.db.select().from(agents)).toHaveLength(0);
expect(await handle.db.select().from(agentAuditEvents)).toHaveLength(0);
expect(await handle.db.select().from(agentOutbox)).toHaveLength(0);
expect(await handle.db.select().from(agentIdempotencyFence)).toHaveLength(0);
});
// ── §5.1 never-echo + §5.2 sealed single-copy ─────────────────────────────
it('never echoes the intake credential value: HTTP result, audit, outbox, fence, and logs are clean', async () => {
const logSink: string[] = [];
const logSpies = (['log', 'error', 'warn', 'debug', 'verbose'] as const).map((method) =>
vi.spyOn(Logger.prototype, method).mockImplementation((...args: unknown[]) => {
logSink.push(args.map(String).join(' '));
}),
);
try {
currentUserId = OWNER;
const provider = `prov-echo-${randomUUID().slice(0, 8)}`;
const res = await http.post('/api/enrollment/agents').send({
harness: HARNESS,
name: 'Echo Probe',
persona: 'a persona',
model: 'anthropic/claude-test',
provider,
credential: { mode: 'intake', type: 'api_key', value: SECRET },
idempotencyKey: randomUUID(),
});
expect(res.status).toBe(201);
expect(res.text).not.toContain(SECRET);
const agentId = (res.body as { agent: EnrolledAgentView }).agent.id;
const events = await eventsForAgent(agentId);
expect(events).toHaveLength(1);
expect(JSON.stringify(events)).not.toContain(SECRET);
expect(JSON.stringify(await allOutbox())).not.toContain(SECRET);
const fences = await handle.db
.select()
.from(agentIdempotencyFence)
.where(eq(agentIdempotencyFence.outcomeAgentId, agentId));
expect(fences).toHaveLength(1);
expect(JSON.stringify(fences)).not.toContain(SECRET);
expect(logSink.join('\n')).not.toContain(SECRET);
// §5.2 sealed single-copy: exactly one provider_credentials row, sealed
// at rest, and it round-trips through unseal — no plaintext column.
const creds = await credentialsFor(OWNER, provider);
expect(creds).toHaveLength(1);
expect(creds[0]?.encryptedValue).not.toBe(SECRET);
expect(creds[0]?.encryptedValue).not.toContain(SECRET);
expect(unseal(creds[0]?.encryptedValue as string)).toBe(SECRET);
} finally {
logSpies.forEach((spy) => spy.mockRestore());
}
});
it('the agents table itself has no credential-bearing column (§5.2)', async () => {
const result = (await handle.db.execute(
sql`select column_name from information_schema.columns where table_name = 'agents'`,
)) as unknown as { rows?: Array<{ column_name: string }> } & Array<{ column_name: string }>;
const names = (result.rows ?? result).map((row) => row.column_name);
expect(names.length).toBeGreaterThan(0);
for (const name of names) {
expect(name).not.toMatch(/credential|secret|token|api_key/i);
}
});
// ── §5.3 reference resolution ─────────────────────────────────────────────
it('refuses an unresolvable credential reference with precondition_failed and creates nothing', async () => {
const input = enrollInput({ credential: { mode: 'reference' } });
const result = await repo.enroll(input);
expectFail(result, 'precondition_failed');
expect(await agentsNamed(input.name)).toHaveLength(0);
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0);
});
it('resolves a reference credential stored earlier for (actor, provider)', async () => {
const provider = `prov-ref-${randomUUID().slice(0, 8)}`;
const seeded = expectOk(await repo.enroll(enrollInput({ provider })));
const result = expectOk(
await repo.enroll(enrollInput({ provider, credential: { mode: 'reference' } })),
);
expect(result.agent.id).not.toBe(seeded.agent.id);
expect(await credentialsFor(OWNER, provider)).toHaveLength(1);
});
// ── §5.4 harness refusals, both codes ────────────────────────────────────
it('refuses a syntactically invalid harness as validation_failed and a registry miss as precondition_failed', async () => {
const blank = await repo.enroll(enrollInput({ harness: ' ' }));
expectFail(blank, 'validation_failed');
const miss = await repo.enroll(enrollInput({ harness: 'well-formed-but-unregistered' }));
expectFail(miss, 'precondition_failed');
currentUserId = OWNER;
const httpBlank = await http.post('/api/enrollment/agents').send({
harness: '',
name: 'H',
model: 'm',
provider: 'p',
credential: { mode: 'reference' },
idempotencyKey: randomUUID(),
});
expect(httpBlank.status).toBe(400);
});
// ── §5.5 idempotency set (contract 3 §4.3) ───────────────────────────────
it('actor-bound replay returns the recorded outcome and executes nothing new', async () => {
const input = enrollInput();
const first = expectOk(await repo.enroll(input));
const replay = expectOk(await repo.enroll({ ...input, correlationId: randomUUID() }));
expect(replay.agent.id).toBe(first.agent.id);
expect(await agentsNamed(input.name)).toHaveLength(1);
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(1);
const events = await eventsForAgent(first.agent.id);
expect(events.filter((e) => e.eventType === 'agent.enrolled')).toHaveLength(1);
// A passing replay appends exactly the non-mutation access event.
const replayed = events.filter((e) => e.eventType === 'agent.enrollment.replayed');
expect(replayed).toHaveLength(1);
expect((replayed[0]?.payload as { fenceId?: string }).fenceId).toBeDefined();
});
it('payload-digest mismatch on a recorded key refuses with the single bounded conflict shape', async () => {
const input = enrollInput();
expectOk(await repo.enroll(input));
const mismatch = await repo.enroll({ ...input, name: `${input.name} CHANGED` });
const failure = expectFail(mismatch, 'conflict');
expect(failure.message).toBe('idempotency conflict');
});
it('replay-mode and scope mismatches on the recorded fence each refuse as the same constant conflict', async () => {
const modeInput = enrollInput();
expectOk(await repo.enroll(modeInput));
await handle.db
.update(agentIdempotencyFence)
.set({ replayMode: 'shared' })
.where(eq(agentIdempotencyFence.idempotencyKey, modeInput.idempotencyKey));
const modeFailure = expectFail(await repo.enroll(modeInput), 'conflict');
const scopeInput = enrollInput();
expectOk(await repo.enroll(scopeInput));
await handle.db
.update(agentIdempotencyFence)
.set({ authorizationScope: 'some-other-scope' })
.where(eq(agentIdempotencyFence.idempotencyKey, scopeInput.idempotencyKey));
const scopeFailure = expectFail(await repo.enroll(scopeInput), 'conflict');
expect(modeFailure.message).toBe(scopeFailure.message);
});
it('a different actor replaying an actor-bound key is refused conflict, learning nothing', async () => {
const input = enrollInput();
expectOk(await repo.enroll(input));
const failure = expectFail(await repo.enroll({ ...input, actorId: STRANGER }), 'conflict');
expect(failure.message).toBe('idempotency conflict');
});
it('a replay is re-authorized fresh: revoked target authority refuses instead of replaying', async () => {
const input = enrollInput();
const first = expectOk(await repo.enroll(input));
// Simulate the legacy CRUD DELETE path removing the outcome agent: the
// submitter no longer holds read authority on the referenced row.
await handle.db.delete(agents).where(eq(agents.id, first.agent.id));
expectFail(await repo.enroll(input), 'conflict');
});
it('a shared replay-mode declaration is refused validation_failed with nothing executed and no fence row', async () => {
const input = enrollInput({ replayMode: 'shared' });
expectFail(await repo.enroll(input), 'validation_failed');
expect(await agentsNamed(input.name)).toHaveLength(0);
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0);
currentUserId = OWNER;
const key = randomUUID();
const res = await http.post('/api/enrollment/agents').send({
harness: HARNESS,
name: 'Shared Probe',
model: 'm',
provider: 'p',
credential: { mode: 'reference' },
idempotencyKey: key,
replayMode: 'shared',
});
expect(res.status).toBe(400);
expect(await fenceForKey(key)).toHaveLength(0);
});
it('two concurrent same-key submissions produce exactly one mutation, the loser resolving as a replay', async () => {
const input = enrollInput();
const [a, b] = await Promise.all([
repo.enroll(input),
repo.enroll({ ...input, correlationId: randomUUID() }),
]);
const okA = expectOk(a);
const okB = expectOk(b);
expect(okA.agent.id).toBe(okB.agent.id);
expect(await agentsNamed(input.name)).toHaveLength(1);
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(1);
const events = await eventsForAgent(okA.agent.id);
expect(events.filter((e) => e.eventType === 'agent.enrolled')).toHaveLength(1);
expect(events.filter((e) => e.eventType === 'agent.enrollment.replayed')).toHaveLength(1);
});
// ── §5.6 same-tx atomicity fault injection ───────────────────────────────
it('rolls everything back on failure at each write point — no orphan credential survives', async () => {
const injectionPoints = [
'writeSealedCredential',
'insertAgentRow',
'insertFenceRow',
'appendEvent',
'insertOutboxRow',
] as const;
for (const point of injectionPoints) {
const input = enrollInput();
const spy = vi.spyOn(repo, point).mockImplementationOnce(() => {
throw new Error(`injected ${point} fault`);
});
try {
const result = await repo.enroll(input);
expectFail(result, 'internal_fault');
expect(await agentsNamed(input.name)).toHaveLength(0);
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0);
// Injection at fence/audit/outbox fires AFTER the sealed credential
// write's statement ran — the rollback must leave no orphan row.
expect(await credentialsFor(OWNER, input.provider)).toHaveLength(0);
} finally {
spy.mockRestore();
}
}
});
// ── §5.8 is_system closure ───────────────────────────────────────────────
it('rejects an is_system injection attempt at the DTO boundary', async () => {
currentUserId = OWNER;
const key = randomUUID();
const res = await http.post('/api/enrollment/agents').send({
harness: HARNESS,
name: 'System Probe',
model: 'm',
provider: 'p',
credential: { mode: 'reference' },
idempotencyKey: key,
isSystem: true,
});
expect(res.status).toBe(400);
expect(await fenceForKey(key)).toHaveLength(0);
});
// ── §5.9 correlation + no-existence-oracle ───────────────────────────────
it('carries a submitted correlation id into the result, the audit event, and the outbox record', async () => {
const correlationId = randomUUID();
const input = enrollInput({ correlationId });
const result = expectOk(await repo.enroll(input));
expect(result.correlationId).toBe(correlationId);
const events = await eventsForAgent(result.agent.id);
expect(events).toHaveLength(1);
expect(events[0]?.correlationId).toBe(correlationId);
const outboxRows = await handle.db
.select()
.from(agentOutbox)
.where(eq(agentOutbox.eventId, events[0]?.id as string));
expect(outboxRows).toHaveLength(1);
expect(outboxRows[0]?.correlationId).toBe(correlationId);
// Refusals carry the correlation envelope too (contract 5 §4.3).
const refusal = expectFail(
await repo.enroll({ ...input, name: 'changed name', correlationId }),
'conflict',
);
expect(refusal.correlationId).toBe(correlationId);
});
it('agent.enrollment.get returns owner and admin reads with the correlation envelope, no idempotency key', async () => {
const enrolled = expectOk(await repo.enroll(enrollInput()));
const correlationId = randomUUID();
const asOwner = expectOk(await repo.getEnrollment(OWNER, enrolled.agent.id, correlationId));
expect(asOwner.correlationId).toBe(correlationId);
expect(asOwner.agent.id).toBe(enrolled.agent.id);
const asAdmin = expectOk(await repo.getEnrollment(ADMIN, enrolled.agent.id));
expect(asAdmin.correlationId).toMatch(/^[0-9a-f-]{36}$/);
currentUserId = OWNER;
const wire = randomUUID();
const res = await http.get(`/api/enrollment/agents/${enrolled.agent.id}?correlationId=${wire}`);
expect(res.status).toBe(200);
expect((res.body as { correlationId: string }).correlationId).toBe(wire);
});
it('no existence oracle: unauthorized get of a real agent and get of a missing id are indistinguishable', async () => {
const enrolled = expectOk(await repo.enroll(enrollInput()));
currentUserId = STRANGER;
const unauthorized = await http.get(`/api/enrollment/agents/${enrolled.agent.id}`);
const missing = await http.get(`/api/enrollment/agents/${randomUUID()}`);
expect(unauthorized.status).toBe(404);
expect(missing.status).toBe(404);
const strip = (body: Record<string, unknown>): Record<string, unknown> =>
Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'correlationId'));
expect(strip(unauthorized.body as Record<string, unknown>)).toEqual(
strip(missing.body as Record<string, unknown>),
);
});
// ── §5.11 fail-closed ────────────────────────────────────────────────────
it('fails closed as internal_fault when the store is unreachable, with no fallback write', async () => {
const before = (await handle.db.select().from(agents)).length;
const txSpy = vi.spyOn(handle.db, 'transaction').mockImplementationOnce(() => {
throw new Error('injected store outage');
});
try {
expectFail(await repo.enroll(enrollInput()), 'internal_fault');
} finally {
txSpy.mockRestore();
}
const selectSpy = vi.spyOn(handle.db, 'select').mockImplementationOnce(() => {
throw new Error('injected store outage');
});
try {
expectFail(await repo.getEnrollment(OWNER, randomUUID()), 'internal_fault');
} finally {
selectSpy.mockRestore();
}
expect((await handle.db.select().from(agents)).length).toBe(before);
});
});
@@ -1,61 +0,0 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { EnrollAgentDto, GetEnrollmentQueryDto } from './enrollment.dto.js';
import { EnrollmentRepository } from './enrollment.repository.js';
import { EnrollmentService } from './enrollment.service.js';
/**
* The agent enrollment command family's closed HTTP surface (design
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3): one command,
* one query. Authentication failures are the guard's (401); everything else
* is the repository's closed enum mapped by EnrollmentService.
*/
@Controller('api/enrollment')
@UseGuards(AuthGuard)
export class EnrollmentController {
constructor(
private readonly repository: EnrollmentRepository,
private readonly service: EnrollmentService,
) {}
/** agent.enroll (§3.1). */
@Post('agents')
async enroll(@CurrentUser() user: { id: string }, @Body() dto: EnrollAgentDto) {
return this.service.unwrap(
await this.repository.enroll({
actorId: user.id,
harness: dto.harness,
name: dto.name,
persona: dto.persona ?? null,
model: dto.model,
provider: dto.provider,
credential: dto.credential,
idempotencyKey: dto.idempotencyKey,
correlationId: dto.correlationId,
replayMode: dto.replayMode,
}),
);
}
/** agent.enrollment.get (§3.2): owner-or-admin; unauthorized and missing fold to one not_found. */
@Get('agents/:id')
async getEnrollment(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Query() query: GetEnrollmentQueryDto,
) {
return this.service.unwrap(
await this.repository.getEnrollment(user.id, id, query.correlationId),
);
}
}
@@ -1,107 +0,0 @@
import { Type } from 'class-transformer';
import {
IsIn,
IsOptional,
IsString,
IsUUID,
MaxLength,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
/**
* Agent enrollment command DTOs (design
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3.1/§3.2,
* contract 5 §4.1 typed boundary).
*
* The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so
* closure is contract surface here exactly as in the hierarchy DTOs:
* - EnrollAgentDto declares NO isSystem field — `is_system` is never
* settable through this command (design §3.1 rule 4); the pipe refuses it.
* - replayMode admits ONLY 'actor-bound': `shared` is seed-only (contract 3
* §4.3), so a shared declaration is refused `validation_failed` at the
* boundary, executes nothing, and records no fence row (design §3.1).
* Every class here must be registered in PIPE_GUARDED_DTOS so the boot-time
* assertion proves the pipe sees the decorators.
*/
/**
* Credential input, discriminated on `mode` (design §3.1):
* - `{ mode: 'reference' }` — a stored credential for (actor, provider)
* must already exist; `type`/`value` must be ABSENT (the repository
* refuses a reference that smuggles a value).
* - `{ mode: 'intake', type: 'api_key', value }` — the value is sealed
* into the credential store inside the enrollment transaction and is
* never echoed anywhere (§3.1 rule 1).
*/
export class EnrollCredentialDto {
@IsIn(['reference', 'intake'])
mode!: 'reference' | 'intake';
@ValidateIf((o: EnrollCredentialDto) => o.mode === 'intake')
@IsIn(['api_key'])
type?: 'api_key';
@ValidateIf((o: EnrollCredentialDto) => o.mode === 'intake')
@IsString()
@MinLength(1)
@MaxLength(4096)
value?: string;
}
export class EnrollAgentDto {
/** Registered harness name; a well-formed name missing from the registry is `precondition_failed`. */
@IsString()
@MinLength(1)
@MaxLength(200)
harness!: string;
@IsString()
@MinLength(1)
@MaxLength(200)
name!: string;
/** Stored as the agent's system prompt; null/absent leaves it unset. */
@IsOptional()
@IsString()
@MaxLength(20000)
persona?: string | null;
/** Provider-qualified model id. */
@IsString()
@MinLength(1)
@MaxLength(200)
model!: string;
/** Names the credential's provider. */
@IsString()
@MinLength(1)
@MaxLength(200)
provider!: string;
@ValidateNested()
@Type(() => EnrollCredentialDto)
credential!: EnrollCredentialDto;
/** REQUIRED — contract 3 §4.3, ratified into contract 5 §4 via §7 item 4. */
@IsUUID()
idempotencyKey!: string;
/** Optional; generated when absent (contract 5 §4.3). */
@IsOptional()
@IsUUID()
correlationId?: string;
/** Only 'actor-bound' is admissible on this family — see module doc. */
@IsOptional()
@IsIn(['actor-bound'])
replayMode?: 'actor-bound';
}
/** Query envelope for agent.enrollment.get (design §3.2): correlation only, no idempotency key. */
export class GetEnrollmentQueryDto {
@IsOptional()
@IsUUID()
correlationId?: string;
}
@@ -1,22 +0,0 @@
import { Module } from '@nestjs/common';
import { HarnessModule } from '../harness/harness.module.js';
import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentRepository } from './enrollment.repository.js';
import { EnrollmentService } from './enrollment.service.js';
/**
* Agent enrollment command family (M4-4b; design
* docs/plans/2026-08-29-agent-enrollment-command-design.md). Imports
* HarnessModule for the live harness registry — the validation source for
* the `harness` field (a well-formed name the registry does not know is a
* precondition failure). EnrollmentRepository is the family's sole writer;
* every mutation runs fence-check → mutate → audit + outbox in one
* transaction.
*/
@Module({
imports: [HarnessModule],
controllers: [EnrollmentController],
providers: [EnrollmentRepository, EnrollmentService],
exports: [EnrollmentRepository],
})
export class EnrollmentModule {}
@@ -1,538 +0,0 @@
import { createHash, randomUUID } from 'node:crypto';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { seal } from '@mosaicstack/auth';
import {
agentAuditEvents,
agentIdempotencyFence,
agentOutbox,
agents,
and,
eq,
providerCredentials,
users,
type Db,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import type { HarnessRegistry } from '../harness/harness.registry.js';
import { HARNESS_REGISTRY } from '../harness/harness.tokens.js';
/**
* Agent enrollment command repository (design
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3; contract 5 §4
* envelope; contract 3 §4.3 idempotency fence, ratified via §7 item 4).
*
* The ONLY writer of the enrollment family's tables (`agent_audit_events`,
* `agent_outbox`, `agent_idempotency_fence`) and the only path that sets
* `agents.harness`/`agents.enrolled_at`. Every enroll runs one transaction:
* fence check → (replay | credential handling → agent insert → fence insert →
* audit event + outbox), so state, fence, event, and outbox commit or roll
* back together (§3.1 rule 6).
*
* Authorization (v1, §3.1 rule 4) is the AuthGuard-authenticated actor — no
* hierarchy grant is consulted because v1 enrollment binds no hierarchy node.
* The recorded fence authorization scope is therefore the constant
* platform-user identity domain (§3.1 rule 5).
*
* Never-echo (§3.1 rule 1): the credential value reaches exactly one sink —
* the sealed store write — and appears in no result, audit payload, outbox
* row, or log line. Log lines here carry correlation ids and error names
* only, never request fields.
*
* The single-write helper methods (writeSealedCredential, insertAgentRow,
* insertFenceRow, appendEvent, insertOutboxRow) are ordinary decomposition;
* the atomicity witnesses (§5.6) spy on them to inject faults at each write
* point without any test-only production switch.
*/
export const ENROLLMENT_OPERATION = 'agent.enroll';
/** §3.1 rule 5: v1 authorization is grant-free, so the scope is the authenticated-user identity domain. */
const AUTHORIZATION_SCOPE = 'platform-user';
/** The single bounded collision shape (§3.1 rule 5): constant, identifying no record. */
const CONFLICT_MESSAGE = 'idempotency conflict';
/** One fixed message for every not_found cause — missing and unauthorized are indistinguishable (§3.2). */
const NOT_FOUND_MESSAGE = 'agent not found';
/** Closed per-family error enum (§3.3). 401 is produced by AuthGuard; 403 folds to not_found (§3.2). */
export type EnrollmentErrorCode =
| 'validation_failed'
| 'authentication_failed'
| 'authorization_refused'
| 'not_found'
| 'conflict'
| 'precondition_failed'
| 'internal_fault';
export interface EnrollmentFailure {
readonly ok: false;
readonly error: EnrollmentErrorCode;
readonly message: string;
/** Refusals carry the correlation id too (contract 5 §4.3 end-to-end traceability). */
readonly correlationId: string;
}
export type EnrollmentResult<T> =
| ({ readonly ok: true; readonly correlationId: string } & T)
| EnrollmentFailure;
/** The persisted agent row; the table stores no credential material (§3.1 rule 1). */
export interface EnrolledAgentView {
readonly id: string;
readonly name: string;
readonly provider: string;
readonly model: string;
readonly status: string;
readonly harness: string | null;
readonly persona: string | null;
readonly ownerId: string | null;
readonly enrolledAt: string | null;
readonly createdAt: string;
}
export interface EnrollCredentialInput {
readonly mode: 'reference' | 'intake';
readonly type?: 'api_key';
readonly value?: string;
}
export interface EnrollAgentInput {
readonly actorId: string;
readonly harness: string;
readonly name: string;
readonly persona?: string | null;
readonly model: string;
readonly provider: string;
readonly credential: EnrollCredentialInput;
readonly idempotencyKey: string;
readonly correlationId?: string;
/** Defense in depth below the DTO: anything but 'actor-bound' is refused (seed-only rule). */
readonly replayMode?: string;
}
type Tx = Pick<Db, 'insert' | 'select' | 'update' | 'delete'>;
type AgentRow = typeof agents.$inferSelect;
type FenceRow = typeof agentIdempotencyFence.$inferSelect;
/** Raised inside the transaction when the fence insert lost a same-key race (§3.1 rule 5 concurrency). */
class ConcurrentEnrollmentError extends Error {
constructor() {
super('concurrent enrollment lost the fence race');
this.name = 'ConcurrentEnrollmentError';
}
}
function agentView(row: AgentRow): EnrolledAgentView {
return {
id: row.id,
name: row.name,
provider: row.provider,
model: row.model,
status: row.status,
harness: row.harness,
persona: row.systemPrompt,
ownerId: row.ownerId,
enrolledAt: row.enrolledAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
/** Key-order-independent serialization (jsonb precedent in hierarchy-audit). */
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);
}
interface NormalizedEnrollment {
readonly actorId: string;
readonly harness: string;
readonly name: string;
readonly persona: string | null;
readonly model: string;
readonly provider: string;
readonly credential: EnrollCredentialInput;
readonly idempotencyKey: string;
readonly correlationId: string;
readonly digest: string;
}
/**
* Canonicalized-payload digest (§3.1 rule 5). The input EXCLUDES the
* credential value by construction: it covers mode and declared type only —
* plaintext never reaches the hash.
*/
function digestOf(
input: Omit<NormalizedEnrollment, 'actorId' | 'idempotencyKey' | 'correlationId' | 'digest'>,
): string {
const canonical = canonicalJson({
harness: input.harness,
name: input.name,
persona: input.persona,
model: input.model,
provider: input.provider,
credential: { mode: input.credential.mode, type: input.credential.type ?? null },
});
return createHash('sha256').update(canonical).digest('hex');
}
@Injectable()
export class EnrollmentRepository {
private readonly logger = new Logger(EnrollmentRepository.name);
constructor(
@Inject(DB) private readonly db: Db,
@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry,
) {}
async enroll(input: EnrollAgentInput): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
const correlationId = input.correlationId ?? randomUUID();
const fail = (error: EnrollmentErrorCode, message: string): EnrollmentFailure => ({
ok: false,
error,
message,
correlationId,
});
const harness = input.harness.trim();
const name = input.name.trim();
if (harness.length === 0) return fail('validation_failed', 'harness must be non-empty');
if (name.length === 0 || name.length > 200) {
return fail('validation_failed', 'name must be non-empty and at most 200 characters');
}
if (input.replayMode !== undefined && input.replayMode !== 'actor-bound') {
// Seed-only rule (contract 3 §4.3): refused with nothing executed and no fence row.
return fail('validation_failed', 'replayMode must be actor-bound');
}
if (input.credential.mode === 'reference') {
if (input.credential.type !== undefined || input.credential.value !== undefined) {
return fail('validation_failed', 'a reference credential carries no type or value');
}
} else if (
input.credential.type !== 'api_key' ||
typeof input.credential.value !== 'string' ||
input.credential.value.length === 0
) {
return fail('validation_failed', 'an intake credential requires type api_key and a value');
}
// Syntactic validity ends above; a well-formed name the live registry
// does not know is a precondition failure (§3.1 table).
if (!this.registry.has(harness)) {
return fail('precondition_failed', 'harness is not registered');
}
const normalized: NormalizedEnrollment = {
actorId: input.actorId,
harness,
name,
persona: input.persona ?? null,
model: input.model,
provider: input.provider,
credential: input.credential,
idempotencyKey: input.idempotencyKey,
correlationId,
digest: digestOf({
harness,
name,
persona: input.persona ?? null,
model: input.model,
provider: input.provider,
credential: input.credential,
}),
};
// Two attempts: a fence-race loser's transaction rolls back and the retry
// resolves through the replay path against the winner's committed row —
// or executes afresh if the winner aborted (§3.1 rule 5 concurrency). A
// unique-violation race never surfaces as an unhandled internal fault.
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
return await this.db.transaction(async (tx) => this.enrollTx(tx, normalized));
} catch (error) {
if (error instanceof ConcurrentEnrollmentError && attempt === 0) continue;
if (error instanceof ConcurrentEnrollmentError) {
return fail('conflict', CONFLICT_MESSAGE);
}
// §4.4 fail-closed: whatever broke, the transaction rolled back and
// the refusal is the internal-fault class — no fallback write or read.
this.logger.error(
`agent.enroll failed closed (correlation=${correlationId}): ${
error instanceof Error ? error.name : 'unknown error'
}`,
);
return fail('internal_fault', 'internal fault');
}
}
return fail('internal_fault', 'internal fault');
}
private async enrollTx(
tx: Tx,
input: NormalizedEnrollment,
): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
const fence = await this.fenceFor(tx, input.idempotencyKey);
if (fence) return this.replay(tx, fence, input);
if (input.credential.mode === 'reference') {
// §3.1 rule 3: the reference must resolve for (actor, provider).
const existing = await tx
.select({ id: providerCredentials.id })
.from(providerCredentials)
.where(
and(
eq(providerCredentials.userId, input.actorId),
eq(providerCredentials.provider, input.provider),
),
)
.limit(1);
if (existing.length === 0) {
return {
ok: false,
error: 'precondition_failed',
message: 'credential reference does not resolve',
correlationId: input.correlationId,
};
}
} else {
// §3.1 rule 2: sealed-store write inside THIS transaction — a later
// failure rolls it back, leaving no orphan credential.
await this.writeSealedCredential(
tx,
input.actorId,
input.provider,
input.credential.value as string,
);
}
const agentRow = await this.insertAgentRow(tx, input);
const fenceRow = await this.insertFenceRow(tx, input, agentRow.id);
if (!fenceRow) {
// A same-(operation, key) winner committed first; abandon our writes.
throw new ConcurrentEnrollmentError();
}
await this.appendEvent(tx, {
eventType: 'agent.enrolled',
actorId: input.actorId,
agentId: agentRow.id,
correlationId: input.correlationId,
// §3.1 rule 6 payload: harness, provider, name, credentialMode — no credential material.
payload: {
harness: input.harness,
provider: input.provider,
name: input.name,
credentialMode: input.credential.mode,
},
});
return { ok: true, correlationId: input.correlationId, agent: agentView(agentRow) };
}
/**
* Replay path (§3.1 rule 5): a fresh submission of a recorded
* (operation, key). The actor is re-authorized exactly as a fresh
* submission (v1: authenticated actor — the guard already ran); then mode,
* scope, digest, and recorded-actor equality; then target-result read
* authority (owner or admin) on the referenced agent. ANY failure refuses
* with the single bounded conflict shape — constant, identifying no record.
* A passing replay executes nothing and appends only the non-mutation
* access event (with its outbox record — one outbox row per event).
*/
private async replay(
tx: Tx,
fence: FenceRow,
input: NormalizedEnrollment,
): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
const collision: EnrollmentFailure = {
ok: false,
error: 'conflict',
message: CONFLICT_MESSAGE,
correlationId: input.correlationId,
};
if (fence.replayMode !== 'actor-bound') return collision;
if (fence.authorizationScope !== AUTHORIZATION_SCOPE) return collision;
if (fence.payloadDigest !== input.digest) return collision;
if (fence.actorId !== input.actorId) return collision;
const rows = await tx.select().from(agents).where(eq(agents.id, fence.outcomeAgentId)).limit(1);
const agentRow = rows[0];
if (!agentRow) return collision;
const authorized =
agentRow.ownerId === input.actorId || (await this.isPlatformAdmin(tx, input.actorId));
if (!authorized) return collision;
await this.appendEvent(tx, {
eventType: 'agent.enrollment.replayed',
actorId: input.actorId,
agentId: agentRow.id,
correlationId: input.correlationId,
payload: { fenceId: fence.id },
});
return { ok: true, correlationId: input.correlationId, agent: agentView(agentRow) };
}
/**
* agent.enrollment.get (§3.2): owner-or-admin read. Unauthorized and
* missing fold to the same not_found wire shape (no existence oracle).
*/
async getEnrollment(
actorId: string,
agentId: string,
correlationId?: string,
): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
const resolvedCorrelation = correlationId ?? randomUUID();
try {
const rows = await this.db.select().from(agents).where(eq(agents.id, agentId)).limit(1);
const row = rows[0];
if (row) {
const authorized =
row.ownerId === actorId || (await this.isPlatformAdmin(this.db, actorId));
if (authorized) {
return { ok: true, correlationId: resolvedCorrelation, agent: agentView(row) };
}
}
return {
ok: false,
error: 'not_found',
message: NOT_FOUND_MESSAGE,
correlationId: resolvedCorrelation,
};
} catch (error) {
this.logger.error(
`agent.enrollment.get failed closed (correlation=${resolvedCorrelation}): ${
error instanceof Error ? error.name : 'unknown error'
}`,
);
return {
ok: false,
error: 'internal_fault',
message: 'internal fault',
correlationId: resolvedCorrelation,
};
}
}
private async fenceFor(tx: Tx, idempotencyKey: string): Promise<FenceRow | null> {
const rows = await tx
.select()
.from(agentIdempotencyFence)
.where(
and(
eq(agentIdempotencyFence.operation, ENROLLMENT_OPERATION),
eq(agentIdempotencyFence.idempotencyKey, idempotencyKey),
),
)
.limit(1);
return rows[0] ?? null;
}
private async isPlatformAdmin(tx: Tx, actorId: string): Promise<boolean> {
const rows = await tx
.select({ role: users.role })
.from(users)
.where(eq(users.id, actorId))
.limit(1);
return rows[0]?.role === 'admin';
}
/**
* Sealed intake write, mirroring ProviderCredentialsService.store semantics
* (seal-at-rest, one row per (userId, provider)) but on the enrollment
* transaction (§3.1 rule 2). The plaintext exists only in this frame.
*/
async writeSealedCredential(
tx: Tx,
userId: string,
provider: string,
value: string,
): Promise<void> {
const encryptedValue = seal(value);
await tx
.insert(providerCredentials)
.values({ userId, provider, credentialType: 'api_key', encryptedValue, metadata: null })
.onConflictDoUpdate({
target: [providerCredentials.userId, providerCredentials.provider],
set: {
credentialType: 'api_key',
encryptedValue,
metadata: null,
updatedAt: new Date(),
},
});
}
async insertAgentRow(tx: Tx, input: NormalizedEnrollment): Promise<AgentRow> {
const rows = await tx
.insert(agents)
.values({
name: input.name,
provider: input.provider,
model: input.model,
harness: input.harness,
systemPrompt: input.persona,
// §3.1 rule 4: owner is the authenticated actor; is_system stays default false.
ownerId: input.actorId,
enrolledAt: new Date(),
})
.returning();
const row = rows[0];
if (!row) throw new Error('agent insert returned no row');
return row;
}
async insertFenceRow(
tx: Tx,
input: NormalizedEnrollment,
outcomeAgentId: string,
): Promise<FenceRow | null> {
const rows = await tx
.insert(agentIdempotencyFence)
.values({
operation: ENROLLMENT_OPERATION,
idempotencyKey: input.idempotencyKey,
actorId: input.actorId,
authorizationScope: AUTHORIZATION_SCOPE,
payloadDigest: input.digest,
replayMode: 'actor-bound',
outcomeAgentId,
})
.onConflictDoNothing()
.returning();
return rows[0] ?? null;
}
/** Append one audit event and its outbox record on the caller's transaction (one outbox row per event). */
async appendEvent(
tx: Tx,
input: {
eventType: 'agent.enrolled' | 'agent.enrollment.replayed';
actorId: string;
agentId: string;
correlationId: string;
payload: Record<string, unknown>;
causationId?: string;
},
): Promise<void> {
const inserted = await tx
.insert(agentAuditEvents)
.values({
eventType: input.eventType,
actorId: input.actorId,
agentId: input.agentId,
correlationId: input.correlationId,
causationId: input.causationId ?? null,
payload: input.payload,
})
.returning();
const event = inserted[0];
if (!event) throw new Error('agent audit event insert returned no row');
await this.insertOutboxRow(tx, event.id, input.correlationId);
}
async insertOutboxRow(tx: Tx, eventId: string, correlationId: string): Promise<void> {
await tx.insert(agentOutbox).values({ eventId, correlationId });
}
}
@@ -1,45 +0,0 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import type {
EnrollmentErrorCode,
EnrollmentFailure,
EnrollmentResult,
} from './enrollment.repository.js';
/**
* Maps enrollment result unions onto the closed HTTP status set (design
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3.3, contract 5
* §4.2). Every refusal body carries the correlation id (contract 5 §4.3
* end-to-end traceability) alongside the enum code. `not_found` carries one
* fixed message for every cause — missing agent and unauthorized caller are
* indistinguishable on the wire (§3.2).
*/
const HTTP_STATUS: Record<EnrollmentErrorCode, HttpStatus> = {
validation_failed: HttpStatus.BAD_REQUEST,
authentication_failed: HttpStatus.UNAUTHORIZED,
authorization_refused: HttpStatus.FORBIDDEN,
not_found: HttpStatus.NOT_FOUND,
conflict: HttpStatus.CONFLICT,
precondition_failed: HttpStatus.UNPROCESSABLE_ENTITY,
internal_fault: HttpStatus.INTERNAL_SERVER_ERROR,
};
@Injectable()
export class EnrollmentService {
unwrap<T>(result: EnrollmentResult<T>): { ok: true; correlationId: string } & T {
if (result.ok) return result;
throw this.toException(result);
}
private toException(failure: EnrollmentFailure): HttpException {
const status = HTTP_STATUS[failure.error];
return new HttpException(
{
statusCode: status,
error: failure.error,
message: failure.message,
correlationId: failure.correlationId,
},
status,
);
}
}
@@ -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 repository (HierarchyRepository) is the allowlisted
* writer and calls into this on its 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,965 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import {
companies,
createPgliteDb,
eq,
estates,
hierarchyAuditEvents,
hierarchyGrants,
hierarchyOutbox,
runPgliteMigrations,
teams,
users,
workspaces,
type DbHandle,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { appendHierarchyEvent } from './hierarchy-audit.repository.js';
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
import { HierarchyRepository, type HierarchyResult } from './hierarchy.repository.js';
/**
* Command-level witnesses for the hierarchy command family (M4-1b-ii):
* contract 1 §6.4 (per-mutation-class commit + rollback), §6.5
* (authorization outcomes), §6.7 (no existence oracle), §6.9 (visibility),
* and contract 2 §3 grant-evaluation semantics (deny-by-default,
* ancestor-chain inheritance, max-role, live revocation, suspended team
* subjects). Schema-level constraints are witnessed in
* packages/db/src/hierarchy-schema.witness.test.ts; the audit machinery's
* own atomicity in hierarchy-audit.integration.test.ts.
*
* The rollback legs pre-seed an audit event under the command's idempotency
* key with different content: the command's append then throws inside the
* command transaction, so the whole mutation must roll back — the command
* returns `conflict` and leaves no state change, no second event, and no
* second outbox record.
*/
describe('hierarchy commands integration', (): void => {
let dataDir: string;
let handle: DbHandle;
let moduleRef: TestingModule;
let repo: HierarchyRepository;
let evaluation: HierarchyGrantEvaluationService;
const OWNER = 'hier-cmd-owner';
const ADMIN = 'hier-cmd-admin';
const STRANGER = 'hier-cmd-stranger';
const SUBJECT = 'hier-cmd-subject';
/** Base fixture: OWNER's company (created through the command surface). */
let companyId: string;
const slug = (prefix: string): string => `${prefix}-${randomUUID().slice(0, 8)}`;
function expectOk<T>(result: HierarchyResult<T>): { ok: true } & T {
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
return result;
}
const eventsForKey = (key: string) =>
handle.db
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
const outboxForKey = (key: string) =>
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key));
/** Occupy `key` with unrelated event content so a command reusing it must abort. */
const seedConflictingKey = async (key: string): Promise<void> => {
await handle.db.transaction(async (tx) =>
appendHierarchyEvent(tx, {
actorId: 'seed-actor',
verb: 'create',
targetKind: 'company',
targetId: randomUUID(),
targetSnapshot: { seeded: true },
correlationId: 'seed-correlation',
idempotencyKey: key,
}),
);
};
/**
* §6.4 rollback leg: the command must return `conflict` and leave exactly
* the seeded event/outbox pair under the key — nothing it wrote survives.
*/
const expectRolledBack = async <T>(
key: string,
command: () => Promise<HierarchyResult<T>>,
assertUnchanged: () => Promise<void>,
): Promise<void> => {
await seedConflictingKey(key);
const result = await command();
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBe('conflict');
expect(await eventsForKey(key)).toHaveLength(1);
expect(await outboxForKey(key)).toHaveLength(1);
await assertUnchanged();
};
beforeAll(async (): Promise<void> => {
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-commands-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
moduleRef = await Test.createTestingModule({
providers: [
HierarchyRepository,
HierarchyGrantEvaluationService,
{ provide: DB, useValue: handle.db },
],
}).compile();
repo = moduleRef.get(HierarchyRepository);
evaluation = moduleRef.get(HierarchyGrantEvaluationService);
await handle.db.insert(users).values([
{ id: OWNER, name: 'Owner', email: `${OWNER}@example.com` },
{ id: ADMIN, name: 'Admin', email: `${ADMIN}@example.com`, role: 'admin' },
{ id: STRANGER, name: 'Stranger', email: `${STRANGER}@example.com` },
{ id: SUBJECT, name: 'Subject', email: `${SUBJECT}@example.com` },
]);
const created = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Base Co', slug: slug('base') }),
);
companyId = created.company.id;
});
afterAll(async (): Promise<void> => {
await moduleRef.close();
await handle.close();
await rm(dataDir, { recursive: true, force: true });
});
// ── §6.4 commit legs ───────────────────────────────────────────────────────
it('createCompany commits company, owner grant, causation-linked events, and outbox atomically', async () => {
const key = `key-${randomUUID()}`;
const result = expectOk(
await repo.createCompany({
actorId: OWNER,
name: 'Atomic Co',
slug: slug('atomic'),
idempotencyKey: key,
}),
);
expect(result.company.visibility).toBe('private');
expect(result.grant.role).toBe('hierarchy:owner');
expect(result.grant.userId).toBe(OWNER);
expect(result.grant.grantedBy).toBe(OWNER);
const [createEvents, grantEvents] = await Promise.all([
eventsForKey(key),
eventsForKey(`${key}:grant`),
]);
expect(createEvents).toHaveLength(1);
expect(createEvents[0]).toMatchObject({ verb: 'create', targetId: result.company.id });
expect(grantEvents).toHaveLength(1);
expect(grantEvents[0]).toMatchObject({ verb: 'grant_create', targetId: result.grant.id });
// The grant event is caused by the create event, same correlation (§4.3).
expect(grantEvents[0]!.causationId).toBe(createEvents[0]!.id);
expect(grantEvents[0]!.correlationId).toBe(createEvents[0]!.correlationId);
expect(await outboxForKey(key)).toHaveLength(1);
expect(await outboxForKey(`${key}:grant`)).toHaveLength(1);
const rows = await handle.db
.select()
.from(companies)
.where(eq(companies.id, result.company.id));
expect(rows).toHaveLength(1);
});
it('deleteCompany commits the delete with one audited grant_revoke per cascaded grant', async () => {
const created = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Mortal Co', slug: slug('mortal') }),
);
const extraGrant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: created.company.id,
role: 'viewer',
}),
);
const key = `key-${randomUUID()}`;
expectOk(
await repo.deleteCompany({
actorId: OWNER,
companyId: created.company.id,
idempotencyKey: key,
}),
);
const deleteEvents = await eventsForKey(key);
expect(deleteEvents).toHaveLength(1);
expect(deleteEvents[0]).toMatchObject({ verb: 'delete', targetId: created.company.id });
for (const grantId of [created.grant.id, extraGrant.grant.id]) {
const revokeEvents = await eventsForKey(`${key}:revoke:${grantId}`);
expect(revokeEvents).toHaveLength(1);
expect(revokeEvents[0]).toMatchObject({ verb: 'grant_revoke', targetId: grantId });
expect(revokeEvents[0]!.causationId).toBe(deleteEvents[0]!.id);
}
expect(
await handle.db.select().from(companies).where(eq(companies.id, created.company.id)),
).toHaveLength(0);
});
// ── §6.4 rollback legs (one per mutation class) ────────────────────────────
it('renameCompany commits the rename with an audited event carrying previousName', async () => {
const created = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Old Name Co', slug: slug('rename') }),
);
const key = `key-${randomUUID()}`;
const renamed = expectOk(
await repo.renameCompany({
actorId: OWNER,
companyId: created.company.id,
name: 'New Name Co',
idempotencyKey: key,
}),
);
expect(renamed.company.name).toBe('New Name Co');
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'rename', targetId: created.company.id });
// §6.4: the audited rename carries the old and new names.
expect(events[0]!.targetSnapshot).toMatchObject({
name: 'New Name Co',
previousName: 'Old Name Co',
});
expect(await outboxForKey(key)).toHaveLength(1);
const rows = await handle.db
.select()
.from(companies)
.where(eq(companies.id, created.company.id));
expect(rows[0]!.name).toBe('New Name Co');
});
it('revokeGrant commits the row deletion with one audited grant_revoke event', async () => {
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
);
const key = `key-${randomUUID()}`;
const revoked = expectOk(
await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: key }),
);
expect(revoked.revokedId).toBe(grant.grant.id);
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'grant_revoke', targetId: grant.grant.id });
expect(await outboxForKey(key)).toHaveLength(1);
// §6 revocation = row deletion: the grant row is gone.
const rows = await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, grant.grant.id));
expect(rows).toHaveLength(0);
});
it('rolls back a create: no estate row survives the aborted transaction', async () => {
const estateSlug = slug('rb-create');
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.createEstate({
actorId: OWNER,
companyId,
name: 'Doomed Estate',
slug: estateSlug,
idempotencyKey: key,
}),
async () => {
expect(
await handle.db.select().from(estates).where(eq(estates.slug, estateSlug)),
).toHaveLength(0);
},
);
});
it('rolls back a rename: the company keeps its name', async () => {
const before = (
await handle.db.select().from(companies).where(eq(companies.id, companyId))
)[0]!;
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() => repo.renameCompany({ actorId: OWNER, companyId, name: 'Never', idempotencyKey: key }),
async () => {
const after = (
await handle.db.select().from(companies).where(eq(companies.id, companyId))
)[0]!;
expect(after.name).toBe(before.name);
},
);
});
it('rolls back a visibility change: the company stays private', async () => {
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.changeCompanyVisibility({
actorId: ADMIN,
companyId,
visibility: 'directory',
idempotencyKey: key,
}),
async () => {
const after = (
await handle.db.select().from(companies).where(eq(companies.id, companyId))
)[0]!;
expect(after.visibility).toBe('private');
},
);
});
it('rolls back a transfer: the estate keeps its parent', async () => {
const estate = expectOk(
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-T', slug: slug('rb-t') }),
);
const other = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'RB Dest', slug: slug('rb-dest') }),
);
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: other.company.id,
idempotencyKey: key,
}),
async () => {
const after = (
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
)[0]!;
expect(after.companyId).toBe(companyId);
},
);
});
it('rolls back a delete: the estate row survives', async () => {
const estate = expectOk(
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-D', slug: slug('rb-d') }),
);
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() => repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id, idempotencyKey: key }),
async () => {
expect(
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id)),
).toHaveLength(1);
},
);
});
it('rolls back a grant create: no grant row survives', async () => {
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.createGrant({
actorId: OWNER,
userId: STRANGER,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
idempotencyKey: key,
}),
async () => {
const rows = await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.userId, STRANGER));
expect(rows.filter((r) => r.companyId === companyId)).toHaveLength(0);
},
);
});
it('rolls back a grant change and a grant revoke: the grant keeps its role and its row', async () => {
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
);
const changeKey = `key-${randomUUID()}`;
await expectRolledBack(
changeKey,
() =>
repo.changeGrant({
actorId: OWNER,
grantId: grant.grant.id,
role: 'member',
idempotencyKey: changeKey,
}),
async () => {
const row = (
await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, grant.grant.id))
)[0]!;
expect(row.role).toBe('viewer');
},
);
const revokeKey = `key-${randomUUID()}`;
await expectRolledBack(
revokeKey,
() =>
repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: revokeKey }),
async () => {
expect(
await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, grant.grant.id)),
).toHaveLength(1);
},
);
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
});
it('replays a completed command idempotently through the audit machinery', async () => {
const key = `key-${randomUUID()}`;
const input = { actorId: OWNER, companyId, name: 'Replayed Estate', slug: slug('replay') };
const first = expectOk(await repo.createEstate({ ...input, idempotencyKey: key }));
// The retry's insert no-ops on the slug conflict — the command surfaces
// `conflict`, and crucially appends no second event under the key.
const retry = await repo.createEstate({ ...input, idempotencyKey: key });
expect(retry.ok).toBe(false);
expect(await eventsForKey(key)).toHaveLength(1);
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: first.estate.id }));
});
// ── §6.5 authorization ─────────────────────────────────────────────────────
it('deny-by-default: a user with no grant cannot mutate and sees not_found (§3.1)', async () => {
expect(await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' })).toEqual({
ok: false,
error: 'not_found',
});
expect(
await repo.createEstate({ actorId: STRANGER, companyId, name: 'x', slug: slug('deny') }),
).toEqual({ ok: false, error: 'not_found' });
expect(await repo.deleteCompany({ actorId: STRANGER, companyId })).toEqual({
ok: false,
error: 'not_found',
});
});
it('grant management requires effective owner: member and viewer are refused (§4.1)', async () => {
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'member',
}),
);
expect(
await repo.createGrant({
actorId: SUBJECT,
userId: STRANGER,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
).toEqual({ ok: false, error: 'not_found' });
expect(await repo.revokeGrant({ actorId: SUBJECT, grantId: grant.grant.id })).toEqual({
ok: false,
error: 'not_found',
});
// Member also cannot create children (owner-only, §4.1/§4.3).
expect(
await repo.createEstate({ actorId: SUBJECT, companyId, name: 'x', slug: slug('member') }),
).toEqual({ ok: false, error: 'not_found' });
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
});
it('platform admin confers no tenant content access (§1.1): ungrated admin is a stranger', async () => {
expect(await repo.renameCompany({ actorId: ADMIN, companyId, name: 'x' })).toEqual({
ok: false,
error: 'not_found',
});
expect(
await repo.createGrant({
actorId: ADMIN,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
).toEqual({ ok: false, error: 'not_found' });
expect(await repo.listGrantedCompanies(ADMIN)).toEqual([]);
expect(await evaluation.effectiveRole(ADMIN, 'company', companyId)).toBeNull();
});
it('visibility change is platform-admin-only (§5.5): the owner is forbidden, the admin succeeds', async () => {
const owned = await repo.changeCompanyVisibility({
actorId: OWNER,
companyId,
visibility: 'directory',
});
expect(owned).toEqual({
ok: false,
error: 'forbidden',
message: 'visibility change is platform-admin-only',
});
const changed = expectOk(
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'directory' }),
);
expect(changed.company.visibility).toBe('directory');
// Restore for later witnesses.
expectOk(
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'private' }),
);
});
// ── §6.9 visibility ────────────────────────────────────────────────────────
it('directory lists exactly directory-class companies with closed fields (§2.8)', async () => {
const listed = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Listed Co', slug: slug('listed') }),
);
const unlisted = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Unlisted Co', slug: slug('unlisted') }),
);
const key = `key-${randomUUID()}`;
expectOk(
await repo.changeCompanyVisibility({
actorId: ADMIN,
companyId: listed.company.id,
visibility: 'directory',
idempotencyKey: key,
}),
);
const directory = await repo.listDirectory();
const ids = directory.map((entry) => entry.id);
expect(ids).toContain(listed.company.id);
expect(ids).not.toContain(unlisted.company.id);
expect(ids).not.toContain(companyId);
// Closed-field: existence, name, slug — nothing else (no visibility, no
// timestamps, no grant or membership data).
for (const entry of directory) {
expect(Object.keys(entry).sort()).toEqual(['id', 'name', 'slug']);
}
// §5.5: the audited event carries old and new values.
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'visibility_change', targetId: listed.company.id });
expect(events[0]!.targetSnapshot).toMatchObject({
previousVisibility: 'private',
visibility: 'directory',
});
});
it('directory disclosure confers no authority: a listed company still refuses non-granted callers (§6.9)', async () => {
const listed = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Exposed Co', slug: slug('exposed') }),
);
expectOk(
await repo.changeCompanyVisibility({
actorId: ADMIN,
companyId: listed.company.id,
visibility: 'directory',
}),
);
// The company is directory-listed for the whole probe window...
expect((await repo.listDirectory()).map((entry) => entry.id)).toContain(listed.company.id);
// ...but the non-granted reader's granted-read surface still excludes it:
// directory disclosure adds existence/name/slug only, never content access.
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
// A stranger mutation of the listed company is refused exactly like a
// missing node — the §6.7 carve-out covers the listing, not commands.
const realProbe = await repo.renameCompany({
actorId: STRANGER,
companyId: listed.company.id,
name: 'x',
});
const missingProbe = await repo.renameCompany({
actorId: STRANGER,
companyId: randomUUID(),
name: 'x',
});
expect(realProbe).toEqual(missingProbe);
expect(await repo.deleteCompany({ actorId: STRANGER, companyId: listed.company.id })).toEqual({
ok: false,
error: 'not_found',
});
});
it('granted companies are the reader control: owner sees them, a stranger sees nothing (§2.8)', async () => {
const ownerCompanies = await repo.listGrantedCompanies(OWNER);
expect(ownerCompanies.map((c) => c.id)).toContain(companyId);
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
});
// ── §6.7 no existence oracle ───────────────────────────────────────────────
it('an unauthorized probe of a real node is indistinguishable from a missing node', async () => {
const realCompany = await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' });
const missingCompany = await repo.renameCompany({
actorId: STRANGER,
companyId: randomUUID(),
name: 'x',
});
expect(realCompany).toEqual(missingCompany);
const estate = expectOk(
await repo.createEstate({
actorId: OWNER,
companyId,
name: 'Oracle E',
slug: slug('oracle'),
}),
);
const realEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: estate.estate.id });
const missingEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: randomUUID() });
expect(realEstate).toEqual(missingEstate);
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'estate',
targetId: estate.estate.id,
role: 'viewer',
}),
);
const realGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: grant.grant.id });
const missingGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: randomUUID() });
expect(realGrant).toEqual(missingGrant);
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
});
// ── contract 2 §3 grant evaluation ─────────────────────────────────────────
it('a company grant confers its role down the whole chain, workspace included (§3.2)', async () => {
const estate = expectOk(
await repo.createEstate({ actorId: OWNER, companyId, name: 'Chain E', slug: slug('chain') }),
);
const project = expectOk(
await repo.createPlatformProject({
actorId: OWNER,
estateId: estate.estate.id,
name: 'Chain P',
slug: slug('chain-p'),
}),
);
// Workspaces are evaluable but not hierarchy commands; seed one directly.
const workspaceId = randomUUID();
await handle.db.insert(workspaces).values({
id: workspaceId,
name: 'Chain W',
slug: slug('chain-w'),
platformProjectId: project.platformProject.id,
});
for (const [kind, id] of [
['company', companyId],
['estate', estate.estate.id],
['platform_project', project.platformProject.id],
['workspace', workspaceId],
] as const) {
expect(await evaluation.effectiveRole(OWNER, kind, id)).toBe('owner');
expect(await evaluation.effectiveRole(STRANGER, kind, id)).toBeNull();
}
// Max-role (§3.3): viewer on the company + owner on the estate → owner at
// and below the estate, viewer at the company.
const viewerGrant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
);
const ownerGrant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'estate',
targetId: estate.estate.id,
role: 'owner',
}),
);
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBe('viewer');
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('owner');
expect(await evaluation.effectiveRole(SUBJECT, 'workspace', workspaceId)).toBe('owner');
// Revocation is row deletion and denies the very next evaluation (§6).
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: ownerGrant.grant.id }));
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('viewer');
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: viewerGrant.grant.id }));
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
await handle.db.delete(workspaces).where(eq(workspaces.id, workspaceId));
expectOk(
await repo.deletePlatformProject({
actorId: OWNER,
platformProjectId: project.platformProject.id,
}),
);
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
});
it('team grant subjects are suspended: a team row confers nothing and cannot be changed (§1.4)', async () => {
const teamId = randomUUID();
await handle.db.insert(teams).values({
id: teamId,
name: slug('team'),
slug: slug('team'),
ownerId: SUBJECT,
managerId: SUBJECT,
});
// Out-of-band team row (the command surface cannot create one).
const inserted = await handle.db
.insert(hierarchyGrants)
.values({ teamId, companyId, role: 'owner', grantedBy: OWNER })
.returning();
const teamGrantId = inserted[0]!.id;
// The team's own owner gains no effective role from it.
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
// changeGrant refuses the row.
expect(
await repo.changeGrant({ actorId: OWNER, grantId: teamGrantId, role: 'viewer' }),
).toEqual({
ok: false,
error: 'conflict',
message: 'team grant subjects are suspended',
});
await handle.db.delete(hierarchyGrants).where(eq(hierarchyGrants.id, teamGrantId));
await handle.db.delete(teams).where(eq(teams.id, teamId));
});
// ── command conflict semantics ─────────────────────────────────────────────
it('transfer needs owner on both parents in its own transaction, and refuses no-op and colliding transfers (§5)', async () => {
const source = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Src Co', slug: slug('src') }),
);
const destination = expectOk(
await repo.createCompany({ actorId: SUBJECT, name: 'Dst Co', slug: slug('dst') }),
);
const estateSlug = slug('mv');
const estate = expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: source.company.id,
name: 'Mv E',
slug: estateSlug,
}),
);
// OWNER owns the source but not the destination → not_found (§6.7-safe).
expect(
await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: destination.company.id,
}),
).toEqual({ ok: false, error: 'not_found' });
// Same-parent transfer is refused.
const samePlace = await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: source.company.id,
});
expect(samePlace.ok).toBe(false);
if (!samePlace.ok) expect(samePlace.error).toBe('conflict');
// Grant OWNER the destination; a slug collision there is refused.
expectOk(
await repo.createGrant({
actorId: SUBJECT,
userId: OWNER,
targetKind: 'company',
targetId: destination.company.id,
role: 'owner',
}),
);
expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: destination.company.id,
name: 'Collide',
slug: estateSlug,
}),
);
const collision = await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: destination.company.id,
});
expect(collision.ok).toBe(false);
if (!collision.ok) expect(collision.error).toBe('conflict');
});
it('a successful transfer records transfer_from and transfer_to (§6.4 three-leg witness)', async () => {
const from = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'From Co', slug: slug('from') }),
);
const to = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'To Co', slug: slug('to') }),
);
const estate = expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: from.company.id,
name: 'Moved E',
slug: slug('moved'),
}),
);
const key = `key-${randomUUID()}`;
expectOk(
await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: to.company.id,
idempotencyKey: key,
}),
);
const moved = (
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
)[0]!;
expect(moved.companyId).toBe(to.company.id);
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'transfer', targetId: estate.estate.id });
expect(events[0]!.transferFrom).toMatchObject({ kind: 'company', id: from.company.id });
expect(events[0]!.transferTo).toMatchObject({ kind: 'company', id: to.company.id });
// The post-transfer snapshot's parent chain names the destination.
expect(events[0]!.targetSnapshot).toMatchObject({
parentChain: [{ kind: 'company', id: to.company.id, slug: to.company.slug }],
});
});
it('refuses duplicate slugs, deletes with children, and degenerate grant commands as conflicts', async () => {
const co = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Conflict Co', slug: slug('conf') }),
);
const dupSlug = await repo.createCompany({ actorId: OWNER, name: 'x', slug: co.company.slug });
expect(dupSlug.ok).toBe(false);
if (!dupSlug.ok) expect(dupSlug.error).toBe('conflict');
expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: co.company.id,
name: 'Child',
slug: slug('child'),
}),
);
const withChildren = await repo.deleteCompany({ actorId: OWNER, companyId: co.company.id });
expect(withChildren.ok).toBe(false);
if (!withChildren.ok) expect(withChildren.error).toBe('conflict');
// Grant to a nonexistent subject is refused (the caller already holds
// owner, so the refusal discloses nothing new).
const ghost = await repo.createGrant({
actorId: OWNER,
userId: `missing-${randomUUID()}`,
targetKind: 'company',
targetId: co.company.id,
role: 'viewer',
});
expect(ghost.ok).toBe(false);
if (!ghost.ok) expect(ghost.error).toBe('conflict');
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: co.company.id,
role: 'viewer',
}),
);
const duplicate = await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: co.company.id,
role: 'viewer',
});
expect(duplicate.ok).toBe(false);
if (!duplicate.ok) expect(duplicate.error).toBe('conflict');
const sameRole = await repo.changeGrant({
actorId: OWNER,
grantId: grant.grant.id,
role: 'viewer',
});
expect(sameRole.ok).toBe(false);
if (!sameRole.ok) expect(sameRole.error).toBe('conflict');
// A second grant with another role exists → changing the first onto that
// role would collide with the unique constraint; refused ahead of it.
const second = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: co.company.id,
role: 'member',
}),
);
const collide = await repo.changeGrant({
actorId: OWNER,
grantId: grant.grant.id,
role: 'member',
});
expect(collide.ok).toBe(false);
if (!collide.ok) expect(collide.error).toBe('conflict');
// A clean change succeeds and records the previous role, namespaced (§4.5).
const changeKey = `key-${randomUUID()}`;
const changed = expectOk(
await repo.changeGrant({
actorId: OWNER,
grantId: second.grant.id,
role: 'owner',
idempotencyKey: changeKey,
}),
);
expect(changed.grant.role).toBe('hierarchy:owner');
const events = await eventsForKey(changeKey);
expect(events).toHaveLength(1);
expect(events[0]!.targetSnapshot).toMatchObject({
role: 'hierarchy:owner',
previousRole: 'hierarchy:member',
});
});
});
@@ -1,242 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import {
companies,
eq,
estates,
hierarchyGrants,
inArray,
or,
platformProjects,
workspaces,
and,
type Db,
HIERARCHY_GRANT_ROLES,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
/**
* Hierarchy grant evaluation (contract 2 §3).
*
* Deny-by-default (§3.1): a user's effective role on a node is null unless a
* grant row explicitly confers one. Grants apply down the chain only (§3.2):
* the effective role on a node is the maximum role over grants targeting the
* node itself or any of its ancestors, maximum per the total order
* viewer ⊂ member ⊂ owner (§2). Evaluation is live and per-decision — no
* caching — so revocation (row deletion, §6) denies the next decision
* inherently. A missing node evaluates to null, indistinguishable from
* no-grant, which keeps unauthorized probes oracle-safe (contract 1 §6.7).
*
* Team grant subjects are SUSPENDED (§1.4): the command surface refuses to
* create them and this evaluator considers user-subject grants only, so a
* team row could not confer access even if one existed.
*
* Read-only module: it selects from the class tables but never writes them,
* so it does not appear on the writer-coverage allowlist.
*/
export type HierarchyGrantRole = (typeof HIERARCHY_GRANT_ROLES)[number];
/** Node kinds a grant may target (§3.2; workspace is evaluable, not grantable). */
export type GrantTargetKind = 'company' | 'estate' | 'platform_project';
/** Node kinds an authorization decision may be evaluated at (§3.2: down to workspace). */
export type EvaluableNodeKind = GrantTargetKind | 'workspace';
type Tx = Pick<Db, 'select'>;
/** Ancestor chain of a node, self included at its own level; ids only. */
export interface AncestorChain {
readonly companyId: string;
readonly estateId?: string;
readonly platformProjectId?: string;
readonly workspaceId?: string;
}
export function roleStrength(role: HierarchyGrantRole): number {
return HIERARCHY_GRANT_ROLES.indexOf(role);
}
export function roleAtLeast(
role: HierarchyGrantRole | null,
required: HierarchyGrantRole,
): boolean {
return role !== null && roleStrength(role) >= roleStrength(required);
}
/**
* Serialized role strings are namespaced (§4.5): audit events and API
* responses carry `hierarchy:owner`, never a bare `owner`.
*/
export function namespacedHierarchyRole(role: HierarchyGrantRole): string {
return `hierarchy:${role}`;
}
/**
* Resolve a node's ancestor chain (self included). Returns null when the
* node does not exist — callers treat that exactly like no-grant (§3.1,
* oracle-safe).
*/
export async function resolveAncestorChain(
tx: Tx,
kind: EvaluableNodeKind,
id: string,
): Promise<AncestorChain | null> {
if (kind === 'company') {
const rows = await tx
.select({ id: companies.id })
.from(companies)
.where(eq(companies.id, id))
.limit(1);
const row = rows[0];
return row ? { companyId: row.id } : null;
}
if (kind === 'estate') {
const rows = await tx
.select({ id: estates.id, companyId: estates.companyId })
.from(estates)
.where(eq(estates.id, id))
.limit(1);
const row = rows[0];
return row ? { companyId: row.companyId, estateId: row.id } : null;
}
if (kind === 'platform_project') {
const rows = await tx
.select({
id: platformProjects.id,
estateId: platformProjects.estateId,
companyId: estates.companyId,
})
.from(platformProjects)
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
.where(eq(platformProjects.id, id))
.limit(1);
const row = rows[0];
return row
? { companyId: row.companyId, estateId: row.estateId, platformProjectId: row.id }
: null;
}
const rows = await tx
.select({
id: workspaces.id,
platformProjectId: workspaces.platformProjectId,
estateId: platformProjects.estateId,
companyId: estates.companyId,
})
.from(workspaces)
.innerJoin(platformProjects, eq(platformProjects.id, workspaces.platformProjectId))
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
.where(eq(workspaces.id, id))
.limit(1);
const row = rows[0];
return row
? {
companyId: row.companyId,
estateId: row.estateId,
platformProjectId: row.platformProjectId,
workspaceId: row.id,
}
: null;
}
function maxRole(roles: readonly string[]): HierarchyGrantRole | null {
let best: HierarchyGrantRole | null = null;
for (const candidate of roles) {
// Fail-closed: a value outside the vocabulary confers nothing.
if (!(HIERARCHY_GRANT_ROLES as readonly string[]).includes(candidate)) continue;
const role = candidate as HierarchyGrantRole;
if (best === null || roleStrength(role) > roleStrength(best)) best = role;
}
return best;
}
/**
* Effective role of a user on a node: maximum over the user's grants whose
* target is the node or any ancestor (§3.2); null = deny (§3.1). Missing
* node → null.
*/
export async function evaluateEffectiveRole(
tx: Tx,
userId: string,
kind: EvaluableNodeKind,
id: string,
): Promise<HierarchyGrantRole | null> {
const chain = await resolveAncestorChain(tx, kind, id);
if (!chain) return null;
const targetConditions = [eq(hierarchyGrants.companyId, chain.companyId)];
if (chain.estateId) targetConditions.push(eq(hierarchyGrants.estateId, chain.estateId));
if (chain.platformProjectId) {
targetConditions.push(eq(hierarchyGrants.platformProjectId, chain.platformProjectId));
}
const rows = await tx
.select({ role: hierarchyGrants.role })
.from(hierarchyGrants)
.where(and(eq(hierarchyGrants.userId, userId), or(...targetConditions)));
return maxRole(rows.map((r) => r.role));
}
/**
* All companies on which the user holds any effective role, i.e. companies
* with a grant on the company itself or on any descendant (contract 1 §2.8:
* a grant anywhere in the subtree discloses the company's chain upward).
*/
export async function grantedCompanyIds(tx: Tx, userId: string): Promise<string[]> {
const grants = await tx
.select({
companyId: hierarchyGrants.companyId,
estateId: hierarchyGrants.estateId,
platformProjectId: hierarchyGrants.platformProjectId,
})
.from(hierarchyGrants)
.where(eq(hierarchyGrants.userId, userId));
const companyIds = new Set<string>();
const estateIds = new Set<string>();
const platformProjectIds = new Set<string>();
for (const grant of grants) {
if (grant.companyId) companyIds.add(grant.companyId);
else if (grant.estateId) estateIds.add(grant.estateId);
else if (grant.platformProjectId) platformProjectIds.add(grant.platformProjectId);
}
if (platformProjectIds.size > 0) {
const rows = await tx
.select({ estateId: platformProjects.estateId })
.from(platformProjects)
.where(inArray(platformProjects.id, [...platformProjectIds]));
for (const row of rows) estateIds.add(row.estateId);
}
if (estateIds.size > 0) {
const rows = await tx
.select({ companyId: estates.companyId })
.from(estates)
.where(inArray(estates.id, [...estateIds]));
for (const row of rows) companyIds.add(row.companyId);
}
return [...companyIds];
}
@Injectable()
export class HierarchyGrantEvaluationService {
constructor(@Inject(DB) private readonly db: Db) {}
/** Live per-decision evaluation; pass a tx to evaluate inside a command's transaction. */
effectiveRole(
userId: string,
kind: EvaluableNodeKind,
id: string,
tx?: Tx,
): Promise<HierarchyGrantRole | null> {
return evaluateEffectiveRole(tx ?? this.db, userId, kind, id);
}
async hasRole(
userId: string,
kind: EvaluableNodeKind,
id: string,
required: HierarchyGrantRole,
tx?: Tx,
): Promise<boolean> {
return roleAtLeast(await this.effectiveRole(userId, kind, id, tx), required);
}
}
@@ -1,297 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import {
ChangeCompanyVisibilityDto,
ChangeGrantDto,
CreateCompanyDto,
CreateEstateDto,
CreateGrantDto,
CreatePlatformProjectDto,
DeleteNodeDto,
RenameNodeDto,
TransferEstateDto,
TransferPlatformProjectDto,
} from './hierarchy.dto.js';
import { HierarchyRepository } from './hierarchy.repository.js';
import { HierarchyService } from './hierarchy.service.js';
/**
* The hierarchy command family (contract 1 §5, §6.3). This controller is the
* closed HTTP surface over the hierarchy class tables: the route-inventory
* witness asserts these routes and no others exist. Delete commands take an
* optional body (idempotency key) via POST-style DTOs; every mutation is
* audited on its own transaction by the repository.
*/
@Controller('api/hierarchy')
@UseGuards(AuthGuard)
export class HierarchyController {
constructor(
private readonly repository: HierarchyRepository,
private readonly service: HierarchyService,
) {}
// ── companies ────────────────────────────────────────────────────────────
@Post('companies')
async createCompany(@CurrentUser() user: { id: string }, @Body() dto: CreateCompanyDto) {
return this.service.unwrap(
await this.repository.createCompany({
actorId: user.id,
name: dto.name,
slug: dto.slug,
idempotencyKey: dto.idempotencyKey,
}),
);
}
/** Companies the caller holds a grant on (directly or via a descendant). */
@Get('companies')
listGrantedCompanies(@CurrentUser() user: { id: string }) {
return this.repository.listGrantedCompanies(user.id);
}
/** Directory-class companies, closed-field (§2.8). */
@Get('companies/directory')
listDirectory() {
return this.repository.listDirectory();
}
@Post('companies/:id/rename')
@HttpCode(200)
async renameCompany(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenameNodeDto,
) {
return this.service.unwrap(
await this.repository.renameCompany({
actorId: user.id,
companyId: id,
name: dto.name,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('companies/:id/visibility')
@HttpCode(200)
async changeCompanyVisibility(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ChangeCompanyVisibilityDto,
) {
return this.service.unwrap(
await this.repository.changeCompanyVisibility({
actorId: user.id,
companyId: id,
visibility: dto.visibility,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('companies/:id')
async deleteCompany(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.deleteCompany({
actorId: user.id,
companyId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
// ── estates ──────────────────────────────────────────────────────────────
@Post('estates')
async createEstate(@CurrentUser() user: { id: string }, @Body() dto: CreateEstateDto) {
return this.service.unwrap(
await this.repository.createEstate({
actorId: user.id,
companyId: dto.companyId,
name: dto.name,
slug: dto.slug,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('estates/:id/rename')
@HttpCode(200)
async renameEstate(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenameNodeDto,
) {
return this.service.unwrap(
await this.repository.renameEstate({
actorId: user.id,
estateId: id,
name: dto.name,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('estates/:id/transfer')
@HttpCode(200)
async transferEstate(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: TransferEstateDto,
) {
return this.service.unwrap(
await this.repository.transferEstate({
actorId: user.id,
estateId: id,
destinationCompanyId: dto.destinationCompanyId,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('estates/:id')
async deleteEstate(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.deleteEstate({
actorId: user.id,
estateId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
// ── platform projects ────────────────────────────────────────────────────
@Post('platform-projects')
async createPlatformProject(
@CurrentUser() user: { id: string },
@Body() dto: CreatePlatformProjectDto,
) {
return this.service.unwrap(
await this.repository.createPlatformProject({
actorId: user.id,
estateId: dto.estateId,
name: dto.name,
slug: dto.slug,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('platform-projects/:id/rename')
@HttpCode(200)
async renamePlatformProject(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenameNodeDto,
) {
return this.service.unwrap(
await this.repository.renamePlatformProject({
actorId: user.id,
platformProjectId: id,
name: dto.name,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('platform-projects/:id/transfer')
@HttpCode(200)
async transferPlatformProject(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: TransferPlatformProjectDto,
) {
return this.service.unwrap(
await this.repository.transferPlatformProject({
actorId: user.id,
platformProjectId: id,
destinationEstateId: dto.destinationEstateId,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('platform-projects/:id')
async deletePlatformProject(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.deletePlatformProject({
actorId: user.id,
platformProjectId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
// ── grants ───────────────────────────────────────────────────────────────
@Post('grants')
async createGrant(@CurrentUser() user: { id: string }, @Body() dto: CreateGrantDto) {
return this.service.unwrap(
await this.repository.createGrant({
actorId: user.id,
userId: dto.userId,
targetKind: dto.targetKind,
targetId: dto.targetId,
role: dto.role,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('grants/:id/change')
@HttpCode(200)
async changeGrant(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ChangeGrantDto,
) {
return this.service.unwrap(
await this.repository.changeGrant({
actorId: user.id,
grantId: id,
role: dto.role,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('grants/:id')
async revokeGrant(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.revokeGrant({
actorId: user.id,
grantId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
}
-169
View File
@@ -1,169 +0,0 @@
import { COMPANY_VISIBILITY, HIERARCHY_GRANT_ROLES } from '@mosaicstack/db';
import { IsIn, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
/**
* Hierarchy command DTOs (contract 1 §5, contract 2 §4/§7).
*
* The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so a
* payload field absent from these classes is a 400. That closure is itself
* contract surface:
* - CreateCompanyDto declares NO visibility field — creation is always
* private (contract 1 §5.5); a visibility argument is refused by the pipe.
* - CreateGrantDto declares NO teamId field — team grant subjects are
* suspended (contract 2 §1.4/§7.5); a team subject is refused by the pipe.
* Every class here must be registered in PIPE_GUARDED_DTOS so the boot-time
* assertion proves the pipe sees the decorators.
*/
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
const SLUG_MESSAGE = 'slug must be lowercase alphanumeric with interior hyphens';
export class CreateCompanyDto {
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(100)
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
slug!: string;
/** Client-supplied idempotency key (REQ-AUD-001 replay); server-generated when absent. */
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class RenameNodeDto {
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class ChangeCompanyVisibilityDto {
@IsIn(COMPANY_VISIBILITY)
visibility!: (typeof COMPANY_VISIBILITY)[number];
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class DeleteNodeDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class CreateEstateDto {
@IsUUID()
companyId!: string;
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(100)
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
slug!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class CreatePlatformProjectDto {
@IsUUID()
estateId!: string;
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(100)
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
slug!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class TransferEstateDto {
@IsUUID()
destinationCompanyId!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class TransferPlatformProjectDto {
@IsUUID()
destinationEstateId!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class CreateGrantDto {
/** Subject user (better-auth text id). No teamId field — see module doc. */
@IsString()
@MinLength(1)
@MaxLength(255)
userId!: string;
@IsIn(['company', 'estate', 'platform_project'])
targetKind!: 'company' | 'estate' | 'platform_project';
@IsUUID()
targetId!: string;
/** Bare vocabulary on requests; responses and audit events are namespaced (§4.5). */
@IsIn(HIERARCHY_GRANT_ROLES)
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class ChangeGrantDto {
@IsIn(HIERARCHY_GRANT_ROLES)
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
@@ -1,28 +0,0 @@
import { Module } from '@nestjs/common';
import { HierarchyAuditRepository } from './hierarchy-audit.repository.js';
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
import { HierarchyController } from './hierarchy.controller.js';
import { HierarchyRepository } from './hierarchy.repository.js';
import { HierarchyService } from './hierarchy.service.js';
/**
* Hierarchy (tenancy/authorization structure) feature module.
*
* M4-1b-i shipped the audit event + outbox machinery (contract 1 §5.2);
* M4-1b-ii adds the command family — the closed route surface asserted by
* the route-inventory witness — plus grant evaluation (contract 2 §3).
* HierarchyRepository is the sole class-table writer (writer-coverage
* allowlist); every mutation runs authorize → mutate → audit in one
* transaction.
*/
@Module({
controllers: [HierarchyController],
providers: [
HierarchyAuditRepository,
HierarchyGrantEvaluationService,
HierarchyRepository,
HierarchyService,
],
exports: [HierarchyAuditRepository, HierarchyGrantEvaluationService],
})
export class HierarchyModule {}
@@ -1,935 +0,0 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import {
and,
asc,
companies,
eq,
estates,
hierarchyGrants,
inArray,
platformProjects,
users,
workspaces,
type Db,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import {
appendHierarchyEvent,
buildNodeSnapshot,
HierarchyAuditIdempotencyConflictError,
} from './hierarchy-audit.repository.js';
import {
evaluateEffectiveRole,
grantedCompanyIds,
namespacedHierarchyRole,
roleAtLeast,
type GrantTargetKind,
type HierarchyGrantRole,
} from './hierarchy-grant-evaluation.js';
/**
* Hierarchy command repository (contract 1 §5, contract 2 §4).
*
* The ONLY writer of the hierarchy class tables (companies, estates,
* platform_projects, hierarchy_grants) — it is the writer-coverage
* allowlist's sole entry. Every command runs one transaction that
* authorizes (live grant evaluation inside the same transaction), mutates,
* and appends the semantic audit event + outbox record via the M4-1b-i
* machinery, so state, event, and outbox commit or roll back together
* (REQ-AUD-001).
*
* Authorization failure and target-not-found both return `not_found`
* (contract 1 §6.7: no existence oracle — an unauthorized caller learns
* nothing a stranger would not). `forbidden` appears only where the caller
* already knows the surface exists independent of any node: the admin-only
* visibility change (§5.5). Serialized role strings are namespaced (§4.5).
*/
export type HierarchyCommandFailure =
| { readonly ok: false; readonly error: 'not_found' }
| { readonly ok: false; readonly error: 'forbidden'; readonly message: string }
| { readonly ok: false; readonly error: 'conflict'; readonly message: string };
export type HierarchyResult<T> = ({ readonly ok: true } & T) | HierarchyCommandFailure;
export interface CompanyView {
readonly id: string;
readonly name: string;
readonly slug: string;
readonly visibility: string;
}
export interface NodeView {
readonly id: string;
readonly name: string;
readonly slug: string;
}
export interface GrantView {
readonly id: string;
readonly userId: string;
readonly targetKind: GrantTargetKind;
readonly targetId: string;
/** Namespaced (§4.5), e.g. `hierarchy:owner`. */
readonly role: string;
readonly grantedBy: string;
}
/** Directory rows are closed-field: existence, name, slug — nothing else (§2.8). */
export interface DirectoryEntry {
readonly id: string;
readonly name: string;
readonly slug: string;
}
type Tx = Pick<Db, 'insert' | 'select' | 'update' | 'delete'>;
type GrantRow = typeof hierarchyGrants.$inferSelect;
const NOT_FOUND: HierarchyCommandFailure = { ok: false, error: 'not_found' };
function conflict(message: string): HierarchyCommandFailure {
return { ok: false, error: 'conflict', message };
}
function grantTarget(row: GrantRow): { kind: GrantTargetKind; id: string } {
if (row.companyId) return { kind: 'company', id: row.companyId };
if (row.estateId) return { kind: 'estate', id: row.estateId };
return { kind: 'platform_project', id: row.platformProjectId as string };
}
/** Grant event snapshot (contract 2 §4.4): subject, target, namespaced role, grantor. */
function grantSnapshot(row: GrantRow): Record<string, unknown> {
const target = grantTarget(row);
return {
id: row.id,
subject: { userId: row.userId },
target: { kind: target.kind, id: target.id },
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
grantedBy: row.grantedBy,
};
}
function grantView(row: GrantRow): GrantView {
const target = grantTarget(row);
return {
id: row.id,
userId: row.userId as string,
targetKind: target.kind,
targetId: target.id,
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
grantedBy: row.grantedBy,
};
}
function companyView(row: typeof companies.$inferSelect): CompanyView {
return { id: row.id, name: row.name, slug: row.slug, visibility: row.visibility };
}
interface CommandContext {
readonly actorId: string;
readonly idempotencyKey: string;
readonly correlationId: string;
}
@Injectable()
export class HierarchyRepository {
constructor(@Inject(DB) private readonly db: Db) {}
private async run<T>(
idempotencyKey: string | undefined,
actorId: string,
body: (tx: Tx, ctx: CommandContext) => Promise<HierarchyResult<T>>,
): Promise<HierarchyResult<T>> {
const ctx: CommandContext = {
actorId,
idempotencyKey: idempotencyKey ?? randomUUID(),
correlationId: randomUUID(),
};
try {
return await this.db.transaction(async (tx) => body(tx, ctx));
} catch (error) {
// A key replayed with different content aborts the whole command —
// the transaction (state change included) has rolled back (§6.4).
if (error instanceof HierarchyAuditIdempotencyConflictError) {
return conflict(error.message);
}
throw error;
}
}
private async requireOwner(
tx: Tx,
actorId: string,
kind: GrantTargetKind,
id: string,
): Promise<boolean> {
return roleAtLeast(await evaluateEffectiveRole(tx, actorId, kind, id), 'owner');
}
private async isPlatformAdmin(tx: Tx, actorId: string): Promise<boolean> {
const rows = await tx
.select({ role: users.role })
.from(users)
.where(eq(users.id, actorId))
.limit(1);
return rows[0]?.role === 'admin';
}
// ── companies ────────────────────────────────────────────────────────────
/**
* Any authenticated user may create a company; the same audited operation
* writes the creator's initial owner grant (§4.3), causation-linked to the
* create event. Visibility is always 'private' — the command takes no
* visibility input (§5.5).
*/
createCompany(input: {
actorId: string;
name: string;
slug: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ company: CompanyView; grant: GrantView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const inserted = await tx
.insert(companies)
.values({ name: input.name, slug: input.slug })
.onConflictDoNothing()
.returning();
const company = inserted[0];
if (!company) return conflict('company slug already exists');
const grantRows = await tx
.insert(hierarchyGrants)
.values({
userId: ctx.actorId,
companyId: company.id,
role: 'owner',
grantedBy: ctx.actorId,
})
.returning();
const grant = grantRows[0] as GrantRow;
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
const created = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'create',
targetKind: 'company',
targetId: company.id,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_create',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: created.event.id,
idempotencyKey: `${ctx.idempotencyKey}:grant`,
});
return { ok: true, company: companyView(company), grant: grantView(grant) };
});
}
renameCompany(input: {
actorId: string;
companyId: string;
name: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ company: CompanyView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
return NOT_FOUND;
}
const rows = await tx
.select()
.from(companies)
.where(eq(companies.id, input.companyId))
.limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(companies)
.set({ name: input.name, updatedAt: new Date() })
.where(eq(companies.id, input.companyId))
.returning();
const company = updated[0] as typeof companies.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'rename',
targetKind: 'company',
targetId: company.id,
targetSnapshot: { ...snapshot, previousName: previous.name },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, company: companyView(company) };
});
}
/**
* Admin-only until the company-CRUD capability ratifies (§5.5) — the one
* hierarchy mutation a platform admin performs without a grant. A
* non-admin caller (owner included) gets `forbidden` before any company
* read: the refusal reveals nothing about the target's existence.
*/
changeCompanyVisibility(input: {
actorId: string;
companyId: string;
visibility: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ company: CompanyView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.isPlatformAdmin(tx, ctx.actorId))) {
return {
ok: false,
error: 'forbidden',
message: 'visibility change is platform-admin-only',
};
}
const rows = await tx
.select()
.from(companies)
.where(eq(companies.id, input.companyId))
.limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(companies)
.set({ visibility: input.visibility, updatedAt: new Date() })
.where(eq(companies.id, input.companyId))
.returning();
const company = updated[0] as typeof companies.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'visibility_change',
targetKind: 'company',
targetId: company.id,
// Old and new values are event content (§5.5).
targetSnapshot: {
...snapshot,
previousVisibility: previous.visibility,
visibility: company.visibility,
},
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, company: companyView(company) };
});
}
deleteCompany(input: {
actorId: string;
companyId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ deletedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
return NOT_FOUND;
}
const children = await tx
.select({ id: estates.id })
.from(estates)
.where(eq(estates.companyId, input.companyId))
.limit(1);
if (children.length > 0) return conflict('company still has estates');
// Snapshot and grants are read before the delete; target FKs cascade
// the grant rows, and each cascaded deletion is audited (§5.2).
const snapshot = await buildNodeSnapshot(tx, 'company', input.companyId);
const grants = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.companyId, input.companyId));
await tx.delete(companies).where(eq(companies.id, input.companyId));
const deleted = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'delete',
targetKind: 'company',
targetId: input.companyId,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
for (const grant of grants) {
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: deleted.event.id,
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
});
}
return { ok: true, deletedId: input.companyId };
});
}
// ── estates ──────────────────────────────────────────────────────────────
/** Child creation requires owner on the parent and confers no grant (§4.3). */
createEstate(input: {
actorId: string;
companyId: string;
name: string;
slug: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ estate: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
return NOT_FOUND;
}
const inserted = await tx
.insert(estates)
.values({ companyId: input.companyId, name: input.name, slug: input.slug })
.onConflictDoNothing()
.returning();
const estate = inserted[0];
if (!estate) return conflict('estate slug already exists in company');
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'create',
targetKind: 'estate',
targetId: estate.id,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
});
}
renameEstate(input: {
actorId: string;
estateId: string;
name: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ estate: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
return NOT_FOUND;
}
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(estates)
.set({ name: input.name })
.where(eq(estates.id, input.estateId))
.returning();
const estate = updated[0] as typeof estates.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'rename',
targetKind: 'estate',
targetId: estate.id,
targetSnapshot: { ...snapshot, previousName: previous.name },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
});
}
/** Transfer requires effective owner on BOTH parents, evaluated in the transfer's own transaction (§5). */
transferEstate(input: {
actorId: string;
estateId: string;
destinationCompanyId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ estate: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
const estate = rows[0];
if (!estate) return NOT_FOUND;
if (!(await this.requireOwner(tx, ctx.actorId, 'company', estate.companyId))) {
return NOT_FOUND;
}
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.destinationCompanyId))) {
return NOT_FOUND;
}
if (estate.companyId === input.destinationCompanyId) {
return conflict('estate already belongs to the destination company');
}
const collision = await tx
.select({ id: estates.id })
.from(estates)
.where(
and(eq(estates.companyId, input.destinationCompanyId), eq(estates.slug, estate.slug)),
)
.limit(1);
if (collision.length > 0) return conflict('destination company already has that estate slug');
const parents = await tx
.select({ id: companies.id, slug: companies.slug })
.from(companies)
.where(inArray(companies.id, [estate.companyId, input.destinationCompanyId]));
const source = parents.find((p) => p.id === estate.companyId);
const destination = parents.find((p) => p.id === input.destinationCompanyId);
if (!source || !destination) return NOT_FOUND;
await tx
.update(estates)
.set({ companyId: input.destinationCompanyId })
.where(eq(estates.id, input.estateId));
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'transfer',
targetKind: 'estate',
targetId: input.estateId,
targetSnapshot: { ...snapshot },
transferFrom: { kind: 'company', id: source.id, slug: source.slug },
transferTo: { kind: 'company', id: destination.id, slug: destination.slug },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
});
}
deleteEstate(input: {
actorId: string;
estateId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ deletedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
return NOT_FOUND;
}
const children = await tx
.select({ id: platformProjects.id })
.from(platformProjects)
.where(eq(platformProjects.estateId, input.estateId))
.limit(1);
if (children.length > 0) return conflict('estate still has platform projects');
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
const grants = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.estateId, input.estateId));
await tx.delete(estates).where(eq(estates.id, input.estateId));
const deleted = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'delete',
targetKind: 'estate',
targetId: input.estateId,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
for (const grant of grants) {
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: deleted.event.id,
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
});
}
return { ok: true, deletedId: input.estateId };
});
}
// ── platform projects ────────────────────────────────────────────────────
createPlatformProject(input: {
actorId: string;
estateId: string;
name: string;
slug: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
return NOT_FOUND;
}
const inserted = await tx
.insert(platformProjects)
.values({ estateId: input.estateId, name: input.name, slug: input.slug })
.onConflictDoNothing()
.returning();
const project = inserted[0];
if (!project) return conflict('platform project slug already exists in estate');
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'create',
targetKind: 'platform_project',
targetId: project.id,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return {
ok: true,
platformProject: { id: project.id, name: project.name, slug: project.slug },
};
});
}
renamePlatformProject(input: {
actorId: string;
platformProjectId: string;
name: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
) {
return NOT_FOUND;
}
const rows = await tx
.select()
.from(platformProjects)
.where(eq(platformProjects.id, input.platformProjectId))
.limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(platformProjects)
.set({ name: input.name })
.where(eq(platformProjects.id, input.platformProjectId))
.returning();
const project = updated[0] as typeof platformProjects.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'rename',
targetKind: 'platform_project',
targetId: project.id,
targetSnapshot: { ...snapshot, previousName: previous.name },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return {
ok: true,
platformProject: { id: project.id, name: project.name, slug: project.slug },
};
});
}
transferPlatformProject(input: {
actorId: string;
platformProjectId: string;
destinationEstateId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx
.select()
.from(platformProjects)
.where(eq(platformProjects.id, input.platformProjectId))
.limit(1);
const project = rows[0];
if (!project) return NOT_FOUND;
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', project.estateId))) {
return NOT_FOUND;
}
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.destinationEstateId))) {
return NOT_FOUND;
}
if (project.estateId === input.destinationEstateId) {
return conflict('platform project already belongs to the destination estate');
}
const collision = await tx
.select({ id: platformProjects.id })
.from(platformProjects)
.where(
and(
eq(platformProjects.estateId, input.destinationEstateId),
eq(platformProjects.slug, project.slug),
),
)
.limit(1);
if (collision.length > 0) {
return conflict('destination estate already has that platform project slug');
}
const parents = await tx
.select({ id: estates.id, slug: estates.slug })
.from(estates)
.where(inArray(estates.id, [project.estateId, input.destinationEstateId]));
const source = parents.find((p) => p.id === project.estateId);
const destination = parents.find((p) => p.id === input.destinationEstateId);
if (!source || !destination) return NOT_FOUND;
await tx
.update(platformProjects)
.set({ estateId: input.destinationEstateId })
.where(eq(platformProjects.id, input.platformProjectId));
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'transfer',
targetKind: 'platform_project',
targetId: input.platformProjectId,
targetSnapshot: { ...snapshot },
transferFrom: { kind: 'estate', id: source.id, slug: source.slug },
transferTo: { kind: 'estate', id: destination.id, slug: destination.slug },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return {
ok: true,
platformProject: { id: project.id, name: project.name, slug: project.slug },
};
});
}
deletePlatformProject(input: {
actorId: string;
platformProjectId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ deletedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
) {
return NOT_FOUND;
}
const children = await tx
.select({ id: workspaces.id })
.from(workspaces)
.where(eq(workspaces.platformProjectId, input.platformProjectId))
.limit(1);
if (children.length > 0) return conflict('platform project still has workspaces');
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
const grants = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.platformProjectId, input.platformProjectId));
await tx.delete(platformProjects).where(eq(platformProjects.id, input.platformProjectId));
const deleted = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'delete',
targetKind: 'platform_project',
targetId: input.platformProjectId,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
for (const grant of grants) {
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: deleted.event.id,
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
});
}
return { ok: true, deletedId: input.platformProjectId };
});
}
// ── grants ───────────────────────────────────────────────────────────────
/** Grant management requires effective owner on the target (§4.1). */
createGrant(input: {
actorId: string;
userId: string;
targetKind: GrantTargetKind;
targetId: string;
role: HierarchyGrantRole;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ grant: GrantView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, input.targetKind, input.targetId))) {
return NOT_FOUND;
}
const subject = await tx
.select({ id: users.id })
.from(users)
.where(eq(users.id, input.userId))
.limit(1);
if (subject.length === 0) return conflict('subject user does not exist');
const inserted = await tx
.insert(hierarchyGrants)
.values({
userId: input.userId,
companyId: input.targetKind === 'company' ? input.targetId : null,
estateId: input.targetKind === 'estate' ? input.targetId : null,
platformProjectId: input.targetKind === 'platform_project' ? input.targetId : null,
role: input.role,
grantedBy: ctx.actorId,
})
.onConflictDoNothing()
.returning();
const grant = inserted[0];
if (!grant) return conflict('grant already exists');
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_create',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, grant: grantView(grant) };
});
}
changeGrant(input: {
actorId: string;
grantId: string;
role: HierarchyGrantRole;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ grant: GrantView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, input.grantId))
.limit(1);
const existing = rows[0];
if (!existing) return NOT_FOUND;
const target = grantTarget(existing);
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
return NOT_FOUND;
}
// Team subjects are suspended (§1.4); the command surface never
// creates them, so this only fires on out-of-band rows.
if (!existing.userId) return conflict('team grant subjects are suspended');
if (existing.role === input.role) return conflict('grant already holds that role');
const duplicate = await tx
.select({ id: hierarchyGrants.id })
.from(hierarchyGrants)
.where(
and(
eq(hierarchyGrants.userId, existing.userId),
target.kind === 'company'
? eq(hierarchyGrants.companyId, target.id)
: target.kind === 'estate'
? eq(hierarchyGrants.estateId, target.id)
: eq(hierarchyGrants.platformProjectId, target.id),
eq(hierarchyGrants.role, input.role),
),
)
.limit(1);
if (duplicate.length > 0) {
return conflict('subject already holds that role on the target');
}
const updated = await tx
.update(hierarchyGrants)
.set({ role: input.role })
.where(eq(hierarchyGrants.id, input.grantId))
.returning();
const grant = updated[0] as GrantRow;
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_change',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: {
...grantSnapshot(grant),
previousRole: namespacedHierarchyRole(existing.role as HierarchyGrantRole),
},
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, grant: grantView(grant) };
});
}
/** Revocation is row deletion (§6): the next evaluation denies, nothing lingers. */
revokeGrant(input: {
actorId: string;
grantId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ revokedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, input.grantId))
.limit(1);
const existing = rows[0];
if (!existing) return NOT_FOUND;
const target = grantTarget(existing);
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
return NOT_FOUND;
}
await tx.delete(hierarchyGrants).where(eq(hierarchyGrants.id, input.grantId));
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: existing.id,
targetSnapshot: grantSnapshot(existing),
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, revokedId: existing.id };
});
}
// ── reads ────────────────────────────────────────────────────────────────
/**
* The directory: every directory-class company, closed-field (§2.8). The
* sole ratified existence-disclosure carve-out (§6.7 / A2 §9.1.2).
*/
async listDirectory(): Promise<DirectoryEntry[]> {
return this.db
.select({ id: companies.id, name: companies.name, slug: companies.slug })
.from(companies)
.where(eq(companies.visibility, 'directory'))
.orderBy(asc(companies.name));
}
/** Companies the user holds any grant on (company or descendant, §2.8). */
async listGrantedCompanies(userId: string): Promise<CompanyView[]> {
const ids = await grantedCompanyIds(this.db, userId);
if (ids.length === 0) return [];
const rows = await this.db
.select()
.from(companies)
.where(inArray(companies.id, ids))
.orderBy(asc(companies.name));
return rows.map(companyView);
}
}
@@ -1,31 +0,0 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { HierarchyCommandFailure, HierarchyResult } from './hierarchy.repository.js';
/**
* Maps repository result unions onto HTTP exceptions. `not_found` carries
* one fixed message for every cause — missing node and unauthorized caller
* are indistinguishable on the wire (contract 1 §6.7).
*/
@Injectable()
export class HierarchyService {
unwrap<T>(result: HierarchyResult<T>): T {
if (result.ok) return result;
throw this.toException(result);
}
private toException(failure: HierarchyCommandFailure): Error {
switch (failure.error) {
case 'not_found':
return new NotFoundException('hierarchy node not found');
case 'forbidden':
return new ForbiddenException(failure.message);
case 'conflict':
return new ConflictException(failure.message);
}
}
}
+24 -89
View File
@@ -176,18 +176,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
).toBe(false);
expect(
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
).toBe(false);
});
it('derives no scope elevation from any platform role (contract 2 §1.1 bypass retirement)', () => {
const memberScopes = deriveMcpToolScopesForUser({ role: 'member' });
for (const role of ['admin', 'platform-admin', 'super-admin', null, undefined]) {
const scopes = deriveMcpToolScopesForUser({ role });
expect([...scopes].sort()).toEqual([...memberScopes].sort());
expect(scopes.has(MCP_TOOL_SCOPES.brain_create_task)).toBe(false);
expect(scopes.has(MCP_TOOL_SCOPES.brain_update_task)).toBe(false);
expect(scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
}
).toBe(true);
});
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
@@ -322,20 +311,14 @@ describe('MCP actor identity and tool scope enforcement', () => {
]);
});
it('gives admin-role and platform-admin-role actors only owned content on brain reads (§1.1 retirement)', async () => {
// Contract 2 §1.1: users.role confers no content visibility. An actor whose
// role is 'admin', 'platform-admin', or 'super-admin' but who holds no
// ownership sees exactly what an unprivileged member with the same
// ownership would see — here, only the one project they own, and nothing
// tenant-wide or platform-wide.
const fixtures = {
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
const { service } = makeService({
projects: [
{ id: 'project-owned', ownerId: 'role-bearing-user', teamId: 'tenant-a', name: 'owned' },
{
id: 'project-tenant-a',
ownerId: 'other-user-a',
teamId: 'tenant-a',
name: 'same tenant, unowned',
name: 'same tenant',
},
{
id: 'project-tenant-b',
@@ -345,50 +328,39 @@ describe('MCP actor identity and tool scope enforcement', () => {
},
],
missions: [
{ id: 'mission-owned', projectId: 'project-owned' },
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
],
tasks: [
{ id: 'task-owned', projectId: 'project-owned', status: 'not-started' },
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
],
};
});
const { server, tools } = makeCapturingServer();
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
const actors = [
makeAdminActor('role-bearing-user', 'tenant-a'),
makePlatformAdminActor('role-bearing-user'),
];
service.registerTools(server, actor);
for (const actor of actors) {
const { service } = makeService(fixtures);
const { server, tools } = makeCapturingServer();
service.registerTools(server, actor);
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-owned']);
}
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
});
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
const { service, brain } = makeService({
projects: [
// §1.1 retirement: content visibility comes from ownership, not the
// tenant-admin role — the acting user owns the tenant-a project.
{ id: 'project-tenant-a', ownerId: 'tenant-admin-user', teamId: 'tenant-a' },
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
],
missions: [
@@ -401,29 +373,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
],
});
const { server, tools } = makeCapturingServer();
// Platform role no longer derives task-write scopes (§1.1 retirement):
// a role-derived admin actor is scope-denied before any tenant logic.
const roleDerivedAdmin = makeAdminActor('tenant-admin-user', 'tenant-a');
service.registerTools(server, roleDerivedAdmin);
await expect(
getTool(tools, 'brain_create_task').handler({ title: 'role-derived write' }),
).rejects.toThrow('MCP tool scope denied');
expect(brain.tasks.create).not.toHaveBeenCalled();
// The tenant-scoping checks below sit behind the scope gate; exercise
// them with explicitly granted task-write scopes (how grant-mapped
// scopes will arrive), not with a platform role.
tools.clear();
const actor = createMcpActorContext({
userId: 'tenant-admin-user',
tenantId: 'tenant-a',
role: 'member',
scopes: [
...deriveMcpToolScopesForUser({ role: 'member' }),
MCP_TOOL_SCOPES.brain_create_task,
MCP_TOOL_SCOPES.brain_update_task,
],
});
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
service.registerTools(server, actor);
@@ -466,7 +416,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
);
});
it('denies coordination tools to every role-derived actor and keeps the granted path server-derived', async () => {
it('keeps admin-only coordination tools on server-derived paths', async () => {
const { service, coord } = makeService();
const { server, tools } = makeCapturingServer();
const member = makeMemberActor('authenticated-user');
@@ -483,25 +433,10 @@ describe('MCP actor identity and tool scope enforcement', () => {
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
// §1.1 retirement: platform-admin no longer derives coord scopes either.
tools.clear();
service.registerTools(server, platformAdmin);
const platformAdminTool = getTool(tools, 'coord_list_tasks');
await expect(platformAdminTool.handler({})).rejects.toThrow(
'MCP tool scope denied: coord:read',
);
// An explicitly granted coord:read scope reaches the server-derived
// path (caller-supplied projectPath is stripped by the schema).
tools.clear();
const grantedActor = createMcpActorContext({
userId: 'granted-user',
role: 'member',
scopes: [MCP_TOOL_SCOPES.coord_list_tasks],
});
service.registerTools(server, grantedActor);
const grantedTool = getTool(tools, 'coord_list_tasks');
await grantedTool.handler({ projectPath: '/tmp/victim' });
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
});
+57 -19
View File
@@ -63,6 +63,20 @@ interface SessionEntry {
actor: McpActorContext;
}
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
MCP_TOOL_SCOPES.brain_list_tasks,
MCP_TOOL_SCOPES.brain_create_task,
MCP_TOOL_SCOPES.brain_update_task,
MCP_TOOL_SCOPES.brain_list_missions,
MCP_TOOL_SCOPES.brain_list_conversations,
MCP_TOOL_SCOPES.memory_search,
MCP_TOOL_SCOPES.memory_get_preferences,
MCP_TOOL_SCOPES.memory_save_preference,
MCP_TOOL_SCOPES.memory_save_insight,
]);
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
@@ -75,17 +89,15 @@ const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.memory_save_insight,
]);
/**
* Contract 2 §1.1: platform role confers NO MCP scope elevation — the
* former tenant-admin/global-admin scope sets keyed on users.role are
* retired. Every authenticated user receives the base member set; task
* writes and coordination scopes attach to explicit hierarchy grants when
* the MCP grant mapping lands, never to a platform role. The role
* parameter is kept for caller compatibility and deliberately ignored.
*/
export function deriveMcpToolScopesForUser(_input: {
export function deriveMcpToolScopesForUser(input: {
role?: string | null;
}): ReadonlySet<McpToolScope> {
if (input.role === 'platform-admin' || input.role === 'super-admin') {
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
}
if (input.role === 'admin') {
return new Set(TENANT_ADMIN_MCP_SCOPES);
}
return new Set(MEMBER_MCP_SCOPES);
}
@@ -156,22 +168,41 @@ type TaskLike = TenantScopedLike & {
userId?: string | null;
};
/**
* Contract 2 §1.1: `users.role` confers NO content visibility — the former
* global-admin/tenant-admin filter short-circuits keyed on the platform role
* are retired along with the role-derived scope sets. Content reaches an MCP
* actor through ownership only; widened access arrives as explicit hierarchy
* grants when the MCP grant mapping lands.
*/
function isGlobalAdminActor(actor: McpActorContext): boolean {
return actor.role === 'platform-admin' || actor.role === 'super-admin';
}
function isTenantAdminActor(actor: McpActorContext): boolean {
return actor.role === 'admin';
}
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
return (
record.tenantId === actor.tenantId ||
record.organizationId === actor.tenantId ||
record.teamId === actor.tenantId
);
}
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
return projects.filter((project) => project.ownerId === actor.userId);
if (isGlobalAdminActor(actor)) return projects;
return projects.filter(
(project) =>
project.ownerId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
);
}
function filterMissionsByDirectActorScope<T extends MissionLike>(
actor: McpActorContext,
missions: T[],
): T[] {
return missions.filter((mission) => mission.userId === actor.userId);
if (isGlobalAdminActor(actor)) return missions;
return missions.filter(
(mission) =>
mission.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
);
}
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
@@ -262,6 +293,7 @@ export class McpService implements OnModuleDestroy {
}
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
}
@@ -270,6 +302,8 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
missions: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return missions;
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
const projectIds = new Set(
filterProjectsForActor(actor, projects).map((project) => project.id),
@@ -283,6 +317,7 @@ export class McpService implements OnModuleDestroy {
}
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
if (!mission) return false;
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
@@ -304,7 +339,7 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
refs: { projectId?: string | null; missionId?: string | null },
): Promise<void> {
if (!refs.projectId && !refs.missionId) {
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
throw new Error('MCP task scope denied');
}
await this.assertTaskReferencesAuthorized(actor, refs);
@@ -314,6 +349,8 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
tasks: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return tasks;
const [projects, missions] = await Promise.all([
this.brain.projects.findAll(),
this.brain.missions.findAll(),
@@ -330,6 +367,7 @@ export class McpService implements OnModuleDestroy {
return tasks.filter(
(task) =>
task.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
);
@@ -108,13 +108,11 @@ export class MissionsController {
) {
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
if (!mission) throw new NotFoundException('Mission not found');
// dto.status is deliberately not forwarded: mission_tasks.status is
// write-prohibited through the N-1 window (SHARED-CONTRACT §5.1 phase 1);
// the repo strips it as well.
return this.brain.missionTasks.create({
missionId,
taskId: dto.taskId,
userId: user.id,
status: dto.status,
description: dto.description,
notes: dto.notes,
pr: dto.pr,
-12
View File
@@ -77,12 +77,6 @@ export class CreateMissionTaskDto {
@IsUUID()
taskId?: string;
/**
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
* The field stays declared because the global ValidationPipe runs with
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
*/
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
@@ -108,12 +102,6 @@ export class UpdateMissionTaskDto {
@IsUUID()
taskId?: string;
/**
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
* The field stays declared because the global ValidationPipe runs with
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
*/
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
-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');
-92
View File
@@ -1,23 +1,6 @@
import 'reflect-metadata';
import { getMetadataStorage } from 'class-validator';
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
import {
ChangeCompanyVisibilityDto,
ChangeGrantDto,
CreateCompanyDto,
CreateEstateDto,
CreateGrantDto,
CreatePlatformProjectDto,
DeleteNodeDto,
RenameNodeDto,
TransferEstateDto,
TransferPlatformProjectDto,
} from './hierarchy/hierarchy.dto.js';
import {
EnrollAgentDto,
EnrollCredentialDto,
GetEnrollmentQueryDto,
} from './enrollment/enrollment.dto.js';
/**
* Boot-time self-check: the global ValidationPipe must be able to SEE the
@@ -60,81 +43,6 @@ export const PIPE_GUARDED_DTOS: Array<{
target: BootstrapSetupDto,
properties: ['name', 'email', 'password'],
},
{
name: 'CreateCompanyDto',
target: CreateCompanyDto,
properties: ['name', 'slug', 'idempotencyKey'],
},
{
name: 'RenameNodeDto',
target: RenameNodeDto,
properties: ['name', 'idempotencyKey'],
},
{
name: 'ChangeCompanyVisibilityDto',
target: ChangeCompanyVisibilityDto,
properties: ['visibility', 'idempotencyKey'],
},
{
name: 'DeleteNodeDto',
target: DeleteNodeDto,
properties: ['idempotencyKey'],
},
{
name: 'CreateEstateDto',
target: CreateEstateDto,
properties: ['companyId', 'name', 'slug', 'idempotencyKey'],
},
{
name: 'CreatePlatformProjectDto',
target: CreatePlatformProjectDto,
properties: ['estateId', 'name', 'slug', 'idempotencyKey'],
},
{
name: 'TransferEstateDto',
target: TransferEstateDto,
properties: ['destinationCompanyId', 'idempotencyKey'],
},
{
name: 'TransferPlatformProjectDto',
target: TransferPlatformProjectDto,
properties: ['destinationEstateId', 'idempotencyKey'],
},
{
name: 'CreateGrantDto',
target: CreateGrantDto,
properties: ['userId', 'targetKind', 'targetId', 'role', 'idempotencyKey'],
},
{
name: 'ChangeGrantDto',
target: ChangeGrantDto,
properties: ['role', 'idempotencyKey'],
},
{
name: 'EnrollAgentDto',
target: EnrollAgentDto,
properties: [
'harness',
'name',
'persona',
'model',
'provider',
'credential',
'idempotencyKey',
'correlationId',
'replayMode',
],
},
{
name: 'EnrollCredentialDto',
target: EnrollCredentialDto,
properties: ['mode', 'type', 'value'],
},
{
name: 'GetEnrollmentQueryDto',
target: GetEnrollmentQueryDto,
properties: ['correlationId'],
},
];
export class PipeMetatypeCheckError extends Error {
+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);
-37
View File
@@ -1,37 +0,0 @@
# Explicit, single-seat dogfood mode for stack-containerization B2.
# Use with docker-compose.yml. The base stack remains credential-free.
services:
gateway:
# The R4 credential helper establishes ownership from process ancestry and
# intentionally does not trust PID 1. Keep gateway Node below Docker's init.
init: true
environment:
# Identity and credential layout match a fleet seat. This fixed name prevents
# an operator from mounting one seat while attributing actions to another.
MOSAIC_AGENT_NAME: code-dogfood-01
MOSAIC_GIT_IDENTITY: code-dogfood-01
MOSAIC_BRAIN_HOME: /opt/mosaic/brain
AGENT_FILE_SANDBOX_DIR: /workspace/stack
# Disable the general shell before admin/user allowlist resolution. Delivery
# uses execFile-only tools bound to the queue and PR wrappers below.
AGENT_SHELL_ENABLED: 'false'
AGENT_DELIVERY_ENABLED: 'true'
MOSAIC_GIT_TOOLS_DIR: /opt/mosaic/tools/git
MOSAIC_INTEGRATION_TRUNK: next
AGENT_USER_TOOLS: fs_read_file,fs_write_file,fs_list_directory,fs_edit_file,git_status,git_log,git_diff,git_publish_branch,git_open_pull_request
volumes:
# Mount a dedicated worktree, never the canonical clone or divergent local main.
- type: bind
source: ${MOSAIC_DOGFOOD_WORKTREE:?set to a dedicated next-based stack worktree}
target: /workspace/stack
# A Git worktree's .git file points into the canonical clone's common Git
# directory. Mount that directory at its original absolute path so Git can
# resolve the pointer. File tools cannot traverse outside /workspace/stack.
- type: bind
source: ${MOSAIC_DOGFOOD_COMMON_GIT_DIR:?set to the canonical stack clone .git directory}
target: ${MOSAIC_DOGFOOD_COMMON_GIT_DIR:?set to the canonical stack clone .git directory}
# Only this seat home enters the container. Other fleet credentials stay outside.
- type: bind
source: ${MOSAIC_DOGFOOD_SEAT_HOME:?set to the external code-dogfood-01 seat directory}
target: /opt/mosaic/brain/fleet/agents/code-dogfood-01
read_only: true
-38
View File
@@ -47,44 +47,6 @@ services:
environment:
COLLECTOR_OTLP_ENABLED: 'true'
gateway:
# Standalone-tier application service (compose `stack` profile).
# Default image = local build of docker/gateway.Dockerfile (works with
# no registry auth); override GATEWAY_IMAGE to a CI-published sha tag
# for registry deploys (git.mosaicstack.dev/mosaicstack/stack/gateway:sha-XXXXXXX).
profiles: [stack]
image: ${GATEWAY_IMAGE:-mosaic-gateway:dev}
build:
context: .
dockerfile: docker/gateway.Dockerfile
ports:
- '${GATEWAY_HOST_PORT:-14242}:14242'
environment:
GATEWAY_PORT: '14242'
DATABASE_URL: postgresql://mosaic:mosaic@postgres:5432/mosaic
VALKEY_URL: valkey://valkey:6379
# The compose IS the standalone tier by declaration (mode contract:
# mode chosen at install); pinning skips cross-container probe races.
MOSAIC_STORAGE_TIER: standalone
# Standalone-tier secrets: generated at install (see .env.example).
# Enterprise tier replaces these with Vault/Openbao plumbing.
BETTER_AUTH_SECRET: '${BETTER_AUTH_SECRET:?set in .env — openssl rand -hex 32}'
volumes:
- gateway_workspaces:/opt/mosaic/.workspaces
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget -qO- http://127.0.0.1:14242/health || exit 1']
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
volumes:
gateway_workspaces:
pg_data:
valkey_data:
+2 -20
View File
@@ -29,29 +29,11 @@ ENV NODE_ENV=production
# $MOSAIC_ROOT/.workspaces (apps/gateway/src/workspace/workspace.service.ts);
# mount a volume over /opt/mosaic to persist workspaces across container restarts.
# Intentionally unpinned: Alpine's signed repository is the trust anchor; pinning
# packages was declined so routine base-image security updates remain maintainable.
# bash/curl/python3 are runtime dependencies of the provider-neutral Mosaic git
# wrappers. jq supports wrapper discovery for non-canonical Gitea hosts.
RUN apk add --no-cache bash curl git jq python3 \
# git was declined so routine base-image security updates remain maintainable.
RUN apk add --no-cache git \
&& mkdir -p /opt/mosaic/.workspaces \
&& chown -R node:node /opt/mosaic /app
ENV MOSAIC_ROOT=/opt/mosaic
# Dogfood agents use the same fail-closed credential helper, queue guard, and
# PR-create wrapper as fleet seats. Copy only those operations and their shared
# dependencies. Merge and infrastructure tools stay out of the image.
COPY --from=builder /app/packages/mosaic/framework/tools/git/pr-create.sh /opt/mosaic/tools/git/pr-create.sh
COPY --from=builder /app/packages/mosaic/framework/tools/git/ci-queue-wait.sh /opt/mosaic/tools/git/ci-queue-wait.sh
COPY --from=builder /app/packages/mosaic/framework/tools/git/detect-platform.sh /opt/mosaic/tools/git/detect-platform.sh
COPY --from=builder /app/packages/mosaic/framework/tools/git/repo-decl.sh /opt/mosaic/tools/git/repo-decl.sh
COPY --from=builder /app/packages/mosaic/framework/tools/git/git-credential-mosaic /opt/mosaic/tools/git/git-credential-mosaic
# R4 hardening (P0-SEC, brain 15f6979a): the credential helper is a pair.
# python entrypoint (allowlist envp, execve boundary) + the bash implementation
# it execs. The entrypoint derives the .impl path from its own directory, so the
# pair sits side by side; system gitconfig keeps pointing at the entrypoint.
COPY --from=builder /app/packages/mosaic/framework/tools/git/git-credential-mosaic.impl /opt/mosaic/tools/git/git-credential-mosaic.impl
COPY --from=builder /app/packages/mosaic/framework/tools/_lib/credentials.sh /opt/mosaic/tools/_lib/credentials.sh
COPY --from=builder /app/packages/mosaic/framework/tools/structure/validate-repo-json.sh /opt/mosaic/tools/structure/validate-repo-json.sh
RUN git config --system credential.helper /opt/mosaic/tools/git/git-credential-mosaic
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
COPY --chown=node:node --from=builder /deploy/node_modules ./node_modules
COPY --chown=node:node --from=builder /deploy/package.json ./package.json
@@ -81,4 +81,4 @@ The page may be promoted to an operative runbook only after deny-all is intentio
## Related contract
- [M1 logical identity and fencing decision](../../DEVELOPER-GUIDE/architecture/decisions/mos-runtime-portability-m1.md)
- [MOS-PORT requirements](../../PRDs/2026-08-31_PRD_rev1/GOV.4-workstream-contracts.md#mos-runtime-portability-workstream-mos-port)
- [MOS-PORT requirements](../../PRD.md#mos-runtime-portability-workstream-mos-port)
@@ -98,4 +98,4 @@ A valid lease or grant is therefore not a claim of exactly-once delivery, produc
## Related contract
- [M1 connector lease operations — held/non-operative](../../../ADMIN-GUIDE/operations/mos-connector-lease-operations.md)
- [MOS-PORT requirements](../../../PRDs/2026-08-31_PRD_rev1/GOV.4-workstream-contracts.md#mos-runtime-portability-workstream-mos-port)
- [MOS-PORT requirements](../../../PRD.md#mos-runtime-portability-workstream-mos-port)
+1 -3
View File
@@ -1,10 +1,8 @@
---
kind: tracking
status: superseded
status: active
---
> **Superseded (2026-09-01, PRD rev1 ratification).** This document's federated-tier-as-canonical-MVP-deployment-topology and Federation-v1-as-top-priority framing is historical. Federation M1M3 are shipped but **frozen** (dormant since 2026-06-25, excluded from the v1 bar, security re-audit gate before any resumption); the canonical v1 deployment topology is the compose standalone tier (PRD rev1, D15). Authority: `docs/PRD.md` → `docs/PRDs/2026-08-31_PRD_rev1/` (decision D3 as amended, GOV.5 Q-T1). Tracking: `docs/fleet/NORTH_STAR.yaml` (dormant federation workstream). Content below is preserved verbatim as a record — do not edit it.
# Mission Manifest — MVP
> Top-level rollup tracking Mosaic Stack MVP execution.
+876 -16
View File
@@ -1,24 +1,884 @@
---
kind: shim
kind: spec
status: active
source_of_truth: true
current_rev: docs/PRDs/2026-08-31_PRD_rev1/
---
# PRD: Mosaic Stack
# PRD: Mosaic Stack — North Star
This file is a permanent shim, not the PRD body (GOV.1 lifecycle rule). The
project source of truth is the **current revision bundle**:
This document is the product source of truth for Mosaic Stack.
**[docs/PRDs/2026-08-31_PRD_rev1/](./PRDs/2026-08-31_PRD_rev1/PRD.md)** —
rev1, ratified 2026-09-01 (Jason Woltje). It consolidates the 2026-08-26 North
Star (D1D15), the fleet north star, the agent-runtime L1/L2 contracts, and the
control-plane-surfaces findings into sectioned documents (AUTHN, AUTHZ, CLI,
DATA, GOV.15, HARN, PROV, ROLE, SEAT, SESS, UI, VIS) with a single decision
map and a closed open-questions list.
- **Part I** defines the product north star. It is written from the ratified
decision set D1D14 (operator decision session, 2026-08-25; decision owner
Jason Woltje). Each section cites the decisions it implements.
- **Part II** preserves the active workstream contracts unchanged. Open issues
bind to them; this rewrite does not alter a single normative word in them.
- The previous v0.1.0 beta PRD body is archived verbatim at
[docs/archive/PRD-v0.1.md](./archive/PRD-v0.1.md) and is no longer authority.
- The delivery roadmap lives in [docs/ROADMAP.md](./ROADMAP.md). Per D11, every
planned phase appears there from day one, even as a placeholder.
Revision bundles are frozen at ratification and never deleted. The prior
revision, rev0 (2026-08-26 North Star), is archived verbatim at
[docs/PRDs/2026-08-26_PRD_rev0/PRD.md](./PRDs/2026-08-26_PRD_rev0/PRD.md).
Updating the PRD means ratifying a new bundle under `docs/PRDs/` and repointing
`current_rev:` here; this path never changes.
## Metadata
- **Owner / decision authority:** Jason Woltje
- **Status:** active (supersedes the v0.1.0 PRD as product authority)
- **Date:** 2026-08-26
- **Decision registry:** D1D14, recorded in Part I §12
- **SSOT rule:** this repository's `docs/` tree is the product source of truth
(D5). Estate brains hold operational records, not product canon; only
product-relevant material migrates here (D6).
---
## Part I — Product north star
### 1. What Mosaic Stack is (D1)
Mosaic Stack is an **open-source, AI-first platform for people who want a
self-hosted environment for agentic management and a life operating system.**
It serves personal, business, and employee needs from one deployment, and the
work is offered freely.
"AI-first" means agents are first-class operators of the system, not a bolted-on
chat box: the platform exists to let humans direct fleets of agents over their
projects, tasks, communications, and infrastructure, with the same tools and
the same guarantees whether a human or an agent is acting.
### 2. Who it is for (D1, D9)
The operator of a deployment is its user. Mosaic Stack is **not a hosted
business**: running the system as a service for external customers is outside
the north star. Multi-tenancy exists WITHIN a deployment so that one operator
can separate their world — for example, several LLCs plus a personal domain —
while every deployment is self-hosted by its own operator.
"Company" in the hierarchy is organizational separation for one operator's
world, not a customer account.
### 3. Deployment modes (D3)
Two modes, chosen at install time:
| | Standalone / personal | Enterprise |
| ------------------- | -------------------------------------- | ----------------------------------------------------- |
| Brains | one mosaic-brain (system + user files) | system brain for config + one brain per user |
| User-data isolation | single user | no user-data leakage between users; sharing is opt-in |
| Secrets | OpenBao/Vault or flat files | OpenBao/Vault REQUIRED |
| Conversion | Standalone → Enterprise, **one-way** | terminal state |
Brains are configurable as external git repositories (recommended, not
required); git tracking is always on locally.
**Federation** (connecting deployments: system-level config, assigned users,
rights and data-access control, trusts with boundaries, exfiltration
monitoring) is intentionally not fully designed. It is deferred, appears on the
roadmap as a placeholder phase per D11, and nothing in v1 may foreclose it.
### 4. Structure and tenancy (D2, D9, D13)
The hierarchy:
```
company/organization (N per deployment)
└─ estate (each in exactly one company)
└─ project (each in exactly one estate)
└─ workspace (project-specific; carries the Kanban)
```
Rules:
- Users can create N companies, N estates, N projects.
- Tasks bubble UP the hierarchy so whole-system status is visible at every
level. Bubble-up is **read-only aggregation**, never a cross-workspace write.
- Granular RBAC: admins restrict access per company, estate, and project;
grants are evaluated down the chain. Assets are transferable subject to the
structure.
- **`workspace_id` remains the hard mechanical isolation unit** exactly as
ratified in
[docs/requirements/native-kanban-sot.md](./requirements/native-kanban-sot.md)
(#751): PostgreSQL sole writable SOT, cross-workspace relationships rejected,
fail-closed mutations. The hierarchy is parent structure ABOVE workspaces,
used for RBAC evaluation and read-only roll-ups. The kanban SOT carries this
as Amendment A1, added by reviewed PR — an amendment, not a rewrite (D13).
### 5. Identity (D10)
Built-in auth (better-auth) is the **account system of record**. Authentik and
other external IdPs federate in via OIDC as login methods; they never become
the system of record. Perimeter shims (forward-auth in front of a web host) are
deployment workarounds, not the design.
### 6. Onboarding (D4)
Onboarding is a **wizard that differs by mode, is re-runnable (no lock-in), and
is extensible** — new wizards attach as tabs.
Standalone flow captures: system and company name; component choices (Mosaic
Comms/Matrix vs external; Mosaic SSO/Authentik vs external; Mosaic
DB/PostgreSQL vs external; vector DB); the initial user
(email/password/name/SSO); comms setup (Matrix/Discord/Slack); agent enrollment
(harness choice and install, OAuth or API-key login, multi-account, model
choice with recommendation, agent name and persona, account assignment,
optional comms auto-enroll); a user onboarding profile (disabilities including
ADHD/autism/PDA/vision, professional background, education, desired agent
communication style, optional voice-matching interview, family/pets/friends/
hobbies/likes-dislikes); email and drive connectors (Gmail/IMAP, Google
Drive/OneDrive/Dropbox) with granular agentic-access consent; SSO/OIDC
configuration; an initial estate, an initial project, and seeded example data.
Enterprise uses the same skeleton with personal data optional; the focus moves
to business structure, org chart, RBAC, M365 and external systems, immediate
OIDC, SSO prominent.
Profile answers feed `USER.md` and/or the user's data store subject to the
custody rule in §7.
### 7. Data custody (D6, D14)
- **Sensitive profile categories** (disabilities, family, communication style,
and similar) live in the **user's own brain ONLY**. PostgreSQL holds
structural data, consent records, and pointers — never the content. "User
data does not leak" is enforced by architecture, not policy (D14).
- Standalone (one user, one brain) **may** keep the same split — D14 makes it
optional in Standalone, not required. Keeping it is the recommended default
because it preserves forward-compatibility with the one-way Enterprise
conversion (D3).
- Estate brains hold operational records. Only product-relevant material
migrates into this repository's docs; operational records stay in their
brains and are linked (D6).
### 8. Architecture gate — the webUI sits OVER official tooling (D8, D12)
**HARD RULE:** every webUI operation goes through the Gateway API backed by the
same official framework tooling the CLI uses. The CLI remains the primary
execution method; the webUI uses the tools to operate and configure the
system. The webUI never bypasses tooling to reach the database or filesystem
directly.
Consequence for planning: when a desired webUI operation has no backing tool,
the gap is scored **"blocked on tooling"** and the tool is built first. The
product baseline therefore always includes all three D8 inputs: the tool
inventory (what exists and what is missing), the webUI→tool mapping, and the
measured current state of the `next` branch.
### 9. v1 slice (D11)
v1 is deliberately small:
1. **Standalone onboarding wizard** — system/company name, component choices,
initial user, initial estate + project, seeded examples, re-runnable.
2. **Hierarchy core** — company → estate → project → workspace → kanban, with
read-only task bubble-up.
3. **Basic RBAC** on the hierarchy.
4. **Minimal agent enrollment** — one harness, API key, name/persona.
Deferred beyond v1: connectors, comms integrations, voice-matching, M365,
Enterprise conversion, federation. Every deferred item appears in
[docs/ROADMAP.md](./ROADMAP.md) per the D11 rule: nothing exists only in heads.
### 10. Relationship to the fleet north star
[docs/fleet/NORTH_STAR.md](./fleet/NORTH_STAR.md) (generated from
`docs/fleet/NORTH_STAR.yaml`) is the **delivery-fleet** north star: how the
agent fleet that builds and operates the system should run (NS-1..NS-10,
workstreams AL). This PRD is the **product** north star. They are not
competitors: the fleet north star is subordinate product-wise — its workstream
J ("Web control plane") is one consumer of this PRD's D8/D12 gate — and this
PRD does not redefine fleet invariants. The subordination rule is ratified in
the frozen audit-input baseline (T2 operator freeze, 2026-08-25: "the PRD must
cite and subordinate it, never fork it"). A change that would put the two in
conflict must amend one of them explicitly, never fork a third document
(drafting addition — see §12.1).
### 11. Explicit non-goals
- Hosted/SaaS operation for external customers (D9).
- A webUI that writes to the database or filesystem around the tooling (D12).
- A second writable task store beside PostgreSQL (native-kanban-sot invariants).
- Fully-designed federation in v1 (D3 — roadmap placeholder only).
### 12. Decision registry
| ID | Decision (short form) |
| --- | ------------------------------------------------------------------------------------------------------------------ |
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred |
| D4 | Re-runnable, extensible, per-mode onboarding wizards |
| D5 | North star = this rewrite of docs/PRD.md; stack docs/ = product SSOT |
| D6 | Only product-relevant material migrates from brains; operational records stay and link |
| D7 | Spec-inventory sweep launched immediately (executed; INPUTS baseline frozen by operator ruling T2, 2026-08-25) |
| D8 | webUI sits over official framework tooling; CLI primary |
| D9 | Not a hosted business; company = organizational separation for one operator |
| D10 | better-auth is the account system of record; external IdPs via OIDC |
| D11 | Small v1 slice; ALL phases on the documented roadmap from day one |
| D12 | HARD RULE: webUI never bypasses tooling; missing tool ⇒ build the tool first |
| D13 | workspace_id stays the hard isolation unit; hierarchy is parent structure above; kanban SOT amended, not rewritten |
| D14 | Sensitive profile data in the user's own brain only; postgres holds structure/consent/pointers |
The full decision texts are recorded in the operator decision log (USC estate
brain, webui-audit lane, `GRILL.md`).
### 12.1 Drafting additions beyond D1D14
Independent review of this rewrite identified rules in this document that are
not present in the D1D14 record or the frozen T2 baseline. They are listed
here so their ratification is explicit: approval of the PR that introduces
this document, by the decision owner, ratifies them. If any is rejected it is
removed, not silently kept.
1. **Federation forward-compatibility gate:** "nothing in v1 may foreclose
federation" (§3), and scoping federation later requires its own PRD plus
threat model ([ROADMAP](./ROADMAP.md) P5). D3 defers federation; these
protective gates are additions.
2. **North-star amendment rule:** a product/fleet north-star conflict must be
resolved by amending one of the two documents explicitly, never by forking
a third (§10). The subordination itself is T2-ratified; this amendment
procedure is an addition.
---
## Part II — Active workstream contracts (preserved unchanged)
The sections below are normative, in-flight workstream contracts carried over
verbatim from the previous revision of this file. Open issues bind to them.
This rewrite moved no text and changed no requirement in them; they are
governed by their own issues and review gates, and they graduate out of this
file individually when their workstreams close.
## Current addendum: #1194 — Installed framework-tool drift detection
- Compare the framework tools shipped with the executing Mosaic package against the deployed `$MOSAIC_HOME/tools` tree by content hash.
- Treat every shipped `tools/**` file as framework-owned/required according to `framework-manifest.txt`, while excluding the explicit operator-owned credential carve-out and preserving installed-only operator/unknown files.
- Distinguish and count `IN_SYNC`, `STALE`, `NOT_INSTALLED`, and installed-only classifications; fail non-zero when shipped tools are stale or absent and refuse self-comparison that would make drift unobservable.
- Surface the observational check through `mosaic doctor`; do not refresh files, restart seats, or mutate live tooling.
- Document identity/messaging/gate behavior changes in the current stale set, the reviewed quiet-window keep-mode refresh command, and post-refresh probes against the installed path.
- Prove by construction that a stale and missing deployed tool are detected; that regression must fail before this checker exists.
## Compaction Refresh Trust Lifecycle (M1, #827#830)
### Problem and objective
Context compaction, session replacement, and same-PID runtime reloads can leave a previously VERIFIED runtime lease attached to stale directives. M1 must revoke that authority mechanically for Claude (including Claudex) and Pi without trusting caller-asserted identity or forking the external broker state machine.
### Requirements
1. `CR-REQ-01`: Claude `PreCompact` and `SessionStart` with matcher `compact`, plus Pi `session_before_compact` and the first post-`session_compact` `context`, SHALL independently revoke the active broker lease.
2. `CR-REQ-02`: Runtime generation increases—including same-PID Pi reload/new/resume/fork and Claude resume/clear—SHALL monotonically replace the prior broker incarnation and inherit no VERIFIED lease.
3. `CR-REQ-03`: A fired observer that cannot confirm broker revocation SHALL fail closed through lifecycle cancellation, a private local generation fence, and/or a runtime-local tool latch. The existing all-tools broker gate remains authoritative.
4. `CR-REQ-04`: The lease TTL SHALL remain monotonic and capped at 300 seconds. If both observers are missed, within-TTL consequential actions remain allowed and after-TTL actions are denied. This named bounded residual stale window SHALL be documented without claiming a mutator-action bound inside the window.
5. `CR-REQ-05`: Hook descendants SHALL use the broker-minted session and owner-only current-generation state inherited from register-before-exec. Caller-minted sessions and parallel lease state machines remain forbidden.
### Acceptance criteria
1. `AC-CR-01`: Real-socket tests prove each Claude observer revokes, Pi lifecycle tests prove both observer paths, and Claudex isolated settings preserve and install the mandatory hooks.
2. `AC-CR-02`: A same-PID generation test proves the old generation is stale and the replacement generation is UNVERIFIED across reload/resume/fork-equivalent lifecycle events.
3. `AC-CR-03`: RED-first T12b/T30 evidence explicitly reports dual-hook miss within TTL as **ALLOWED** and after TTL as **DENIED**.
4. `AC-CR-04`: Attributable executable coverage is at least 85%, the full repository suite is green on deterministic main, and independent code/security review completes before merge.
---
## Pi Persistent Goal Loop (#1150)
### Problem and objective
A Pi agent can stop after a plausible-looking answer even when the operator's broader objective is
not complete, and ordinary compaction can weaken or omit the original objective. Mosaic needs an
optional, operator-controlled goal loop that keeps a Pi session oriented, checks progress at native
lifecycle boundaries, and resumes work until completion is verified or a bounded safety state is
reached.
The objective is a Mosaic-owned Pi extension deployed from the framework into
`~/.config/mosaic/runtime/pi/`. It must not install into or depend on `~/.pi/agent/extensions/`.
### Scope
#### In scope
1. `PGL-REQ-01`: The framework SHALL ship a dedicated Pi goal extension under
`packages/mosaic/framework/runtime/pi/`, seed it under `$MOSAIC_HOME/runtime/pi/`, and make
`mosaic pi` load it alongside the core Mosaic extension when present.
2. `PGL-REQ-02`: `/goal` SHALL support setting a goal plus status, pause, resume, cancel, and help
operations without silently replacing an active goal.
3. `PGL-REQ-03`: Active branch-specific goal state SHALL be persisted in Pi custom session entries,
restored on session start and tree navigation, and never rely on a compaction summary as its
source of truth.
4. `PGL-REQ-04`: A hidden goal contract SHALL be injected through Pi's `context` event before every
model request so it remains effective across tool turns, retries, and post-compaction requests.
5. `PGL-REQ-05`: The harness SHALL inspect every `turn_end` and successful `session_compact` event.
A structured terminating goal-report tool SHALL capture `continue`, evidence-bearing `achieved`,
or `blocked` status without requiring a redundant model turn.
6. `PGL-REQ-06`: An achievement claim SHALL remain provisional until a second consecutive
evidence-bearing verification report. Any continuation report or successful compaction during
verification SHALL reset the verification sequence.
7. `PGL-REQ-07`: Continuation SHALL be initiated at safe lifecycle boundaries, primarily
`agent_settled`; manual compaction and restored active sessions may schedule a deferred idle
continuation without re-entering compaction handlers.
8. `PGL-REQ-08`: The loop SHALL have operator cancellation plus bounded turn and repeated-no-progress
limits. Exhausted or blocked goals pause rather than continuing indefinitely.
9. `PGL-REQ-09`: Framework installation and update SHALL preserve normal manifest ownership: the
goal extension is framework-owned under `runtime/**`, while no goal extension or configuration
asset is created or modified under the operator's main Pi configuration. Pi remains the owner of
its native session files used by `appendEntry()`.
#### Out of scope
1. A mathematical guarantee that an arbitrary natural-language goal is semantically complete.
2. Automatically executing user-supplied shell predicates or accepting executable validation code in
`/goal` arguments.
3. Restarting Pi after process, host, or supervisor failure; the existing Mosaic fleet/runtime
supervisor owns process durability.
4. Gateway, database, web UI, Discord, or cross-harness goal orchestration in this slice.
### User and stakeholder requirements
- An operator can start a goal from Pi and see its current phase, evidence, limits, and latest report.
- The agent remains oriented after each turn and compaction until verified, paused, blocked,
exhausted, or cancelled.
- Local testing uses a file under `~/.config/mosaic/runtime/pi/`; the feature never writes an
extension asset to `~/.pi/agent/extensions/`.
- Framework updates deploy the same reviewed extension source through Mosaic's existing manifest
sync path.
### Non-functional requirements
1. **Safety:** bounded continuation, explicit cancellation, no arbitrary command execution, and no
completion without non-empty reported evidence.
2. **Reliability:** serialized continuation scheduling, branch-aware restoration, compaction-safe
context injection, and stale-timer cancellation on session shutdown.
3. **Performance:** no extra nested judge-model request on every turn; structured reporting uses the
active agent's final terminating tool call.
4. **Observability:** Pi status/notifications expose phase and bounded counters without recording
credentials or hidden model reasoning.
5. **Maintainability:** the state machine is deterministic and behavior-tested independently from Pi
provider/network access.
### Acceptance criteria
1. `AC-PGL-01`: A framework-sync fixture installs the extension at
`$MOSAIC_HOME/runtime/pi/goal-extension.ts`, and launcher tests prove both Mosaic Pi extensions are
emitted in deterministic order while absent optional files remain backward-compatible.
2. `AC-PGL-02`: Command tests prove set/status/pause/resume/cancel behavior, active-goal replacement
refusal, and bounded input handling.
3. `AC-PGL-03`: Lifecycle tests prove every turn is recorded, active context is injected on every
request, two evidence-bearing achievement reports are required, and `agent_settled` continues an
unmet goal without duplicate scheduling.
4. `AC-PGL-04`: Compaction and restoration tests prove goal state survives, verification is reset and
rechecked after compaction, manual compaction continuation is deferred until idle, and tree/session
branch state is reconstructed correctly.
5. `AC-PGL-05`: Limit tests prove max-turn and repeated-no-progress exhaustion stop autonomous
continuation, while pause/cancel/blocked states do not restart.
6. `AC-PGL-06`: Focused tests, package typecheck/lint/test, repository quality gates, a local Pi load
smoke test from `~/.config/mosaic/runtime/pi/`, independent review, and terminal-green CI pass before
issue #1150 closes.
### Constraints, risks, and assumptions
- Dependency: Pi's extension API must continue to provide `registerCommand`, `registerTool`,
`context`, `turn_end`, `agent_settled`, `session_compact`, session custom entries, and terminating
tool results.
- Risk: the working agent can overstate completion. Mitigation: structured evidence, a mandatory
second verification pass, explicit semantic limitations, and operator-visible reports.
- Risk: an impossible goal can consume unbounded resources. Mitigation: hard turn/no-progress bounds
and paused terminal states.
- Risk: automatic continuation can race compaction or session replacement. Mitigation: drive from
`agent_settled`, defer idle restarts, generation-check timers, and clear timers on shutdown.
- `ASSUMPTION:` Two consecutive evidence-bearing reports are the initial local verification policy;
rationale: it provides a real recheck without doubling every turn's model cost. Future policy may
add independent or deterministic validators.
- `ASSUMPTION:` Default limits are 40 turns and 6 repeated no-progress reports, configurable only by
bounded Mosaic environment settings; rationale: useful persistence with a finite autonomous budget.
- `ASSUMPTION:` Documentation remains canonical in-repo for this slice; no external docs publication
is requested.
### Testing and delivery intent
Use TDD for the deterministic controller and lifecycle invariants. Test with fake Pi lifecycle
objects first, then run a local load/smoke test from the deployed Mosaic path. Deliver source, tests,
launcher wiring, framework/runtime documentation, user/developer guides, and sitemap updates in one
reviewed squash PR to `main` with terminal-green CI.
---
## Fleet Declarative Configuration Management Workstream (FCM, #758)
### Problem and objective
The local Mosaic fleet has a roster, generated agent environment files, user-systemd units, tmux
sessions, heartbeat files, examples, profiles, and separate gateway-backed agent records. These
planes have drifted and are not one safe operator lifecycle. The objective is one **local fleet
roster** as the desired-state SSOT, with generated environment, systemd, tmux, and heartbeat
artifacts as rebuildable projections; it does not merge the local fleet control plane with the
gateway-backed agent catalog.
### Normative requirements
| ID | Requirement |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
| `FCM-REQ-09` | M1M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
### Acceptance criteria
1. `AC-FCM-01`: A valid local v2 roster can be parsed from YAML or JSON, validated structurally and semantically through the shared resolver, and rendered canonically; invalid fields, duplicate names, unresolved classes, unsupported runtime/model combinations, socket ambiguity, and incompatible options fail closed.
2. `AC-FCM-02`: `plan` reports deterministic desired-versus-observed differences for roster, generated environment, systemd enablement, tmux/session, heartbeat, installed-asset revision, and provable orphans without mutation; `apply --check` reports drift without mutation.
3. `AC-FCM-03`: Local create/update/delete is generation-guarded, atomic, idempotent, and safe by default; it permits supported runtime/model/harness/effort/workdir/role changes without direct editing of generated environment files and does not start a newly created agent unless explicitly persisted.
4. `AC-FCM-04`: The generated-env/local-override launch chain rejects generated-key shadowing, arbitrary command override, unknown keys, shell evaluation, and sensitive-value diagnostics before any agent starts; known-safe legacy input is regenerated or strictly relocated, and forbidden input is quarantined.
5. `AC-FCM-05`: Local lifecycle reconciliation implements the persisted/transient start-stop rules, exact default/named tmux socket targeting, systemd/tmux status, stale generated state, unmanaged-session reporting, and rollback without surprise restarts or fuzzy destructive targeting.
6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory.
7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers.
8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes.
### M0 implementation gate
No source, schema, role, example, profile, systemd, or live-fleet change is authorized before M0
lands. M0 consists only of these normative requirements, the complete task DAG, the scoped
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
### Fleet git identity launch propagation (#1043)
#### Problem and objective
A fleet seat can have a registered per-agent Git credential while its launched runtime process lacks
`MOSAIC_GIT_IDENTITY`. The credential resolver then cannot select the seat identity reliably, which
blocks repository operations on fail-closed estates and can fall through to an unrelated identity on
estates where that refusal is not active. The objective is to make Git identity a deterministic,
roster-derived part of the generated launch projection and prove it reaches the launched process.
#### Normative requirements
1. `FGI-REQ-01`: Every generated fleet agent projection SHALL declare
`MOSAIC_GIT_IDENTITY=<MOSAIC_AGENT_NAME>`; a differing or unsafe identity SHALL fail closed before
tmux launch.
2. `FGI-REQ-02`: The clean `/usr/bin/env -i` pane boundary SHALL pass every variable declared by the
generated projection, including `MOSAIC_GIT_IDENTITY`, to the launched runtime process.
3. `FGI-REQ-03`: A behavioral integration test SHALL set-compare the complete generated projection
against the launched process environment. Source-text/string-presence assertions are insufficient.
4. `FGI-REQ-04`: Verification SHALL include RED-first evidence and a delete-the-subject mutation that
removes Git-identity pane propagation and makes the behavioral test fail.
#### Acceptance criteria
1. `AC-FGI-01`: A launched seat process contains every key/value pair declared by its generated
environment projection, including the roster-derived Git identity.
2. `AC-FGI-02`: Missing, unsafe, or split Git identity is rejected before a tmux session is created.
3. `AC-FGI-03`: Focused launcher and generated-environment tests, repository quality gates,
independent review, and the required RED/green/R7 evidence are recorded before push.
### Framework shell assertion portability (#1098)
#### Problem and objective
The blocking framework-shell chain can report that a pane command omitted `/usr/bin/env -i` even when
`-i` matched successfully. A short-circuiting `grep -q` under `set -o pipefail` may close its pipe after
the match and cause an upstream producer to exit with SIGPIPE, turning a valid semantic result into a
nonzero aggregate pipeline. The objective is to inspect the captured NUL-delimited argv directly and
make failures carry the observed records needed for diagnosis.
#### Normative requirements
1. `FSP-REQ-01`: The pane-boundary test SHALL validate an adjacent `/usr/bin/env`, `-i` argv pair from
the authoritative NUL-delimited tmux capture without a short-circuit pipeline whose upstream status
can override a successful match.
2. `FSP-REQ-02`: Missing, reversed, or non-adjacent boundary tokens SHALL fail, while valid boundaries
SHALL remain valid regardless of trailing argv size, pipe capacity, process scheduling, or host/CI
utility implementation.
3. `FSP-REQ-03`: A failed boundary check SHALL print stable indexed, shell-escaped observed argv records
before exiting nonzero; the fixture SHALL continue to contain generated non-secret launch data only.
4. `FSP-REQ-04`: Verification SHALL include RED-first large-payload evidence, negative token-order
controls, the complete focused launcher suite, canonical Woodpecker CI, and independent review.
#### Acceptance criteria
1. `AC-FSP-01`: A large captured argv with adjacent `/usr/bin/env`, `-i` passes even when the former
`grep -q` pipeline returns nonzero from an upstream SIGPIPE.
2. `AC-FSP-02`: Missing executable, missing flag, and detached/reversed flag fixtures return nonzero and
emit the indexed observed argv.
3. `AC-FSP-03`: The focused suite passes on the development host and CI image, and the merged-main
Woodpecker pipeline is terminal green before #1098 closes.
---
## Exact Cross-Harness Fleet Communications Contract (#766)
### Problem and objective
Fleet runtime contracts currently combine exact peer rows with generic operational metavariables and
independently parsed roster data. Non-Claude harnesses can mistake those metavariables for values to
infer, producing incorrect host, session, socket, or helper targets. The objective is one
roster-resolved communications contract that every supported harness receives unchanged.
### Normative requirements
1. `FCOM-REQ-01`: Fleet commands and runtime composition SHALL use one shared v1 roster structural
resolver. A second lenient communications parser is forbidden.
2. `FCOM-REQ-02`: The composed contract SHALL render the local roster member's authoritative host,
exact agent/session name, resolved tmux socket, exact helper path, and deterministic communications
generation.
3. `FCOM-REQ-03`: Every known peer SHALL have one exact executable command. Same-host commands SHALL
omit `-H`; cross-host commands SHALL use only that peer's explicit roster `ssh` target; the one
supported fleet-wide named socket SHALL use `-L` with its exact value. A per-agent socket declaration
must equal that fleet-wide value; unsupported independent sockets and missing cross-host SSH data SHALL
fail closed.
4. `FCOM-REQ-04`: Operational fleet examples SHALL not contain unresolved host, session, socket, or
helper-path metavariables. Agents SHALL select an exact rendered peer row and SHALL NOT infer,
substitute, or fuzzy-match targeting values.
5. `FCOM-REQ-05`: An unknown local member or requested peer SHALL fail closed with exact-name discovery
guidance. Runtime composition SHALL not silently omit a requested fleet member's communications
contract.
6. `FCOM-REQ-06`: Claude Code, Codex, OpenCode, and Pi SHALL receive equivalent authoritative
communications data through the common runtime composer.
7. `FCOM-REQ-07`: Tests SHALL prove the contract from framework-source `TOOLS.md`, through a fresh
installed `TOOLS.md`, to final runtime composition and helper executability. User-owned installed
`TOOLS.md` content SHALL remain preserved.
8. `FCOM-REQ-08`: Stale installed or active composed context SHALL be reported with deterministic
generation/repair/relaunch guidance. Currency requires the expected source and installed contract
marker/version plus bounded byte equality. The supported current-version repair SHALL run independently
of package updates, preserve divergent `TOOLS.md` bytes in a digest-qualified no-clobber backup, restore
a regular executable helper without following symlinks, and be idempotent. Detection and reporting SHALL
NOT rewrite active context, restart a session, or mutate a live fleet.
9. `FCOM-REQ-09`: The shared resolver SHALL preserve and strictly validate every schema-supported v1
connector kind (`tmux`, `discord`, and `matrix`) from YAML and JSON. Every accepted snake/camel alias
pair SHALL reject differing dual declarations and accept identical declarations. JSON roster fallback
SHALL occur only when `roster.yaml` is absent; all other YAML access failures SHALL fail closed.
10. `FCOM-REQ-10`: The communications generation SHALL cover the complete canonical rendered semantic
contract, including identity, role/class, resolved host/socket/helper, peer metadata, and exact commands.
Installed helpers SHALL be validated with no-follow filesystem inspection as regular executable files.
Keep-mode reseed and relaunch discovery SHALL preserve and support both YAML and JSON rosters.
### Acceptance criteria
1. `AC-FCOM-01`: Contract fixtures contain no unresolved operational targeting metavariables; local
identity contains exact host/session/socket/helper values.
2. `AC-FCOM-02`: Same-host, cross-host, named-socket, literal-default-socket, and missing-SSH tests prove
exact targeting and fail-closed behavior.
3. `AC-FCOM-03`: Unknown identities and peers report known exact names plus an exact self-scoped
discovery command; no fuzzy session selection is emitted.
4. `AC-FCOM-04`: Four-harness tests prove byte-equal authoritative communications sections.
5. `AC-FCOM-05`: Source, fresh-install, preserved-custom-install, stale-installed, composed-generation,
helper executable, agent-send socket isolation, and exact-target tests pass.
6. `AC-FCOM-06`: Documentation defines non-mutating stale-context detection and operator-authorized,
exact-agent relaunch; no implementation path performs automatic session mutation.
7. `AC-FCOM-07`: YAML and JSON fixtures cover every connector kind; all snake/camel aliases cover
identical acceptance and conflicting rejection; non-`ENOENT` YAML failures do not fall back.
8. `AC-FCOM-08`: Missing, directory, symlink, and non-executable installed helpers fail closed. Explicit
current-version repair proves partial-deletion recovery, digest-qualified backup collision safety,
symlink-target safety, and repeated-run idempotence.
9. `AC-FCOM-09`: Markerless-equal and wrong-version source/installed contracts are stale, and a rendered
role/class change produces a different communications generation.
---
## KBN-101 Database Runtime/Migration Role Split (#771)
### Problem and objective
PostgreSQL Gateway/storage currently uses one `DATABASE_URL` for runtime queries and migrations. That makes the deployed application identity an owner and prevents certification that KBN immutable event, artifact, checkpoint, and evidence relations reject runtime `UPDATE`/`DELETE`. KBN-101 freezes a least-privilege runtime/migration split before KBN-100 schema work.
### Normative requirements
1. `K101-REQ-01`: `DATABASE_URL` SHALL be the non-owner PostgreSQL runtime connection and `DATABASE_MIGRATION_URL` SHALL be the migration-only owner/migrator connection. They are required respectively for runtime and the dedicated `mosaic-db-migrator --run|--verify` phase in `standalone`/`federated`; local PGlite is the explicit exception. The published `@mosaicstack/db` bin maps exactly `mosaic-db-migrator` to `./dist/cli.js`, its image entrypoint is exactly `mosaic-db-migrator`, accepts no URL/SQL/schema/role argv, and returns stable sanitized exits. Every current/future PostgreSQL DDL entrypoint SHALL route to that runner or be denied, and SHALL reject `DATABASE_URL`-only execution before connection/DDL. Data migration may connect only after the runner prepares and verifies the PostgreSQL target, through dedicated non-DDL `mosaic_data_importer` and exactly `--target-url-file /run/secrets/mosaic-migrate-target-url`, its fixed paired authenticated provider-version file `/run/secrets/mosaic-migrate-target-version`, plus `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`. KBN-101-05 obtains URL key `url` and version only from the same successful Vault KV-v2 response at `secret-{env}/mosaic-stack/database/importer` (`data.metadata.version`), renders them as one immutable generation into separate consumer copies, and never infers a provider version from DSN bytes. The trusted runner verifies TLS/identity/manifest, reads its fixed importer URL/version copies only for binding through safe no-follow fd checks, and signs a credential-free JCS/Ed25519 attestation using its runner-only fixed root-owned private-key file; no signing key reaches importer/runtime. The artifact binds secret version and SHA-256 of exact high-entropy credential-file bytes, canonical TLS host/port/database, CA/SPKI, PostgreSQL system identifier/database OID, importer role, manifest/schema fingerprints, producer invocation/build/image digest, issued/expires/nonce, and correlation. Before target connection the importer validates URL/version/attestation/public-key files, signature/key/expiry/replay/authenticated provider version/digest/generation/bindings and the importer-only CA at exact `DATABASE_TLS_CA_CERT_PATH`; after verified TLS and before DML it validates server/database/role/CA/schema identity, with same-fd/in-memory-byte TOCTOU protection, rotation/revocation, a privileged producer-only-to-importer-only artifact handoff controller that verifies/copies/fsyncs/atomically renames/seals before importer start, consumer isolation/no logging-oracle, and sanitized errors. Raw `--target-url`, `DATABASE_URL` fallback, runtime-owner use, missing/unsafe/substituted files, stale/replayed/tampered/wrong-key attestation, wrong binding, and DDL attempt fail before target connection/DDL; post-connect mismatch closes with zero DML/DDL. A reviewed finite classifier inventories executable current source/scripts/package bins, operator docs, deploy manifests, and exact normative contracts by path; active secure records pin both options/files, producer/key/bindings/tests, while normative contracts cannot mask instructions. Unknown active commands, duplicate-owner, ownerless, missing-path, and historical/status-only masking hits fail. `db:push` is forbidden outside an explicitly disposable local developer database and cannot accept a production-like URL.
2. `K101-REQ-02`: Gateway runtime/replicas SHALL not execute migrations or DDL. The runner SHALL hold one `max:1` session and fixed two-int advisory namespace `1297044289` (`MOSA`), `1262636593` (`KBN1`) across preflight, reconciliation, migration, verification, and release. It SHALL compare the versioned canonical manifest v1 tuple (journal logical index/tag plus exact SQL-byte SHA-256) to the complete observed ledger mapping; count/set-only, timestamps, and physical insertion order are non-normative and insufficient.
3. `K101-REQ-03`: PostgreSQL SHALL separate non-login platform database owner, non-login schema owner, dedicated `NOLOGIN SUPERUSER` `mosaic_extension_owner`, login migrator, dedicated login non-DDL data importer, non-login runtime capability, and login runtime roles. For PostgreSQL 17 + pgvector 0.8.2, `vector` is untrusted (`trusted` is absent and `relocatable=true`): only an externally controlled audited platform-bootstrap superuser session may `SET ROLE mosaic_extension_owner` for CREATE/UPDATE/SET SCHEMA, then `RESET ROLE`; the role has `rolcanlogin=false`, `rolsuper=true`, zero members, no runtime credential/Vault secret, and is never provided to app containers. It owns `mosaic_extensions`, fresh `vector`, and owner-bearing extension members, while `mosaic_schema_owner` receives only `USAGE` for type resolution and never ownership/`CREATE`/`ALTER`/`DROP`/member-change/default-privilege authority there. Superuser cannot be constrained by `GRANT`/`REVOKE`; this is identity/non-login/no-membership/external-control/audit isolation, not a false least-privilege claim. Extension operations require control-plane change, independent review, backup/rollback, maintenance window, and audit evidence. Managed targets that cannot establish this exact role are ineligible until an independently approved versioned provider-owned extension-owner profile exists; app/migrator ownership is never silently retained. Existing approved-owner extension relocation validates exact `pg_namespace.nspowner`, `pg_extension.extowner`, member ownership/schema/version, while legacy runtime-owned extension fails closed to a controlled shadow-database migration—never unsupported ownership alteration, catalog mutation, ownership adoption, or `DROP CASCADE`. Runtime, migrator, schema owner, importer, and all service roles must fail `SET ROLE`, catalog/direct `ALTER`/`UPDATE`/`DROP`/membership-change denial, role ownership, superuser/role-creation/schema-creation/TEMPORARY, unsafe membership, untrusted search path, missing grants, unauthenticated TLS, and immutable privilege drift checks. Application schema is fixed `mosaic` with exact `pg_catalog,mosaic` session path; historical public migrations remain byte-immutable legacy bootstrap only, every future Drizzle application declaration targets `mosaic`, and `vector` is explicitly qualified from non-writable `mosaic_extensions`. No config-derived SQL identifier is permitted.
4. `K101-REQ-04`: `mosaicstack/stack` KBN-101-00 SHALL exclusively own `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, and bootstrap tests; KBN-101-05 SHALL exclusively own `tools/db/render-postgres-secrets.ts`, its tests, and current Compose/Portainer/two-gateway deployment declarations, consuming the versioned bootstrap interface without overlap. Environment IaC/Vault is named input and Mosaic deployment control plane/Jason is activation authority. Distinct runtime/migrator/importer URL, importer authenticated provider-version, DB-client CA, Gateway leaf, and PostgreSQL server key/certificate materials are provisioned before a production-like database starts. Importer and migrator have separate immutable URL/version copies at fixed `10002:10002`/`10003:10003` identities; runtime/unrelated containers receive neither importer material, attestation private key, or importer artifact. Runtime, migrator, and importer require their mounted CA plus `sslmode=verify-full`. Exact UID/GID/mode/rendering, service-DNS SANs, Vault/compose/Swarm consumer isolation, two-gateway pair ordering, server activation, pre-enforcement legacy-client drain and `hostssl` zero-plaintext-session proof, fresh/existing transition, CA-overlap rotation, TLS-only rollback, and standalone/federated/Swarm/two-gateway positive/negative TLS evidence are required. No application-generated production certificate or plaintext bootstrap exception is permitted.
5. `K101-REQ-05`: KBN immutable relations SHALL permit the real runtime role INSERT/SELECT only and deny UPDATE/DELETE; parent retention remains RESTRICT/no-cascade. Role/password/Vault creation is external platform control, never application migration/source.
6. `K101-REQ-06`: N-1 single-URL compatibility, rollout/rollback, Vault ownership/rotation/redaction, CI, installer, compose/Portainer, observability, and deployment handoffs SHALL be separately bounded one-card/one-PR work. Prepared slices remain inactive while current owner-runtime deployments stay N-1; Mosaic control plane/Jason alone authorizes one final atomic activation or rollback, with no force-on-red/bypass. KBN-101 planning itself SHALL not mutate production.
7. `K101-REQ-07`: KBN-100 SHALL begin only after the KBN-101 foundation role/schema-boundary certificate; it SHALL rebase on that main head, restore generated Drizzle declaration/snapshot/journal consistency, and bound procedural immutable-table grant/trigger/backfill additions to its schema slice. KBN-101 real deployed-role immutable-operation certification SHALL complete after KBN-100 creates those relations and before KBN-105.
### Acceptance criteria
1. `AC-K101-01`: DTO/command-matrix tests prove required modes, PGlite exception, `mosaic-db-migrator --help|--run|--verify`/stable exits/argv refusal, public-import negative, every finite classified DDL/static-bypass inventory path and both harness pairs reject `DATABASE_URL`-only before connection/DDL, no migration-to-runtime fallback, and `db:push` refusal outside an allowlisted disposable DB. Before inventory, ownership, or status masking, the semantic fixture fails README's exact former commented code-fence generic-wrapper form and the user guide's exact former executable generic-wrapper form; source-consistency proves current `packages/storage/src/cli.ts` directly `execSync`s `pnpm --filter @mosaicstack/db db:migrate` and no `mosaic-db-migrator` bin exists, so runner-delegation documentation fails. The active `docs/guides/migrate-tier.md` route is inventoried to KBN-101-07 and proves runner-produced `--target-url-file /run/secrets/mosaic-migrate-target-url`, fixed paired provider-version file, and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`; runner-only signing/private-key isolation; Vault KV-v2 same-response version provenance, separate immutable generation mounts, importer CA, JCS/Ed25519 signature/key rotation/revocation, atomic artifact, expiry/replay, safe-fd secret-version/digest, canonical TLS/CA/server/database/role/manifest/schema bindings, dedicated non-DDL importer, consumer isolation/no log-oracle, and exact no-connection versus zero-DML rejection for missing/wrong/stale/replayed/tampered/wrong-key/substituted/generation-mismatched inputs. The full current non-normative docs inventory—including user guide, federation historical task/MILESTONES status, and non-operative SETUP—has an exact safe disposition. Scanner semantic checks reject automatic first-boot/startup extension/schema/migration wording, Compose-up-before-runner, init-script authority, production `.env`/monorepo auto-load/`EnvironmentFile=`/credential-export-or-argv/restart-as-secret-activation routes, and every unqualified operator-document `mosaic-db-migrator --run|--verify` hit regardless of named/normative/status classification. The exact former README/dev/deployment Compose-first sequences, former SETUP wording, exact former MILESTONES wording `pgvector extension installed + verified on startup`, former architecture-plan/PERFORMANCE/backlog runner routes, and any unqualified runner fixture fail before inventory masking. Only one `Held future procedure` Markdown section—bounded through the next equal-or-higher heading—may contain the explicit non-operative/no-current-command-authority form that names KBN-101-00/-03/-05 and preserves external bootstrap → TLS/roles → `mosaic-db-migrator --run``mosaic-db-migrator --verify` → Gateway/Compose readiness; every runner hit outside that section fails. The README assertion for the checked-in direct CI `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` passes only as active legacy N-1, uncertified, non-authorizing-as-an-operator-route status against an isolated disposable CI database pending KBN-101-06 removal—not as an ordinary operator or approved DDL-authority route. Only local PGlite data-layer work or non-PostgreSQL Compose is current (Gateway/Web local startup is held pending daemon/inherited/project-DSN rejection).
2. `AC-K101-02`: Fixed namespace lock contention/crash/readiness/non-interference and exact manifest-v1 reconciliation tests prove no replica race/runtime auto-migration and fail closed on every missing/unknown/duplicate/ambiguous/corrupt/stale ledger state.
3. `AC-K101-03`: Actual PostgreSQL 17 + pgvector 0.8.2 control-file, catalog, Drizzle-generation, vector-query/operator, fresh/approved-owner/legacy-shadow/partial/resume/rollback/N-1, and real deployed-role tests prove `trusted` absent/untrusted plus relocatability, external-superuser `SET ROLE` create/update/`RESET ROLE` audit, exact `rolcanlogin=false`/`rolsuper=true`/zero-membership/no-runtime-secret state, platform/schema/extension-owner/migrator/importer/runtime separation, `pg_extension.extowner` plus owner-bearing extension-member/schema/version assertions, and runtime/migrator/schema-owner/importer/all-service-role `SET ROLE`/ALTER/DROP/member-update denial. They also prove `pg_catalog,mosaic` per-session pool safety, `mosaic_extensions` qualification, identifier injection denial, ownership/membership/ledger-read/TEMP/default grants, and unsafe privilege denial.
4. `AC-K101-04`: Disposable standalone, federated/Swarm, and two-gateway verified-TLS positives plus for both pairs missing CA/wrong CA/wrong SAN/sslmode downgrade, server/Gateway key mode, UID/GID, secret-consumer isolation, and legacy-drain/`hostssl` negatives prove server bootstrap, ordering, and readiness; PGlite is expressly excluded from this PostgreSQL evidence.
5. `AC-K101-05`: Real runtime-role evidence proves INSERT/SELECT succeeds and UPDATE/DELETE fails for every frozen immutable KBN relation.
6. `AC-K101-06`: N-1/atomic activation/rollback, Vault/CA-overlap rotation/redaction, health/operator behavior, CI/deployment handoff, independent exact-head security review, and terminal-green CI evidence the foundation before KBN-100; after KBN-100, the real deployed-role immutable-operation certificate and Ultron approval release KBN-105.
**Normative implementation contract:** [`docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md`](./native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md). `ASSUMPTION:` existing `standalone` and `federated` are all PostgreSQL production-like modes; any new PostgreSQL tier inherits these requirements until an explicit versioned amendment.
---
## Tess Interaction Agent Workstream (TESS)
### Problem and Objective
Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent.
The objective is to ship **Tess** (from _tessera_, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency.
### Scope
#### In Scope
1. `TESS-ARP-001`: A runtime-neutral `AgentRuntimeProvider` contract supporting `listSessions`, `streamSession`, `sendMessage`, `terminate`, `getSessionTree`, `attach`, health, capability discovery, and normalized events/errors.
2. `TESS-PI-001`: A long-running Pi-native Tess agent profile/service pinned to GPT-5.6 Sol with high reasoning, explicit tool policy, lifecycle hooks, durable checkpoints, and restart recovery.
3. `TESS-DSC-001`: Dedicated Discord channel binding to Tess through the Mosaic gateway, with allowlists/RBAC, thread/reply policy, streaming, attachments, approvals, and correlation IDs.
4. `TESS-CLI-001`: `mosaic tess` CLI commands for chat, status, session listing, attach/detach, send/steer/stop, provider health, and recovery.
5. `TESS-FLT-001`: Fleet plugin capabilities for roster/status/heartbeat inspection, message delivery, session hierarchy, safe attach, and controlled restart/recovery.
6. `TESS-MOS-001`: Explicit Mos coordination boundary and tools: hand off orchestration requests, observe mission/task state, receive results, and never silently compete for orchestration authority.
7. `TESS-HRM-001`: Transitional Hermes adapter for profiles/agents, sessions, streaming/messages, Kanban, skills, memory, tools, cron, and health, using capability negotiation and fail-closed unsupported operations.
8. `TESS-MEM-001`: Unified memory/retrieval plugin with scoped search/recent/capture/stats, startup context injection, provenance, redaction, namespace isolation, and flat-file/project truth precedence.
9. `TESS-STA-001`: Durable agent state, inbox, handoff, compaction-recovery, and resume reconstruction.
10. `TESS-PLG-001`: Plugin/tool catalog covering runtime bootstrap, repository/PR workflow, fleet diagnostics, incident-safe read operations, Discord interaction, and extensible MCP/skill discovery.
11. `TESS-TRN-001`: Replaceable transport providers: tmux/fleet now, Matrix/native Mosaic transport later, with no Discord/CLI business logic coupled to transport details.
12. `TESS-SEC-001`: RBAC, per-operation authorization, explicit approval for destructive/privileged/customer-visible actions, audit events, secret/PII redaction, tenant isolation, and bounded command execution.
13. `TESS-SEC-002`: Command execution SHALL enforce declared scope/role server-side; admin/system and destructive operations SHALL require policy-bound durable approval.
14. `TESS-SEC-003`: Every session list/read/attach/send/terminate operation SHALL enforce server-derived owner and tenant scope; guessed or client-supplied IDs SHALL grant no authority.
15. `TESS-SEC-004`: MCP tools SHALL derive actor/tenant from authenticated context and SHALL NOT accept caller-controlled identity fields.
16. `TESS-SEC-005`: Discord plugin ingress SHALL authenticate service identity, enforce guild/channel/user allowlists, propagate correlation/message IDs, and reject replay.
17. `TESS-SEC-006`: Secret/PII classification and redaction SHALL occur before persistence and before channel egress, including tool metadata and authentication flows.
18. `TESS-SEC-007`: Approvals SHALL be one-time, expiring, actor/tenant-bound, and cryptographically bound to the exact structured action digest.
19. `TESS-SEC-008`: Ingress, provider sends, tool side effects, and responses SHALL use durable inbox/outbox/checkpoints and idempotency records for restart-safe replay.
20. `TESS-SEC-009`: Garbage collection and retention SHALL be session/tenant scoped unless executed as a separately authorized and audited system-wide job.
21. `TESS-OBS-001`: Structured logs, traces, health/readiness, provider latency/errors, session lifecycle, tool audit, and actionable recovery diagnostics.
22. `TESS-MIG-001`: Capability inventory and staged Hermes-to-Mosaic migration matrix with coexistence, cutover, rollback, and deprecation gates.
#### Out of Scope
1. Replacing Mos as coding/general fleet orchestrator.
2. Making Hermes the Mosaic core or coupling Mosaic domain logic to Hermes schemas.
3. Migrating every historical chat verbatim; only policy-compliant indexed summaries and user-selected sessions are migrated.
4. Unrestricted shell execution from Discord.
5. Full web UI parity in the first Tess operational milestone; gateway contracts must remain web-consumable.
6. Replacing tmux before Matrix/native transport reaches operational parity.
### Stakeholder and User Requirements
- Jason must be able to converse with the same Tess session from Discord and CLI.
- Jason must be able to see what is running, stale, blocked, or unhealthy without attaching manually to every session.
- Jason must be able to attach to Tess and authorized fleet sessions through supported CLI controls.
- Tess must collaborate with Mos and the fleet while preserving a single clear orchestration authority.
- The system must migrate useful Hermes/OpenClaw capabilities intentionally, with evidence, instead of copying implementations wholesale.
### Non-Functional Requirements
1. **Security:** default-deny provider/tool capabilities, least privilege, no secrets in logs/prompts/commits, Discord user/channel authorization, and auditable approvals.
2. **Reliability:** durable inbox/checkpoints; idempotent message handling; reconnect with bounded backoff; no message loss or duplicate execution across gateway restart.
3. **Performance:** first acknowledgement within 2 seconds when connected; streamed agent output begins within 5 seconds excluding model/provider delay; status reads return within 2 seconds under nominal local conditions.
4. **Observability:** every ingress message and resulting provider/tool operation carries a correlation ID across Discord, gateway, Tess, provider, and audit events.
5. **Maintainability:** channel, runtime, transport, memory, and external-agent integrations remain adapter-based with contract tests.
6. **Privacy:** only scoped context enters external runtimes; persisted messages/memories follow retention and redaction policy.
7. **Portability:** Tess runs through Pi/Mosaic contracts and does not require Hermes to start or serve native Mosaic operations.
### Acceptance Criteria
1. `AC-TESS-01`: A dedicated Discord channel and `mosaic tess chat` connect to one durable Tess session and stream responses bidirectionally.
2. `AC-TESS-02`: `mosaic tess status|sessions|tree|attach|send|stop` operate against authorized provider capabilities with stable typed outputs and actionable errors.
3. `AC-TESS-03`: Tess runs GPT-5.6 Sol at high reasoning and its effective runtime/model/tool policy is visible through status without exposing credentials.
4. `AC-TESS-04`: Tess can inspect and message the Mosaic fleet, hand orchestration work to Mos, and demonstrate that Tess does not independently claim Mos-owned orchestration work.
5. `AC-TESS-05`: Hermes adapter demonstrates session listing, streaming/message delivery, hierarchy mapping, and at least one approved capability in each of Kanban, skills, memory, tools, and cron—or reports unsupported capabilities fail-closed.
6. `AC-TESS-06`: Restart/compaction test preserves session identity, pending inbox, last durable checkpoint, and a resumable handoff without duplicate side effects.
7. `AC-TESS-07`: Unauthorized Discord users/channels, cross-tenant access, unsafe tool calls, forged approvals, and sensitive-output cases are denied and audited.
8. `AC-TESS-08`: tmux/fleet and Matrix/native transport implementations pass the same provider contract suite; Matrix may remain non-default until readiness gates pass.
9. `AC-TESS-09`: Baseline quality gates, unit/integration/contract tests, Discord+CLI E2E, restart/recovery tests, independent code review, and security review are green.
10. `AC-TESS-10`: Migration matrix documents every audited Hermes/OpenClaw capability as native, adapted, deferred, or rejected, with cutover and rollback evidence.
11. `AC-TESS-11`: User, admin, developer, API/OpenAPI, operations/recovery, and plugin-authoring documentation is current and linked from the sitemap.
### Constraints, Dependencies, Risks, and Assumptions
- Dependency: Mosaic gateway remains the single API surface; Pi is the native runtime; Valkey/PostgreSQL provide canonical durable state where required.
- Dependency: Discord bot credentials and dedicated channel ID are deployment secrets provisioned outside source control.
- Risk: Tess could drift into a second orchestrator. Mitigation: explicit role policy, Mos handoff contract, authority checks, and E2E boundary tests.
- Risk: broad Hermes compatibility can freeze legacy semantics into Mosaic. Mitigation: Mosaic-owned normalized contracts and capability negotiation.
- Risk: Discord creates a privileged remote-control surface. Mitigation: pairing/allowlists, RBAC, approvals, rate limits, audit, and safe tool classes.
- Risk: transcript ingestion can violate privacy or overload memory. Mitigation: scoped opt-in import, redacted summaries, provenance, retention, and deduplication.
- Risk: current root filesystem has limited headroom. Mitigation: isolated worktrees, no duplicated dependency installation unless required, and cleanup only after active-lane verification.
- `ASSUMPTION:` The public name is **Tess**, because the user requested a name and the tessera/Mosaic relationship is distinctive; config must permit later display-name changes without renaming APIs or storage keys.
- `ASSUMPTION:` The dedicated Discord channel ID and final guild policy will be supplied/provisioned during deployment, so implementation uses explicit configuration and fail-fast startup validation.
- `ASSUMPTION:` tmux/fleet is the production transport for the first operational milestone; Matrix/native transport is implemented behind the same contract and promoted only after parity/reliability verification.
- `ASSUMPTION:` Project/task truth remains in canonical Mosaic/project stores; semantic memory systems are retrieval/mirror layers, not hidden authorities.
### Testing and Delivery Intent
Delivery uses five gated milestones: runtime contracts/security; Pi service/state; Discord/CLI; fleet/Hermes/plugin suite; migration/Matrix/recovery/qualification. Every source-code task requires tests, independent review, a PR to `main`, terminal-green CI, and issue/task closure. Production activation additionally requires a clean-host Pi launch, dedicated Discord channel smoke test, CLI attach test, restart/recovery drill, and rollback procedure.
---
## Official Channel Plugin Workstream (#756)
### Problem and Objective
The Discord plugin currently couples Discord event handling, gateway bridging, and reply routing in one implementation and activates only on mentions. Mosaic needs an official channel adapter that behaves the same no matter whether the bound logical agent currently runs through Claude, Codex, Pi, OpenCode, or a future harness. The Discord connection and conversation address must remain stable while the gateway changes the runtime provider behind that logical session.
The objective is to make Discord the first implementation of a transport-neutral official channel contract, with explicit authorization and deterministic channel/thread routing that future Matrix, Slack, and other adapters can share.
### Scope
#### In Scope
1. `CHN-001`: Transport-neutral channel adapter, route, message, attachment, authorization-principal, response-target, and health contracts in `@mosaicstack/types`, including trusted per-binding logical-agent configuration selection.
2. `CHN-002`: Stable channel conversation addresses based on logical agent plus channel/thread identity; harness, model, and runtime-provider IDs are forbidden from channel session keys.
3. `DSC-001`: An authorized untagged message in a configured agent-bound channel routes to the agent and receives its response in that channel.
4. `DSC-002`: A bot mention in a configured parent channel creates a Discord thread, or reuses the thread already attached to that same native message; the mentioned turn and subsequent thread turns route and respond in that thread.
5. `DSC-003`: A message already inside an authorized thread inherits authorization from its configured parent and never attempts a nested thread.
6. `DSC-004`: Guild, parent channel, user, pairing, and role authorization remains default-deny before thread creation or gateway dispatch.
7. `DSC-005`: Discord service authentication, HMAC envelope integrity, replay protection, attachments, approvals, response chunking, and correlation behavior remain intact.
8. `DSC-006`: The Discord adapter exposes lifecycle and health behavior through the shared channel contract without importing a harness SDK.
#### Out of Scope
1. The logical-agent lease, fencing epoch, execution grant, checkpoint, or cross-harness takeover implementation tracked by #754/#755.
2. Dynamic Discord authorization administration in the web UI.
3. Multi-guild tenant isolation, DMs, slash commands, voice, reactions, or production bot deployment.
4. Implementing Matrix or Slack adapters in this slice.
### Non-Functional Requirements
1. **Security:** no thread or dispatch side effect occurs until guild, parent channel, user, pairing, role, and bounded per-user/channel rate checks pass; attachment metadata is shape- and size-bounded; credentials never enter source, messages, session keys, or logs.
2. **Portability:** channel contracts and stable conversation IDs contain no Claude, Codex, Pi, OpenCode, model, process, or provider-specific field; each configuration-owned binding selects its trusted logical agent without changing the channel identity.
3. **Reliability:** repeated messages for one channel/thread resolve the same conversation handle; reconnecting the adapter does not require a harness-specific rebinding.
4. **Maintainability:** Discord-specific API translation stays in the Discord package; gateway and future adapters depend on transport-neutral contracts.
5. **Observability:** thread creation or routing failure is reported without message content or credential material.
### Acceptance Criteria
1. `AC-CHN-01`: Contract and behavior tests prove the plugin route contains only logical agent plus channel/thread identity and produces the same stable conversation handle regardless of underlying harness selection.
2. `AC-CHN-02`: A mentioned authorized parent-channel message creates a thread (or reuses its already-attached thread), dispatches to the thread conversation, and targets the response to that thread.
3. `AC-CHN-03`: An untagged authorized parent-channel message dispatches to the parent conversation and targets the response to the parent channel.
4. `AC-CHN-04`: Untagged follow-ups inside an authorized thread dispatch and respond in that same thread without creating a nested thread.
5. `AC-CHN-05`: Unauthorized guilds, channels, users, unpaired users, insufficient roles, and rate-limited senders produce no thread and no gateway dispatch.
6. `AC-CHN-06`: Shared channel contracts are exported from `@mosaicstack/types`, Discord implements the lifecycle/health seam, and no harness SDK is imported by the plugin.
7. `AC-CHN-07`: Focused routing/auth tests, package tests, typecheck, lint, formatting, coverage, independent code/security review, and terminal-green CI pass.
### Constraints, Risks, and Assumptions
- Dependency: Mosaic gateway remains the policy, durable-session, audit, and runtime-provider boundary.
- Constraint: This work must not modify orchestrator-to-Pi migration or #754/#755 lease/fencing files.
- Risk: accepting untagged messages could create noisy or unintended agent input. Mitigation: only explicitly configured channels and paired, role-authorized users are accepted, with bounded per-user/channel message and thread rates.
- Risk: Discord thread creation can fail because of channel permissions, archived state, or API rate limits. Mitigation: fail without dispatching a turn whose response destination cannot be honored, and emit sanitized diagnostics.
- `ASSUMPTION:` Configured channels are dedicated agent interaction surfaces, so authorized untagged human messages are intentional agent input.
- `ASSUMPTION:` Mention in a parent channel selects a public thread; messages already in a thread remain there because Discord has no nested threads.
- `ASSUMPTION:` One Discord bot may serve multiple configuration-owned logical-agent bindings.
- `ASSUMPTION:` Static allowlists and paired-user roles are the authorization administration surface for this slice.
### Testing and Delivery Intent
Use TDD for remote-ingress routing and permission boundaries. Required evidence includes parent-channel mention, untagged parent message, existing-thread follow-up, existing-thread mention, thread reuse, unauthorized side-effect denial, stable harness-neutral conversation identity, adapter health, and regression coverage for signed envelopes and approvals. Deliver through issue #756, a reviewed squash PR to `main`, terminal-green CI, and issue closure.
---
## Mos Runtime Portability Workstream (MOS-PORT)
### Problem and Objective
Mos is currently identified partly by a harness-native session and communication process. Replacement/rebinding exists, but no gateway-enforced logical identity or fencing prevents a stale harness from continuing to reply or execute effects after takeover.
The objective is to make Mos a server-derived logical Mosaic identity whose authority can move safely among runtime connectors. The gateway owns identity, lease, policy, and audit; harnesses remain replaceable adapters.
### M1 Requirements
1. `MOS-PORT-ID-001`: Define a normalized logical-agent identity independent of Claude Code, Pi, Codex, tmux, Matrix, and provider-native session IDs.
2. `MOS-PORT-LEASE-001`: Persist one exclusive connector lease per tenant/logical-agent/binding with CAS acquisition, monotonic fencing epoch, TTL, heartbeat, explicit release, and takeover.
3. `MOS-PORT-FENCE-001`: Bind every connector dispatch/execution grant to the current server-derived tenant, logical identity, binding, connector, scopes, expiry, and lease epoch.
4. `MOS-PORT-FENCE-002`: Reject and audit stale, expired, forged, cross-tenant, cross-binding, and unauthorized grants before connector, channel, provider, or tool side effects.
5. `MOS-PORT-OBS-001`: Emit credential-safe correlation/audit events for lease acquire, renew, takeover, reject, release, and expiry.
6. `MOS-PORT-ARCH-001`: Runtime/provider adapters consume normalized lease context without adding harness-native schemas to Mosaic core.
### M1 Acceptance Criteria
1. `AC-MOS-PORT-01`: Two contenders for one binding cannot simultaneously hold current authority under concurrency.
2. `AC-MOS-PORT-02`: Successful takeover increments the fencing epoch and every operation from the old epoch fails closed before side effects.
3. `AC-MOS-PORT-03`: Gateway/database restart preserves lease and epoch state; expired leases can be recovered only through the authorized takeover path.
4. `AC-MOS-PORT-04`: Cross-tenant, cross-agent, cross-binding, forged, and expired lease/grant cases are denied and audited.
5. `AC-MOS-PORT-05`: Unit, migration, repository close/reopen, concurrency, abuse, gateway integration, independent security review, CI, and documentation gates pass.
### Deferred to Later #754 Milestones
Canonical checkpoint/handoff payloads, exactly-once connector receipts, concrete Claude/Pi/Codex adapters, channel cutover, and full cross-harness failover/rollback E2E are explicitly out of M1 scope.
---
## Workspace placement guard hardening (#1174)
### Problem and objective
The Bash pre-tool guard must prevent Git checkouts and repository state from being placed under
`$HOME` without refusing ordinary Git commands merely because a source, option value, branch name,
or metadata mentions `$HOME`. A guard that over-blocks routine work is unsafe because operators
will route around it.
### Scope and requirements
1. `WPG-REQ-01`: `git clone` and `git worktree add` placement SHALL be judged from their placement
operands, not from every HOME-shaped word in the command.
2. `WPG-REQ-02`: Clone sources, references, templates, environment assignments, and non-placement
worktree metadata MAY resolve under HOME when all placement operands resolve elsewhere.
3. `WPG-REQ-03`: Both attached and separate-value `--separate-git-dir` forms SHALL remain placement
operands and SHALL be refused when they resolve under HOME.
4. `WPG-REQ-04`: Option classification SHALL account for Git's rule-generated boolean negations
without relying on an enumerable allowlist of flag spellings.
5. `WPG-REQ-05`: Quote removal, escapes, shell command boundaries, redirections, and end-of-options
handling SHALL preserve existing fail-closed checkout coverage.
6. `WPG-REQ-06`: Absolute placement aliases SHALL resolve shell-known HOME spellings, dot segments,
repeated separators, and existing symlink parents before the HOME boundary comparison.
7. Relative targets whose effective path depends on the shell cwd are out of scope and tracked by
#1197.
### Acceptance and verification
1. Git's own option parser accepts each tested flag, including generated `--no-*` forms, while the
guard allows a HOME-valued source with an explicit safe destination.
2. Equivalent clone and worktree fixtures cover rule-generated negations and remain discriminating
against the prior head where the defect existed.
3. Real HOME destinations and both `--separate-git-dir` forms remain blocked, including placements
after shell command boundaries.
4. The full hermetic guard suite, syntax/static checks, adversarial probes, independent review, and
terminal-green CI pass before merge.
5. Any option-classification residual is documented with its deliberate failure direction.
### Constraints, risks, and assumptions
- Security and usability are co-equal: neither a placement bypass nor routine over-block is an
acceptable repair.
- `ASSUMPTION:` The value-taking option surface exposed by the installed Git version is closed and
measurable through Git's own parser/help output; rationale: boolean flags are rule-generated,
while separate-value options have explicit grammar and must be classified as such.
- Risk: a future Git release may add a new value-taking placement option. Mitigation: document the
chosen residual direction and pin every currently supported placement option in behavior tests.
- Risk: a symlink can be replaced after pre-execution canonicalization. Mitigation: resolve every
existing parent physically and document the remaining inherent TOCTOU window; the worktree helper
remains the authoritative path-derivation mechanism, with atomic closure tracked by #1199.
---
## Release Integrity Workstream (RI, #1275)
### Problem and objective
At `next` 476db12b (review of 2026-08-17), publication from `next` is not bound to the full verification pipeline for the same commit: the publish pipeline's publish steps depend on `build` only, while ordinary push CI excludes `next`. Public Forge/MACP paths contain false-success placeholders: a stub executor that reports `completed` with exit zero, planning/remediation gates that execute literal `true`, a review gate that echoes an approving verdict, and a gate runner that treats empty commands and unimplemented CI-provider gates as passing. Shipping UI surfaces can render a failed fetch as an empty, healthy collection.
Objective: for alpha 0.0.50, the release cannot publish, report, or display work state that the repository has not actually verified. Decisions SDLC-D-033 through SDLC-D-038 (Jason, 2026-08-17) scope this floor; full decision text and required-behavior lists live in jarvis-brain `docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` and `data/decisions/mosaic-stack-sdlc-protocol.json`. This section restates only the normative requirements.
### Normative requirements
1. **RI-N1 Exact-commit publication verification (SDLC-D-034).** One canonical terminal verification command performs self-contained re-verification in the publish pipeline against the job's checked-out commit before any external publication effect. The command contains or invokes the complete mandatory verification set (semantic parity with the PR merge gate, including sanitization, upgrade-guard, typecheck, lint, format check, tests, and build); CI and publication do not maintain separate semantic checklists. Every publish step depends on the verification step in the executable pipeline DAG. Provider commit identity and `git rev-parse HEAD` must identify the same commit. Missing, skipped, cancelled, stale, or inconclusive checks fail closed. Documentation-only runs may skip publication but cannot bypass verification when a publication effect will occur. A negative control must prove that a broken check blocks every publish step.
2. **RI-N2 Fail-closed Forge/MACP with explicit simulation (SDLC-D-035).** Simulation requires explicit caller intent (e.g. `--simulate`) and produces a distinct typed `simulated` state that can never satisfy dependencies, acceptance criteria, gates, merge, or release. Normal execution exits nonzero with a typed capability failure when a required executor, reviewer, command, or CI provider is absent — no stub completion, no literal-`true` gates, no synthetic approvals, no empty-command passes. A manual gate with no automation enters a waiting state; it does not pass. Positive tests prove explicit simulation still works; negative controls prove simulation and every missing-provider case cannot advance lifecycle state.
3. **RI-N3 One transitional PRD authority (SDLC-D-036).** `@mosaicstack/prdy` structured storage under `docs/prdy/`, driven by `mosaic mission --plan`, is the authoritative PRD representation for the alpha. `mosaic prdy` either routes through the same application service or operates only as an explicit, named Markdown import/export adapter; `docs/PRD.md` is not a peer authority. `mission --plan` must persist the mission↔PRD linkage (mission id/version, PRD id/version, selected requirements). Markdown output is a generated view carrying source identity; editing it cannot mutate authority silently. Import is explicit, validated, and conflict-aware (proposed successor, never overwrite). Structural validity is separate from approval.
4. **RI-N4 One quality-rails evaluator (SDLC-D-037).** The TypeScript quality-rails package is the sole authoritative evaluator. A complete probe inventory maps every current TypeScript and shell check to one canonical check with disposition (preserve/strengthen/retire, each named). Effective shell enforcement probes are absorbed before their independent paths retire; expected-file presence alone is not parity. The evaluator returns typed results (`passed`/`failed`/`blocked`/`error`/`not-applicable`) with check version, subject, and reason; missing implementation, missing input, unknown check, process error, timeout, or malformed output can never become `passed` or an unqualified skip. Check definitions and policy are versioned and digested. Shell commands become thin adapters with no separate verdict logic. The canonical terminal verification command (RI-N1) invokes this evaluator rather than duplicating its logic. Contract, parity, and negative-control tests are required, plus independent review of probe equivalence.
5. **RI-N5 Consequence-aware stale UI (SDLC-D-038).** Mission Control distinguishes typed freshness states (`current`, `stale`, `partial`, `unknown`, `unavailable`) rather than inferring from empty arrays or null. A failed fetch never renders as an empty healthy collection. Last-known data may display for situational awareness only with source identity, version, and age visibly labeled; any derived completion/assurance/release verdict whose inputs are stale becomes `unknown`; all state-changing actions are disabled until fresh state loads and is revalidated. With no verified snapshot, surfaces show an explicit unavailable state. Cache corruption, cross-workspace data, schema mismatch, and version regression invalidate the snapshot. Tests cover the failure matrix (network, auth, malformed, partial, corruption, stale age, schema mismatch, recovery, stale-action rejection) with negative controls proving no case yields a current green verdict or enabled mutation.
### Acceptance criteria
- AC-RI-1: A push to `next` that fails any mandatory verification step publishes nothing (no npm package, no image), demonstrated by a checked-in negative control and by pipeline evidence on a real `next` publish run where the verification step is green and every publish step depends on it.
- AC-RI-2: With no executor/reviewer/CI provider wired, Forge and MACP normal runs exit nonzero with typed capability failures; with `--simulate`, runs complete but every result is typed `simulated` and cannot satisfy any gate, dependency, or completion state — proven by unit tests including negative controls.
- AC-RI-3: A PRD created or revised through either `mosaic mission --plan` or `mosaic prdy` resolves to one authority under `docs/prdy/` with stable identities and versions; the mission↔PRD linkage survives restart; a Markdown export is labeled as generated and cannot silently become a second writer; divergent legacy content blocks baseline claims until explicitly resolved — proven by contract tests.
- AC-RI-4: `quality-rails check` through any entry point (TS CLI, framework shell adapter) returns the same typed verdict for the same subject; the probe inventory names every legacy check's disposition; a deliberately broken probe fails closed — proven by contract/parity/negative-control tests and independent review of probe equivalence.
- AC-RI-5: No shipping surface renders a failed fetch as an empty healthy state; stale/partial/unavailable states are typed, labeled, and mutation-disabled — proven by the failure-matrix tests.
- AC-RI-6: All cards merged to `next` via squash PR with terminal-green CI; release evidence for 0.0.50 records commit, verification run, and published artifacts.
### 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.
-910
View File
@@ -1,910 +0,0 @@
---
kind: spec
status: active
source_of_truth: true
---
# PRD: Mosaic Stack — North Star
This document is the product source of truth for Mosaic Stack.
- **Part I** defines the product north star. It is written from the ratified
decision set D1D14 (operator decision session, 2026-08-25; decision owner
Jason Woltje). Each section cites the decisions it implements.
- **Part II** preserves the active workstream contracts unchanged. Open issues
bind to them; this rewrite does not alter a single normative word in them.
- The previous v0.1.0 beta PRD body is archived verbatim at
[docs/archive/PRD-v0.1.md](./archive/PRD-v0.1.md) and is no longer authority.
- The delivery roadmap lives in [docs/ROADMAP.md](./ROADMAP.md). Per D11, every
planned phase appears there from day one, even as a placeholder.
## Metadata
- **Owner / decision authority:** Jason Woltje
- **Status:** active (supersedes the v0.1.0 PRD as product authority)
- **Date:** 2026-08-26
- **Decision registry:** D1D14, recorded in Part I §12
- **SSOT rule:** this repository's `docs/` tree is the product source of truth
(D5). Estate brains hold operational records, not product canon; only
product-relevant material migrates here (D6).
---
## Part I — Product north star
### 1. What Mosaic Stack is (D1)
Mosaic Stack is an **open-source, AI-first platform for people who want a
self-hosted environment for agentic management and a life operating system.**
It serves personal, business, and employee needs from one deployment, and the
work is offered freely.
"AI-first" means agents are first-class operators of the system, not a bolted-on
chat box: the platform exists to let humans direct fleets of agents over their
projects, tasks, communications, and infrastructure, with the same tools and
the same guarantees whether a human or an agent is acting.
### 2. Who it is for (D1, D9)
The operator of a deployment is its user. Mosaic Stack is **not a hosted
business**: running the system as a service for external customers is outside
the north star. Multi-tenancy exists WITHIN a deployment so that one operator
can separate their world — for example, several LLCs plus a personal domain —
while every deployment is self-hosted by its own operator.
"Company" in the hierarchy is organizational separation for one operator's
world, not a customer account.
### 3. Deployment modes (D3)
Two modes, chosen at install time:
| | Standalone / personal | Enterprise |
| ------------------- | -------------------------------------- | ----------------------------------------------------- |
| Brains | one mosaic-brain (system + user files) | system brain for config + one brain per user |
| User-data isolation | single user | no user-data leakage between users; sharing is opt-in |
| Secrets | OpenBao/Vault or flat files | OpenBao/Vault REQUIRED |
| Conversion | Standalone → Enterprise, **one-way** | terminal state |
Brains are configurable as external git repositories (recommended, not
required); git tracking is always on locally.
**Federation** (connecting deployments: system-level config, assigned users,
rights and data-access control, trusts with boundaries, exfiltration
monitoring) is intentionally not fully designed. It is deferred, appears on the
roadmap as a placeholder phase per D11, and nothing in v1 may foreclose it.
### 4. Structure and tenancy (D2, D9, D13)
The hierarchy:
```
company/organization (N per deployment)
└─ estate (each in exactly one company)
└─ project (each in exactly one estate)
└─ workspace (project-specific; carries the Kanban)
```
Rules:
- Users can create N companies, N estates, N projects.
- Tasks bubble UP the hierarchy so whole-system status is visible at every
level. Bubble-up is **read-only aggregation**, never a cross-workspace write.
- Granular RBAC: admins restrict access per company, estate, and project;
grants are evaluated down the chain. Assets are transferable subject to the
structure.
- **`workspace_id` remains the hard mechanical isolation unit** exactly as
ratified in
[docs/requirements/native-kanban-sot.md](./requirements/native-kanban-sot.md)
(#751): PostgreSQL sole writable SOT, cross-workspace relationships rejected,
fail-closed mutations. The hierarchy is parent structure ABOVE workspaces,
used for RBAC evaluation and read-only roll-ups. The kanban SOT carries this
as Amendment A1, added by reviewed PR — an amendment, not a rewrite (D13).
### 5. Identity (D10)
Built-in auth (better-auth) is the **account system of record**. Authentik and
other external IdPs federate in via OIDC as login methods; they never become
the system of record. Perimeter shims (forward-auth in front of a web host) are
deployment workarounds, not the design.
### 6. Onboarding (D4)
Onboarding is a **wizard that differs by mode, is re-runnable (no lock-in), and
is extensible** — new wizards attach as tabs.
Standalone flow captures: system and company name; component choices (Mosaic
Comms/Matrix vs external; Mosaic SSO/Authentik vs external; Mosaic
DB/PostgreSQL vs external; vector DB); the initial user
(email/password/name/SSO); comms setup (Matrix/Discord/Slack); agent enrollment
(harness choice and install, OAuth or API-key login, multi-account, model
choice with recommendation, agent name and persona, account assignment,
optional comms auto-enroll); a user onboarding profile (disabilities including
ADHD/autism/PDA/vision, professional background, education, desired agent
communication style, optional voice-matching interview, family/pets/friends/
hobbies/likes-dislikes); email and drive connectors (Gmail/IMAP, Google
Drive/OneDrive/Dropbox) with granular agentic-access consent; SSO/OIDC
configuration; an initial estate, an initial project, and seeded example data.
Enterprise uses the same skeleton with personal data optional; the focus moves
to business structure, org chart, RBAC, M365 and external systems, immediate
OIDC, SSO prominent.
Profile answers feed `USER.md` and/or the user's data store subject to the
custody rule in §7.
### 7. Data custody (D6, D14)
- **Sensitive profile categories** (disabilities, family, communication style,
and similar) live in the **user's own brain ONLY**. PostgreSQL holds
structural data, consent records, and pointers — never the content. "User
data does not leak" is enforced by architecture, not policy (D14).
- Standalone (one user, one brain) **may** keep the same split — D14 makes it
optional in Standalone, not required. Keeping it is the recommended default
because it preserves forward-compatibility with the one-way Enterprise
conversion (D3).
- Estate brains hold operational records. Only product-relevant material
migrates into this repository's docs; operational records stay in their
brains and are linked (D6).
### 8. Architecture gate — the webUI sits OVER official tooling (D8, D12)
**HARD RULE:** every webUI operation goes through the Gateway API backed by the
same official framework tooling the CLI uses. The CLI remains the primary
execution method; the webUI uses the tools to operate and configure the
system. The webUI never bypasses tooling to reach the database or filesystem
directly.
Consequence for planning: when a desired webUI operation has no backing tool,
the gap is scored **"blocked on tooling"** and the tool is built first. The
product baseline therefore always includes all three D8 inputs: the tool
inventory (what exists and what is missing), the webUI→tool mapping, and the
measured current state of the `next` branch.
### 9. v1 slice (D11)
v1 is deliberately small:
1. **Standalone onboarding wizard** — system/company name, component choices,
initial user, initial estate + project, seeded examples, re-runnable.
2. **Hierarchy core** — company → estate → project → workspace → kanban, with
read-only task bubble-up.
3. **Basic RBAC** on the hierarchy.
4. **Minimal agent enrollment** — one harness, API key, name/persona.
Deferred beyond v1: connectors, comms integrations, voice-matching, M365,
Enterprise conversion, federation. Every deferred item appears in
[docs/ROADMAP.md](./ROADMAP.md) per the D11 rule: nothing exists only in heads.
### 10. Relationship to the fleet north star
[docs/fleet/NORTH_STAR.md](./fleet/NORTH_STAR.md) (generated from
`docs/fleet/NORTH_STAR.yaml`) is the **delivery-fleet** north star: how the
agent fleet that builds and operates the system should run (NS-1..NS-10,
workstreams AL). This PRD is the **product** north star. They are not
competitors: the fleet north star is subordinate product-wise — its workstream
J ("Web control plane") is one consumer of this PRD's D8/D12 gate — and this
PRD does not redefine fleet invariants. The subordination rule is ratified in
the frozen audit-input baseline (T2 operator freeze, 2026-08-25: "the PRD must
cite and subordinate it, never fork it"). A change that would put the two in
conflict must amend one of them explicitly, never fork a third document
(drafting addition — see §12.1).
### 11. Explicit non-goals
- Hosted/SaaS operation for external customers (D9).
- A webUI that writes to the database or filesystem around the tooling (D12).
- A second writable task store beside PostgreSQL (native-kanban-sot invariants).
- Fully-designed federation in v1 (D3 — roadmap placeholder only).
### D15 — Tiered containerized deployment (2026-08-30, containerization lane)
The stack ships a tiered deployment target, additive to the architecture
gate (D8): (1) Standalone tier — docker compose is the canonical
single-host deployment: postgres, valkey, openbao, gateway, appservice
and the served webUI in one composition, with migrations, health checks,
and a documented install/upgrade path; the registry (CI-published
images) is the only deployment source. (2) Enterprise tier — Kubernetes
manifests for the same service set, phase-gated on the standalone tier
holding its acceptance bar. The v1 acceptance bar for the standalone
tier: compose-up healthy; webUI hosts agent chat; an in-stack agent can
open a PR to this repo; CI validates it; the running deployment adopts
the merged change (pull + restart). Federation (D3 clause) remains
deferred and unforeclosed. Implementation plan:
docs/plans/2026-08-30_containerization.md.
## 12. Decision registry
| ID | Decision (short form) |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred |
| D4 | Re-runnable, extensible, per-mode onboarding wizards |
| D5 | North star = this rewrite of docs/PRD.md; stack docs/ = product SSOT |
| D6 | Only product-relevant material migrates from brains; operational records stay and link |
| D7 | Spec-inventory sweep launched immediately (executed; INPUTS baseline frozen by operator ruling T2, 2026-08-25) |
| D8 | webUI sits over official framework tooling; CLI primary |
| D9 | Not a hosted business; company = organizational separation for one operator |
| D10 | better-auth is the account system of record; external IdPs via OIDC |
| D11 | Small v1 slice; ALL phases on the documented roadmap from day one |
| D12 | HARD RULE: webUI never bypasses tooling; missing tool ⇒ build the tool first |
| D13 | workspace_id stays the hard isolation unit; hierarchy is parent structure above; kanban SOT amended, not rewritten |
| D14 | Sensitive profile data in the user's own brain only; postgres holds structure/consent/pointers |
| D15 | Tiered containerized deployment: compose standalone tier (five-point v1 bar) + phase-gated k8s enterprise tier; registry-only image source | 2026-08-30 containerization lane; plan docs/plans/2026-08-30_containerization.md |
The full decision texts are recorded in the operator decision log (USC estate
brain, webui-audit lane, `GRILL.md`).
### 12.1 Drafting additions beyond D1D14
Independent review of this rewrite identified rules in this document that are
not present in the D1D14 record or the frozen T2 baseline. They are listed
here so their ratification is explicit: approval of the PR that introduces
this document, by the decision owner, ratifies them. If any is rejected it is
removed, not silently kept.
1. **Federation forward-compatibility gate:** "nothing in v1 may foreclose
federation" (§3), and scoping federation later requires its own PRD plus
threat model ([ROADMAP](./ROADMAP.md) P5). D3 defers federation; these
protective gates are additions.
2. **North-star amendment rule:** a product/fleet north-star conflict must be
resolved by amending one of the two documents explicitly, never by forking
a third (§10). The subordination itself is T2-ratified; this amendment
procedure is an addition.
---
## Part II — Active workstream contracts (preserved unchanged)
The sections below are normative, in-flight workstream contracts carried over
verbatim from the previous revision of this file. Open issues bind to them.
This rewrite moved no text and changed no requirement in them; they are
governed by their own issues and review gates, and they graduate out of this
file individually when their workstreams close.
## Current addendum: #1194 — Installed framework-tool drift detection
- Compare the framework tools shipped with the executing Mosaic package against the deployed `$MOSAIC_HOME/tools` tree by content hash.
- Treat every shipped `tools/**` file as framework-owned/required according to `framework-manifest.txt`, while excluding the explicit operator-owned credential carve-out and preserving installed-only operator/unknown files.
- Distinguish and count `IN_SYNC`, `STALE`, `NOT_INSTALLED`, and installed-only classifications; fail non-zero when shipped tools are stale or absent and refuse self-comparison that would make drift unobservable.
- Surface the observational check through `mosaic doctor`; do not refresh files, restart seats, or mutate live tooling.
- Document identity/messaging/gate behavior changes in the current stale set, the reviewed quiet-window keep-mode refresh command, and post-refresh probes against the installed path.
- Prove by construction that a stale and missing deployed tool are detected; that regression must fail before this checker exists.
## Compaction Refresh Trust Lifecycle (M1, #827#830)
### Problem and objective
Context compaction, session replacement, and same-PID runtime reloads can leave a previously VERIFIED runtime lease attached to stale directives. M1 must revoke that authority mechanically for Claude (including Claudex) and Pi without trusting caller-asserted identity or forking the external broker state machine.
### Requirements
1. `CR-REQ-01`: Claude `PreCompact` and `SessionStart` with matcher `compact`, plus Pi `session_before_compact` and the first post-`session_compact` `context`, SHALL independently revoke the active broker lease.
2. `CR-REQ-02`: Runtime generation increases—including same-PID Pi reload/new/resume/fork and Claude resume/clear—SHALL monotonically replace the prior broker incarnation and inherit no VERIFIED lease.
3. `CR-REQ-03`: A fired observer that cannot confirm broker revocation SHALL fail closed through lifecycle cancellation, a private local generation fence, and/or a runtime-local tool latch. The existing all-tools broker gate remains authoritative.
4. `CR-REQ-04`: The lease TTL SHALL remain monotonic and capped at 300 seconds. If both observers are missed, within-TTL consequential actions remain allowed and after-TTL actions are denied. This named bounded residual stale window SHALL be documented without claiming a mutator-action bound inside the window.
5. `CR-REQ-05`: Hook descendants SHALL use the broker-minted session and owner-only current-generation state inherited from register-before-exec. Caller-minted sessions and parallel lease state machines remain forbidden.
### Acceptance criteria
1. `AC-CR-01`: Real-socket tests prove each Claude observer revokes, Pi lifecycle tests prove both observer paths, and Claudex isolated settings preserve and install the mandatory hooks.
2. `AC-CR-02`: A same-PID generation test proves the old generation is stale and the replacement generation is UNVERIFIED across reload/resume/fork-equivalent lifecycle events.
3. `AC-CR-03`: RED-first T12b/T30 evidence explicitly reports dual-hook miss within TTL as **ALLOWED** and after TTL as **DENIED**.
4. `AC-CR-04`: Attributable executable coverage is at least 85%, the full repository suite is green on deterministic main, and independent code/security review completes before merge.
---
## Pi Persistent Goal Loop (#1150)
### Problem and objective
A Pi agent can stop after a plausible-looking answer even when the operator's broader objective is
not complete, and ordinary compaction can weaken or omit the original objective. Mosaic needs an
optional, operator-controlled goal loop that keeps a Pi session oriented, checks progress at native
lifecycle boundaries, and resumes work until completion is verified or a bounded safety state is
reached.
The objective is a Mosaic-owned Pi extension deployed from the framework into
`~/.config/mosaic/runtime/pi/`. It must not install into or depend on `~/.pi/agent/extensions/`.
### Scope
#### In scope
1. `PGL-REQ-01`: The framework SHALL ship a dedicated Pi goal extension under
`packages/mosaic/framework/runtime/pi/`, seed it under `$MOSAIC_HOME/runtime/pi/`, and make
`mosaic pi` load it alongside the core Mosaic extension when present.
2. `PGL-REQ-02`: `/goal` SHALL support setting a goal plus status, pause, resume, cancel, and help
operations without silently replacing an active goal.
3. `PGL-REQ-03`: Active branch-specific goal state SHALL be persisted in Pi custom session entries,
restored on session start and tree navigation, and never rely on a compaction summary as its
source of truth.
4. `PGL-REQ-04`: A hidden goal contract SHALL be injected through Pi's `context` event before every
model request so it remains effective across tool turns, retries, and post-compaction requests.
5. `PGL-REQ-05`: The harness SHALL inspect every `turn_end` and successful `session_compact` event.
A structured terminating goal-report tool SHALL capture `continue`, evidence-bearing `achieved`,
or `blocked` status without requiring a redundant model turn.
6. `PGL-REQ-06`: An achievement claim SHALL remain provisional until a second consecutive
evidence-bearing verification report. Any continuation report or successful compaction during
verification SHALL reset the verification sequence.
7. `PGL-REQ-07`: Continuation SHALL be initiated at safe lifecycle boundaries, primarily
`agent_settled`; manual compaction and restored active sessions may schedule a deferred idle
continuation without re-entering compaction handlers.
8. `PGL-REQ-08`: The loop SHALL have operator cancellation plus bounded turn and repeated-no-progress
limits. Exhausted or blocked goals pause rather than continuing indefinitely.
9. `PGL-REQ-09`: Framework installation and update SHALL preserve normal manifest ownership: the
goal extension is framework-owned under `runtime/**`, while no goal extension or configuration
asset is created or modified under the operator's main Pi configuration. Pi remains the owner of
its native session files used by `appendEntry()`.
#### Out of scope
1. A mathematical guarantee that an arbitrary natural-language goal is semantically complete.
2. Automatically executing user-supplied shell predicates or accepting executable validation code in
`/goal` arguments.
3. Restarting Pi after process, host, or supervisor failure; the existing Mosaic fleet/runtime
supervisor owns process durability.
4. Gateway, database, web UI, Discord, or cross-harness goal orchestration in this slice.
### User and stakeholder requirements
- An operator can start a goal from Pi and see its current phase, evidence, limits, and latest report.
- The agent remains oriented after each turn and compaction until verified, paused, blocked,
exhausted, or cancelled.
- Local testing uses a file under `~/.config/mosaic/runtime/pi/`; the feature never writes an
extension asset to `~/.pi/agent/extensions/`.
- Framework updates deploy the same reviewed extension source through Mosaic's existing manifest
sync path.
### Non-functional requirements
1. **Safety:** bounded continuation, explicit cancellation, no arbitrary command execution, and no
completion without non-empty reported evidence.
2. **Reliability:** serialized continuation scheduling, branch-aware restoration, compaction-safe
context injection, and stale-timer cancellation on session shutdown.
3. **Performance:** no extra nested judge-model request on every turn; structured reporting uses the
active agent's final terminating tool call.
4. **Observability:** Pi status/notifications expose phase and bounded counters without recording
credentials or hidden model reasoning.
5. **Maintainability:** the state machine is deterministic and behavior-tested independently from Pi
provider/network access.
### Acceptance criteria
1. `AC-PGL-01`: A framework-sync fixture installs the extension at
`$MOSAIC_HOME/runtime/pi/goal-extension.ts`, and launcher tests prove both Mosaic Pi extensions are
emitted in deterministic order while absent optional files remain backward-compatible.
2. `AC-PGL-02`: Command tests prove set/status/pause/resume/cancel behavior, active-goal replacement
refusal, and bounded input handling.
3. `AC-PGL-03`: Lifecycle tests prove every turn is recorded, active context is injected on every
request, two evidence-bearing achievement reports are required, and `agent_settled` continues an
unmet goal without duplicate scheduling.
4. `AC-PGL-04`: Compaction and restoration tests prove goal state survives, verification is reset and
rechecked after compaction, manual compaction continuation is deferred until idle, and tree/session
branch state is reconstructed correctly.
5. `AC-PGL-05`: Limit tests prove max-turn and repeated-no-progress exhaustion stop autonomous
continuation, while pause/cancel/blocked states do not restart.
6. `AC-PGL-06`: Focused tests, package typecheck/lint/test, repository quality gates, a local Pi load
smoke test from `~/.config/mosaic/runtime/pi/`, independent review, and terminal-green CI pass before
issue #1150 closes.
### Constraints, risks, and assumptions
- Dependency: Pi's extension API must continue to provide `registerCommand`, `registerTool`,
`context`, `turn_end`, `agent_settled`, `session_compact`, session custom entries, and terminating
tool results.
- Risk: the working agent can overstate completion. Mitigation: structured evidence, a mandatory
second verification pass, explicit semantic limitations, and operator-visible reports.
- Risk: an impossible goal can consume unbounded resources. Mitigation: hard turn/no-progress bounds
and paused terminal states.
- Risk: automatic continuation can race compaction or session replacement. Mitigation: drive from
`agent_settled`, defer idle restarts, generation-check timers, and clear timers on shutdown.
- `ASSUMPTION:` Two consecutive evidence-bearing reports are the initial local verification policy;
rationale: it provides a real recheck without doubling every turn's model cost. Future policy may
add independent or deterministic validators.
- `ASSUMPTION:` Default limits are 40 turns and 6 repeated no-progress reports, configurable only by
bounded Mosaic environment settings; rationale: useful persistence with a finite autonomous budget.
- `ASSUMPTION:` Documentation remains canonical in-repo for this slice; no external docs publication
is requested.
### Testing and delivery intent
Use TDD for the deterministic controller and lifecycle invariants. Test with fake Pi lifecycle
objects first, then run a local load/smoke test from the deployed Mosaic path. Deliver source, tests,
launcher wiring, framework/runtime documentation, user/developer guides, and sitemap updates in one
reviewed squash PR to `main` with terminal-green CI.
---
## Fleet Declarative Configuration Management Workstream (FCM, #758)
### Problem and objective
The local Mosaic fleet has a roster, generated agent environment files, user-systemd units, tmux
sessions, heartbeat files, examples, profiles, and separate gateway-backed agent records. These
planes have drifted and are not one safe operator lifecycle. The objective is one **local fleet
roster** as the desired-state SSOT, with generated environment, systemd, tmux, and heartbeat
artifacts as rebuildable projections; it does not merge the local fleet control plane with the
gateway-backed agent catalog.
### Normative requirements
| ID | Requirement |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
| `FCM-REQ-09` | M1M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
### Acceptance criteria
1. `AC-FCM-01`: A valid local v2 roster can be parsed from YAML or JSON, validated structurally and semantically through the shared resolver, and rendered canonically; invalid fields, duplicate names, unresolved classes, unsupported runtime/model combinations, socket ambiguity, and incompatible options fail closed.
2. `AC-FCM-02`: `plan` reports deterministic desired-versus-observed differences for roster, generated environment, systemd enablement, tmux/session, heartbeat, installed-asset revision, and provable orphans without mutation; `apply --check` reports drift without mutation.
3. `AC-FCM-03`: Local create/update/delete is generation-guarded, atomic, idempotent, and safe by default; it permits supported runtime/model/harness/effort/workdir/role changes without direct editing of generated environment files and does not start a newly created agent unless explicitly persisted.
4. `AC-FCM-04`: The generated-env/local-override launch chain rejects generated-key shadowing, arbitrary command override, unknown keys, shell evaluation, and sensitive-value diagnostics before any agent starts; known-safe legacy input is regenerated or strictly relocated, and forbidden input is quarantined.
5. `AC-FCM-05`: Local lifecycle reconciliation implements the persisted/transient start-stop rules, exact default/named tmux socket targeting, systemd/tmux status, stale generated state, unmanaged-session reporting, and rollback without surprise restarts or fuzzy destructive targeting.
6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory.
7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers.
8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes.
### M0 implementation gate
No source, schema, role, example, profile, systemd, or live-fleet change is authorized before M0
lands. M0 consists only of these normative requirements, the complete task DAG, the scoped
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
### Fleet git identity launch propagation (#1043)
#### Problem and objective
A fleet seat can have a registered per-agent Git credential while its launched runtime process lacks
`MOSAIC_GIT_IDENTITY`. The credential resolver then cannot select the seat identity reliably, which
blocks repository operations on fail-closed estates and can fall through to an unrelated identity on
estates where that refusal is not active. The objective is to make Git identity a deterministic,
roster-derived part of the generated launch projection and prove it reaches the launched process.
#### Normative requirements
1. `FGI-REQ-01`: Every generated fleet agent projection SHALL declare
`MOSAIC_GIT_IDENTITY=<MOSAIC_AGENT_NAME>`; a differing or unsafe identity SHALL fail closed before
tmux launch.
2. `FGI-REQ-02`: The clean `/usr/bin/env -i` pane boundary SHALL pass every variable declared by the
generated projection, including `MOSAIC_GIT_IDENTITY`, to the launched runtime process.
3. `FGI-REQ-03`: A behavioral integration test SHALL set-compare the complete generated projection
against the launched process environment. Source-text/string-presence assertions are insufficient.
4. `FGI-REQ-04`: Verification SHALL include RED-first evidence and a delete-the-subject mutation that
removes Git-identity pane propagation and makes the behavioral test fail.
#### Acceptance criteria
1. `AC-FGI-01`: A launched seat process contains every key/value pair declared by its generated
environment projection, including the roster-derived Git identity.
2. `AC-FGI-02`: Missing, unsafe, or split Git identity is rejected before a tmux session is created.
3. `AC-FGI-03`: Focused launcher and generated-environment tests, repository quality gates,
independent review, and the required RED/green/R7 evidence are recorded before push.
### Framework shell assertion portability (#1098)
#### Problem and objective
The blocking framework-shell chain can report that a pane command omitted `/usr/bin/env -i` even when
`-i` matched successfully. A short-circuiting `grep -q` under `set -o pipefail` may close its pipe after
the match and cause an upstream producer to exit with SIGPIPE, turning a valid semantic result into a
nonzero aggregate pipeline. The objective is to inspect the captured NUL-delimited argv directly and
make failures carry the observed records needed for diagnosis.
#### Normative requirements
1. `FSP-REQ-01`: The pane-boundary test SHALL validate an adjacent `/usr/bin/env`, `-i` argv pair from
the authoritative NUL-delimited tmux capture without a short-circuit pipeline whose upstream status
can override a successful match.
2. `FSP-REQ-02`: Missing, reversed, or non-adjacent boundary tokens SHALL fail, while valid boundaries
SHALL remain valid regardless of trailing argv size, pipe capacity, process scheduling, or host/CI
utility implementation.
3. `FSP-REQ-03`: A failed boundary check SHALL print stable indexed, shell-escaped observed argv records
before exiting nonzero; the fixture SHALL continue to contain generated non-secret launch data only.
4. `FSP-REQ-04`: Verification SHALL include RED-first large-payload evidence, negative token-order
controls, the complete focused launcher suite, canonical Woodpecker CI, and independent review.
#### Acceptance criteria
1. `AC-FSP-01`: A large captured argv with adjacent `/usr/bin/env`, `-i` passes even when the former
`grep -q` pipeline returns nonzero from an upstream SIGPIPE.
2. `AC-FSP-02`: Missing executable, missing flag, and detached/reversed flag fixtures return nonzero and
emit the indexed observed argv.
3. `AC-FSP-03`: The focused suite passes on the development host and CI image, and the merged-main
Woodpecker pipeline is terminal green before #1098 closes.
---
## Exact Cross-Harness Fleet Communications Contract (#766)
### Problem and objective
Fleet runtime contracts currently combine exact peer rows with generic operational metavariables and
independently parsed roster data. Non-Claude harnesses can mistake those metavariables for values to
infer, producing incorrect host, session, socket, or helper targets. The objective is one
roster-resolved communications contract that every supported harness receives unchanged.
### Normative requirements
1. `FCOM-REQ-01`: Fleet commands and runtime composition SHALL use one shared v1 roster structural
resolver. A second lenient communications parser is forbidden.
2. `FCOM-REQ-02`: The composed contract SHALL render the local roster member's authoritative host,
exact agent/session name, resolved tmux socket, exact helper path, and deterministic communications
generation.
3. `FCOM-REQ-03`: Every known peer SHALL have one exact executable command. Same-host commands SHALL
omit `-H`; cross-host commands SHALL use only that peer's explicit roster `ssh` target; the one
supported fleet-wide named socket SHALL use `-L` with its exact value. A per-agent socket declaration
must equal that fleet-wide value; unsupported independent sockets and missing cross-host SSH data SHALL
fail closed.
4. `FCOM-REQ-04`: Operational fleet examples SHALL not contain unresolved host, session, socket, or
helper-path metavariables. Agents SHALL select an exact rendered peer row and SHALL NOT infer,
substitute, or fuzzy-match targeting values.
5. `FCOM-REQ-05`: An unknown local member or requested peer SHALL fail closed with exact-name discovery
guidance. Runtime composition SHALL not silently omit a requested fleet member's communications
contract.
6. `FCOM-REQ-06`: Claude Code, Codex, OpenCode, and Pi SHALL receive equivalent authoritative
communications data through the common runtime composer.
7. `FCOM-REQ-07`: Tests SHALL prove the contract from framework-source `TOOLS.md`, through a fresh
installed `TOOLS.md`, to final runtime composition and helper executability. User-owned installed
`TOOLS.md` content SHALL remain preserved.
8. `FCOM-REQ-08`: Stale installed or active composed context SHALL be reported with deterministic
generation/repair/relaunch guidance. Currency requires the expected source and installed contract
marker/version plus bounded byte equality. The supported current-version repair SHALL run independently
of package updates, preserve divergent `TOOLS.md` bytes in a digest-qualified no-clobber backup, restore
a regular executable helper without following symlinks, and be idempotent. Detection and reporting SHALL
NOT rewrite active context, restart a session, or mutate a live fleet.
9. `FCOM-REQ-09`: The shared resolver SHALL preserve and strictly validate every schema-supported v1
connector kind (`tmux`, `discord`, and `matrix`) from YAML and JSON. Every accepted snake/camel alias
pair SHALL reject differing dual declarations and accept identical declarations. JSON roster fallback
SHALL occur only when `roster.yaml` is absent; all other YAML access failures SHALL fail closed.
10. `FCOM-REQ-10`: The communications generation SHALL cover the complete canonical rendered semantic
contract, including identity, role/class, resolved host/socket/helper, peer metadata, and exact commands.
Installed helpers SHALL be validated with no-follow filesystem inspection as regular executable files.
Keep-mode reseed and relaunch discovery SHALL preserve and support both YAML and JSON rosters.
### Acceptance criteria
1. `AC-FCOM-01`: Contract fixtures contain no unresolved operational targeting metavariables; local
identity contains exact host/session/socket/helper values.
2. `AC-FCOM-02`: Same-host, cross-host, named-socket, literal-default-socket, and missing-SSH tests prove
exact targeting and fail-closed behavior.
3. `AC-FCOM-03`: Unknown identities and peers report known exact names plus an exact self-scoped
discovery command; no fuzzy session selection is emitted.
4. `AC-FCOM-04`: Four-harness tests prove byte-equal authoritative communications sections.
5. `AC-FCOM-05`: Source, fresh-install, preserved-custom-install, stale-installed, composed-generation,
helper executable, agent-send socket isolation, and exact-target tests pass.
6. `AC-FCOM-06`: Documentation defines non-mutating stale-context detection and operator-authorized,
exact-agent relaunch; no implementation path performs automatic session mutation.
7. `AC-FCOM-07`: YAML and JSON fixtures cover every connector kind; all snake/camel aliases cover
identical acceptance and conflicting rejection; non-`ENOENT` YAML failures do not fall back.
8. `AC-FCOM-08`: Missing, directory, symlink, and non-executable installed helpers fail closed. Explicit
current-version repair proves partial-deletion recovery, digest-qualified backup collision safety,
symlink-target safety, and repeated-run idempotence.
9. `AC-FCOM-09`: Markerless-equal and wrong-version source/installed contracts are stale, and a rendered
role/class change produces a different communications generation.
---
## KBN-101 Database Runtime/Migration Role Split (#771)
### Problem and objective
PostgreSQL Gateway/storage currently uses one `DATABASE_URL` for runtime queries and migrations. That makes the deployed application identity an owner and prevents certification that KBN immutable event, artifact, checkpoint, and evidence relations reject runtime `UPDATE`/`DELETE`. KBN-101 freezes a least-privilege runtime/migration split before KBN-100 schema work.
### Normative requirements
1. `K101-REQ-01`: `DATABASE_URL` SHALL be the non-owner PostgreSQL runtime connection and `DATABASE_MIGRATION_URL` SHALL be the migration-only owner/migrator connection. They are required respectively for runtime and the dedicated `mosaic-db-migrator --run|--verify` phase in `standalone`/`federated`; local PGlite is the explicit exception. The published `@mosaicstack/db` bin maps exactly `mosaic-db-migrator` to `./dist/cli.js`, its image entrypoint is exactly `mosaic-db-migrator`, accepts no URL/SQL/schema/role argv, and returns stable sanitized exits. Every current/future PostgreSQL DDL entrypoint SHALL route to that runner or be denied, and SHALL reject `DATABASE_URL`-only execution before connection/DDL. Data migration may connect only after the runner prepares and verifies the PostgreSQL target, through dedicated non-DDL `mosaic_data_importer` and exactly `--target-url-file /run/secrets/mosaic-migrate-target-url`, its fixed paired authenticated provider-version file `/run/secrets/mosaic-migrate-target-version`, plus `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`. KBN-101-05 obtains URL key `url` and version only from the same successful Vault KV-v2 response at `secret-{env}/mosaic-stack/database/importer` (`data.metadata.version`), renders them as one immutable generation into separate consumer copies, and never infers a provider version from DSN bytes. The trusted runner verifies TLS/identity/manifest, reads its fixed importer URL/version copies only for binding through safe no-follow fd checks, and signs a credential-free JCS/Ed25519 attestation using its runner-only fixed root-owned private-key file; no signing key reaches importer/runtime. The artifact binds secret version and SHA-256 of exact high-entropy credential-file bytes, canonical TLS host/port/database, CA/SPKI, PostgreSQL system identifier/database OID, importer role, manifest/schema fingerprints, producer invocation/build/image digest, issued/expires/nonce, and correlation. Before target connection the importer validates URL/version/attestation/public-key files, signature/key/expiry/replay/authenticated provider version/digest/generation/bindings and the importer-only CA at exact `DATABASE_TLS_CA_CERT_PATH`; after verified TLS and before DML it validates server/database/role/CA/schema identity, with same-fd/in-memory-byte TOCTOU protection, rotation/revocation, a privileged producer-only-to-importer-only artifact handoff controller that verifies/copies/fsyncs/atomically renames/seals before importer start, consumer isolation/no logging-oracle, and sanitized errors. Raw `--target-url`, `DATABASE_URL` fallback, runtime-owner use, missing/unsafe/substituted files, stale/replayed/tampered/wrong-key attestation, wrong binding, and DDL attempt fail before target connection/DDL; post-connect mismatch closes with zero DML/DDL. A reviewed finite classifier inventories executable current source/scripts/package bins, operator docs, deploy manifests, and exact normative contracts by path; active secure records pin both options/files, producer/key/bindings/tests, while normative contracts cannot mask instructions. Unknown active commands, duplicate-owner, ownerless, missing-path, and historical/status-only masking hits fail. `db:push` is forbidden outside an explicitly disposable local developer database and cannot accept a production-like URL.
2. `K101-REQ-02`: Gateway runtime/replicas SHALL not execute migrations or DDL. The runner SHALL hold one `max:1` session and fixed two-int advisory namespace `1297044289` (`MOSA`), `1262636593` (`KBN1`) across preflight, reconciliation, migration, verification, and release. It SHALL compare the versioned canonical manifest v1 tuple (journal logical index/tag plus exact SQL-byte SHA-256) to the complete observed ledger mapping; count/set-only, timestamps, and physical insertion order are non-normative and insufficient.
3. `K101-REQ-03`: PostgreSQL SHALL separate non-login platform database owner, non-login schema owner, dedicated `NOLOGIN SUPERUSER` `mosaic_extension_owner`, login migrator, dedicated login non-DDL data importer, non-login runtime capability, and login runtime roles. For PostgreSQL 17 + pgvector 0.8.2, `vector` is untrusted (`trusted` is absent and `relocatable=true`): only an externally controlled audited platform-bootstrap superuser session may `SET ROLE mosaic_extension_owner` for CREATE/UPDATE/SET SCHEMA, then `RESET ROLE`; the role has `rolcanlogin=false`, `rolsuper=true`, zero members, no runtime credential/Vault secret, and is never provided to app containers. It owns `mosaic_extensions`, fresh `vector`, and owner-bearing extension members, while `mosaic_schema_owner` receives only `USAGE` for type resolution and never ownership/`CREATE`/`ALTER`/`DROP`/member-change/default-privilege authority there. Superuser cannot be constrained by `GRANT`/`REVOKE`; this is identity/non-login/no-membership/external-control/audit isolation, not a false least-privilege claim. Extension operations require control-plane change, independent review, backup/rollback, maintenance window, and audit evidence. Managed targets that cannot establish this exact role are ineligible until an independently approved versioned provider-owned extension-owner profile exists; app/migrator ownership is never silently retained. Existing approved-owner extension relocation validates exact `pg_namespace.nspowner`, `pg_extension.extowner`, member ownership/schema/version, while legacy runtime-owned extension fails closed to a controlled shadow-database migration—never unsupported ownership alteration, catalog mutation, ownership adoption, or `DROP CASCADE`. Runtime, migrator, schema owner, importer, and all service roles must fail `SET ROLE`, catalog/direct `ALTER`/`UPDATE`/`DROP`/membership-change denial, role ownership, superuser/role-creation/schema-creation/TEMPORARY, unsafe membership, untrusted search path, missing grants, unauthenticated TLS, and immutable privilege drift checks. Application schema is fixed `mosaic` with exact `pg_catalog,mosaic` session path; historical public migrations remain byte-immutable legacy bootstrap only, every future Drizzle application declaration targets `mosaic`, and `vector` is explicitly qualified from non-writable `mosaic_extensions`. No config-derived SQL identifier is permitted.
4. `K101-REQ-04`: `mosaicstack/stack` KBN-101-00 SHALL exclusively own `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, and bootstrap tests; KBN-101-05 SHALL exclusively own `tools/db/render-postgres-secrets.ts`, its tests, and current Compose/Portainer/two-gateway deployment declarations, consuming the versioned bootstrap interface without overlap. Environment IaC/Vault is named input and Mosaic deployment control plane/Jason is activation authority. Distinct runtime/migrator/importer URL, importer authenticated provider-version, DB-client CA, Gateway leaf, and PostgreSQL server key/certificate materials are provisioned before a production-like database starts. Importer and migrator have separate immutable URL/version copies at fixed `10002:10002`/`10003:10003` identities; runtime/unrelated containers receive neither importer material, attestation private key, or importer artifact. Runtime, migrator, and importer require their mounted CA plus `sslmode=verify-full`. Exact UID/GID/mode/rendering, service-DNS SANs, Vault/compose/Swarm consumer isolation, two-gateway pair ordering, server activation, pre-enforcement legacy-client drain and `hostssl` zero-plaintext-session proof, fresh/existing transition, CA-overlap rotation, TLS-only rollback, and standalone/federated/Swarm/two-gateway positive/negative TLS evidence are required. No application-generated production certificate or plaintext bootstrap exception is permitted.
5. `K101-REQ-05`: KBN immutable relations SHALL permit the real runtime role INSERT/SELECT only and deny UPDATE/DELETE; parent retention remains RESTRICT/no-cascade. Role/password/Vault creation is external platform control, never application migration/source.
6. `K101-REQ-06`: N-1 single-URL compatibility, rollout/rollback, Vault ownership/rotation/redaction, CI, installer, compose/Portainer, observability, and deployment handoffs SHALL be separately bounded one-card/one-PR work. Prepared slices remain inactive while current owner-runtime deployments stay N-1; Mosaic control plane/Jason alone authorizes one final atomic activation or rollback, with no force-on-red/bypass. KBN-101 planning itself SHALL not mutate production.
7. `K101-REQ-07`: KBN-100 SHALL begin only after the KBN-101 foundation role/schema-boundary certificate; it SHALL rebase on that main head, restore generated Drizzle declaration/snapshot/journal consistency, and bound procedural immutable-table grant/trigger/backfill additions to its schema slice. KBN-101 real deployed-role immutable-operation certification SHALL complete after KBN-100 creates those relations and before KBN-105.
### Acceptance criteria
1. `AC-K101-01`: DTO/command-matrix tests prove required modes, PGlite exception, `mosaic-db-migrator --help|--run|--verify`/stable exits/argv refusal, public-import negative, every finite classified DDL/static-bypass inventory path and both harness pairs reject `DATABASE_URL`-only before connection/DDL, no migration-to-runtime fallback, and `db:push` refusal outside an allowlisted disposable DB. Before inventory, ownership, or status masking, the semantic fixture fails README's exact former commented code-fence generic-wrapper form and the user guide's exact former executable generic-wrapper form; source-consistency proves current `packages/storage/src/cli.ts` directly `execSync`s `pnpm --filter @mosaicstack/db db:migrate` and no `mosaic-db-migrator` bin exists, so runner-delegation documentation fails. The active `docs/guides/migrate-tier.md` route is inventoried to KBN-101-07 and proves runner-produced `--target-url-file /run/secrets/mosaic-migrate-target-url`, fixed paired provider-version file, and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`; runner-only signing/private-key isolation; Vault KV-v2 same-response version provenance, separate immutable generation mounts, importer CA, JCS/Ed25519 signature/key rotation/revocation, atomic artifact, expiry/replay, safe-fd secret-version/digest, canonical TLS/CA/server/database/role/manifest/schema bindings, dedicated non-DDL importer, consumer isolation/no log-oracle, and exact no-connection versus zero-DML rejection for missing/wrong/stale/replayed/tampered/wrong-key/substituted/generation-mismatched inputs. The full current non-normative docs inventory—including user guide, federation historical task/MILESTONES status, and non-operative SETUP—has an exact safe disposition. Scanner semantic checks reject automatic first-boot/startup extension/schema/migration wording, Compose-up-before-runner, init-script authority, production `.env`/monorepo auto-load/`EnvironmentFile=`/credential-export-or-argv/restart-as-secret-activation routes, and every unqualified operator-document `mosaic-db-migrator --run|--verify` hit regardless of named/normative/status classification. The exact former README/dev/deployment Compose-first sequences, former SETUP wording, exact former MILESTONES wording `pgvector extension installed + verified on startup`, former architecture-plan/PERFORMANCE/backlog runner routes, and any unqualified runner fixture fail before inventory masking. Only one `Held future procedure` Markdown section—bounded through the next equal-or-higher heading—may contain the explicit non-operative/no-current-command-authority form that names KBN-101-00/-03/-05 and preserves external bootstrap → TLS/roles → `mosaic-db-migrator --run``mosaic-db-migrator --verify` → Gateway/Compose readiness; every runner hit outside that section fails. The README assertion for the checked-in direct CI `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` passes only as active legacy N-1, uncertified, non-authorizing-as-an-operator-route status against an isolated disposable CI database pending KBN-101-06 removal—not as an ordinary operator or approved DDL-authority route. Only local PGlite data-layer work or non-PostgreSQL Compose is current (Gateway/Web local startup is held pending daemon/inherited/project-DSN rejection).
2. `AC-K101-02`: Fixed namespace lock contention/crash/readiness/non-interference and exact manifest-v1 reconciliation tests prove no replica race/runtime auto-migration and fail closed on every missing/unknown/duplicate/ambiguous/corrupt/stale ledger state.
3. `AC-K101-03`: Actual PostgreSQL 17 + pgvector 0.8.2 control-file, catalog, Drizzle-generation, vector-query/operator, fresh/approved-owner/legacy-shadow/partial/resume/rollback/N-1, and real deployed-role tests prove `trusted` absent/untrusted plus relocatability, external-superuser `SET ROLE` create/update/`RESET ROLE` audit, exact `rolcanlogin=false`/`rolsuper=true`/zero-membership/no-runtime-secret state, platform/schema/extension-owner/migrator/importer/runtime separation, `pg_extension.extowner` plus owner-bearing extension-member/schema/version assertions, and runtime/migrator/schema-owner/importer/all-service-role `SET ROLE`/ALTER/DROP/member-update denial. They also prove `pg_catalog,mosaic` per-session pool safety, `mosaic_extensions` qualification, identifier injection denial, ownership/membership/ledger-read/TEMP/default grants, and unsafe privilege denial.
4. `AC-K101-04`: Disposable standalone, federated/Swarm, and two-gateway verified-TLS positives plus for both pairs missing CA/wrong CA/wrong SAN/sslmode downgrade, server/Gateway key mode, UID/GID, secret-consumer isolation, and legacy-drain/`hostssl` negatives prove server bootstrap, ordering, and readiness; PGlite is expressly excluded from this PostgreSQL evidence.
5. `AC-K101-05`: Real runtime-role evidence proves INSERT/SELECT succeeds and UPDATE/DELETE fails for every frozen immutable KBN relation.
6. `AC-K101-06`: N-1/atomic activation/rollback, Vault/CA-overlap rotation/redaction, health/operator behavior, CI/deployment handoff, independent exact-head security review, and terminal-green CI evidence the foundation before KBN-100; after KBN-100, the real deployed-role immutable-operation certificate and Ultron approval release KBN-105.
**Normative implementation contract:** [`docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md`](./native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md). `ASSUMPTION:` existing `standalone` and `federated` are all PostgreSQL production-like modes; any new PostgreSQL tier inherits these requirements until an explicit versioned amendment.
---
## Tess Interaction Agent Workstream (TESS)
### Problem and Objective
Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent.
The objective is to ship **Tess** (from _tessera_, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency.
### Scope
#### In Scope
1. `TESS-ARP-001`: A runtime-neutral `AgentRuntimeProvider` contract supporting `listSessions`, `streamSession`, `sendMessage`, `terminate`, `getSessionTree`, `attach`, health, capability discovery, and normalized events/errors.
2. `TESS-PI-001`: A long-running Pi-native Tess agent profile/service pinned to GPT-5.6 Sol with high reasoning, explicit tool policy, lifecycle hooks, durable checkpoints, and restart recovery.
3. `TESS-DSC-001`: Dedicated Discord channel binding to Tess through the Mosaic gateway, with allowlists/RBAC, thread/reply policy, streaming, attachments, approvals, and correlation IDs.
4. `TESS-CLI-001`: `mosaic tess` CLI commands for chat, status, session listing, attach/detach, send/steer/stop, provider health, and recovery.
5. `TESS-FLT-001`: Fleet plugin capabilities for roster/status/heartbeat inspection, message delivery, session hierarchy, safe attach, and controlled restart/recovery.
6. `TESS-MOS-001`: Explicit Mos coordination boundary and tools: hand off orchestration requests, observe mission/task state, receive results, and never silently compete for orchestration authority.
7. `TESS-HRM-001`: Transitional Hermes adapter for profiles/agents, sessions, streaming/messages, Kanban, skills, memory, tools, cron, and health, using capability negotiation and fail-closed unsupported operations.
8. `TESS-MEM-001`: Unified memory/retrieval plugin with scoped search/recent/capture/stats, startup context injection, provenance, redaction, namespace isolation, and flat-file/project truth precedence.
9. `TESS-STA-001`: Durable agent state, inbox, handoff, compaction-recovery, and resume reconstruction.
10. `TESS-PLG-001`: Plugin/tool catalog covering runtime bootstrap, repository/PR workflow, fleet diagnostics, incident-safe read operations, Discord interaction, and extensible MCP/skill discovery.
11. `TESS-TRN-001`: Replaceable transport providers: tmux/fleet now, Matrix/native Mosaic transport later, with no Discord/CLI business logic coupled to transport details.
12. `TESS-SEC-001`: RBAC, per-operation authorization, explicit approval for destructive/privileged/customer-visible actions, audit events, secret/PII redaction, tenant isolation, and bounded command execution.
13. `TESS-SEC-002`: Command execution SHALL enforce declared scope/role server-side; admin/system and destructive operations SHALL require policy-bound durable approval.
14. `TESS-SEC-003`: Every session list/read/attach/send/terminate operation SHALL enforce server-derived owner and tenant scope; guessed or client-supplied IDs SHALL grant no authority.
15. `TESS-SEC-004`: MCP tools SHALL derive actor/tenant from authenticated context and SHALL NOT accept caller-controlled identity fields.
16. `TESS-SEC-005`: Discord plugin ingress SHALL authenticate service identity, enforce guild/channel/user allowlists, propagate correlation/message IDs, and reject replay.
17. `TESS-SEC-006`: Secret/PII classification and redaction SHALL occur before persistence and before channel egress, including tool metadata and authentication flows.
18. `TESS-SEC-007`: Approvals SHALL be one-time, expiring, actor/tenant-bound, and cryptographically bound to the exact structured action digest.
19. `TESS-SEC-008`: Ingress, provider sends, tool side effects, and responses SHALL use durable inbox/outbox/checkpoints and idempotency records for restart-safe replay.
20. `TESS-SEC-009`: Garbage collection and retention SHALL be session/tenant scoped unless executed as a separately authorized and audited system-wide job.
21. `TESS-OBS-001`: Structured logs, traces, health/readiness, provider latency/errors, session lifecycle, tool audit, and actionable recovery diagnostics.
22. `TESS-MIG-001`: Capability inventory and staged Hermes-to-Mosaic migration matrix with coexistence, cutover, rollback, and deprecation gates.
#### Out of Scope
1. Replacing Mos as coding/general fleet orchestrator.
2. Making Hermes the Mosaic core or coupling Mosaic domain logic to Hermes schemas.
3. Migrating every historical chat verbatim; only policy-compliant indexed summaries and user-selected sessions are migrated.
4. Unrestricted shell execution from Discord.
5. Full web UI parity in the first Tess operational milestone; gateway contracts must remain web-consumable.
6. Replacing tmux before Matrix/native transport reaches operational parity.
### Stakeholder and User Requirements
- Jason must be able to converse with the same Tess session from Discord and CLI.
- Jason must be able to see what is running, stale, blocked, or unhealthy without attaching manually to every session.
- Jason must be able to attach to Tess and authorized fleet sessions through supported CLI controls.
- Tess must collaborate with Mos and the fleet while preserving a single clear orchestration authority.
- The system must migrate useful Hermes/OpenClaw capabilities intentionally, with evidence, instead of copying implementations wholesale.
### Non-Functional Requirements
1. **Security:** default-deny provider/tool capabilities, least privilege, no secrets in logs/prompts/commits, Discord user/channel authorization, and auditable approvals.
2. **Reliability:** durable inbox/checkpoints; idempotent message handling; reconnect with bounded backoff; no message loss or duplicate execution across gateway restart.
3. **Performance:** first acknowledgement within 2 seconds when connected; streamed agent output begins within 5 seconds excluding model/provider delay; status reads return within 2 seconds under nominal local conditions.
4. **Observability:** every ingress message and resulting provider/tool operation carries a correlation ID across Discord, gateway, Tess, provider, and audit events.
5. **Maintainability:** channel, runtime, transport, memory, and external-agent integrations remain adapter-based with contract tests.
6. **Privacy:** only scoped context enters external runtimes; persisted messages/memories follow retention and redaction policy.
7. **Portability:** Tess runs through Pi/Mosaic contracts and does not require Hermes to start or serve native Mosaic operations.
### Acceptance Criteria
1. `AC-TESS-01`: A dedicated Discord channel and `mosaic tess chat` connect to one durable Tess session and stream responses bidirectionally.
2. `AC-TESS-02`: `mosaic tess status|sessions|tree|attach|send|stop` operate against authorized provider capabilities with stable typed outputs and actionable errors.
3. `AC-TESS-03`: Tess runs GPT-5.6 Sol at high reasoning and its effective runtime/model/tool policy is visible through status without exposing credentials.
4. `AC-TESS-04`: Tess can inspect and message the Mosaic fleet, hand orchestration work to Mos, and demonstrate that Tess does not independently claim Mos-owned orchestration work.
5. `AC-TESS-05`: Hermes adapter demonstrates session listing, streaming/message delivery, hierarchy mapping, and at least one approved capability in each of Kanban, skills, memory, tools, and cron—or reports unsupported capabilities fail-closed.
6. `AC-TESS-06`: Restart/compaction test preserves session identity, pending inbox, last durable checkpoint, and a resumable handoff without duplicate side effects.
7. `AC-TESS-07`: Unauthorized Discord users/channels, cross-tenant access, unsafe tool calls, forged approvals, and sensitive-output cases are denied and audited.
8. `AC-TESS-08`: tmux/fleet and Matrix/native transport implementations pass the same provider contract suite; Matrix may remain non-default until readiness gates pass.
9. `AC-TESS-09`: Baseline quality gates, unit/integration/contract tests, Discord+CLI E2E, restart/recovery tests, independent code review, and security review are green.
10. `AC-TESS-10`: Migration matrix documents every audited Hermes/OpenClaw capability as native, adapted, deferred, or rejected, with cutover and rollback evidence.
11. `AC-TESS-11`: User, admin, developer, API/OpenAPI, operations/recovery, and plugin-authoring documentation is current and linked from the sitemap.
### Constraints, Dependencies, Risks, and Assumptions
- Dependency: Mosaic gateway remains the single API surface; Pi is the native runtime; Valkey/PostgreSQL provide canonical durable state where required.
- Dependency: Discord bot credentials and dedicated channel ID are deployment secrets provisioned outside source control.
- Risk: Tess could drift into a second orchestrator. Mitigation: explicit role policy, Mos handoff contract, authority checks, and E2E boundary tests.
- Risk: broad Hermes compatibility can freeze legacy semantics into Mosaic. Mitigation: Mosaic-owned normalized contracts and capability negotiation.
- Risk: Discord creates a privileged remote-control surface. Mitigation: pairing/allowlists, RBAC, approvals, rate limits, audit, and safe tool classes.
- Risk: transcript ingestion can violate privacy or overload memory. Mitigation: scoped opt-in import, redacted summaries, provenance, retention, and deduplication.
- Risk: current root filesystem has limited headroom. Mitigation: isolated worktrees, no duplicated dependency installation unless required, and cleanup only after active-lane verification.
- `ASSUMPTION:` The public name is **Tess**, because the user requested a name and the tessera/Mosaic relationship is distinctive; config must permit later display-name changes without renaming APIs or storage keys.
- `ASSUMPTION:` The dedicated Discord channel ID and final guild policy will be supplied/provisioned during deployment, so implementation uses explicit configuration and fail-fast startup validation.
- `ASSUMPTION:` tmux/fleet is the production transport for the first operational milestone; Matrix/native transport is implemented behind the same contract and promoted only after parity/reliability verification.
- `ASSUMPTION:` Project/task truth remains in canonical Mosaic/project stores; semantic memory systems are retrieval/mirror layers, not hidden authorities.
### Testing and Delivery Intent
Delivery uses five gated milestones: runtime contracts/security; Pi service/state; Discord/CLI; fleet/Hermes/plugin suite; migration/Matrix/recovery/qualification. Every source-code task requires tests, independent review, a PR to `main`, terminal-green CI, and issue/task closure. Production activation additionally requires a clean-host Pi launch, dedicated Discord channel smoke test, CLI attach test, restart/recovery drill, and rollback procedure.
---
## Official Channel Plugin Workstream (#756)
### Problem and Objective
The Discord plugin currently couples Discord event handling, gateway bridging, and reply routing in one implementation and activates only on mentions. Mosaic needs an official channel adapter that behaves the same no matter whether the bound logical agent currently runs through Claude, Codex, Pi, OpenCode, or a future harness. The Discord connection and conversation address must remain stable while the gateway changes the runtime provider behind that logical session.
The objective is to make Discord the first implementation of a transport-neutral official channel contract, with explicit authorization and deterministic channel/thread routing that future Matrix, Slack, and other adapters can share.
### Scope
#### In Scope
1. `CHN-001`: Transport-neutral channel adapter, route, message, attachment, authorization-principal, response-target, and health contracts in `@mosaicstack/types`, including trusted per-binding logical-agent configuration selection.
2. `CHN-002`: Stable channel conversation addresses based on logical agent plus channel/thread identity; harness, model, and runtime-provider IDs are forbidden from channel session keys.
3. `DSC-001`: An authorized untagged message in a configured agent-bound channel routes to the agent and receives its response in that channel.
4. `DSC-002`: A bot mention in a configured parent channel creates a Discord thread, or reuses the thread already attached to that same native message; the mentioned turn and subsequent thread turns route and respond in that thread.
5. `DSC-003`: A message already inside an authorized thread inherits authorization from its configured parent and never attempts a nested thread.
6. `DSC-004`: Guild, parent channel, user, pairing, and role authorization remains default-deny before thread creation or gateway dispatch.
7. `DSC-005`: Discord service authentication, HMAC envelope integrity, replay protection, attachments, approvals, response chunking, and correlation behavior remain intact.
8. `DSC-006`: The Discord adapter exposes lifecycle and health behavior through the shared channel contract without importing a harness SDK.
#### Out of Scope
1. The logical-agent lease, fencing epoch, execution grant, checkpoint, or cross-harness takeover implementation tracked by #754/#755.
2. Dynamic Discord authorization administration in the web UI.
3. Multi-guild tenant isolation, DMs, slash commands, voice, reactions, or production bot deployment.
4. Implementing Matrix or Slack adapters in this slice.
### Non-Functional Requirements
1. **Security:** no thread or dispatch side effect occurs until guild, parent channel, user, pairing, role, and bounded per-user/channel rate checks pass; attachment metadata is shape- and size-bounded; credentials never enter source, messages, session keys, or logs.
2. **Portability:** channel contracts and stable conversation IDs contain no Claude, Codex, Pi, OpenCode, model, process, or provider-specific field; each configuration-owned binding selects its trusted logical agent without changing the channel identity.
3. **Reliability:** repeated messages for one channel/thread resolve the same conversation handle; reconnecting the adapter does not require a harness-specific rebinding.
4. **Maintainability:** Discord-specific API translation stays in the Discord package; gateway and future adapters depend on transport-neutral contracts.
5. **Observability:** thread creation or routing failure is reported without message content or credential material.
### Acceptance Criteria
1. `AC-CHN-01`: Contract and behavior tests prove the plugin route contains only logical agent plus channel/thread identity and produces the same stable conversation handle regardless of underlying harness selection.
2. `AC-CHN-02`: A mentioned authorized parent-channel message creates a thread (or reuses its already-attached thread), dispatches to the thread conversation, and targets the response to that thread.
3. `AC-CHN-03`: An untagged authorized parent-channel message dispatches to the parent conversation and targets the response to the parent channel.
4. `AC-CHN-04`: Untagged follow-ups inside an authorized thread dispatch and respond in that same thread without creating a nested thread.
5. `AC-CHN-05`: Unauthorized guilds, channels, users, unpaired users, insufficient roles, and rate-limited senders produce no thread and no gateway dispatch.
6. `AC-CHN-06`: Shared channel contracts are exported from `@mosaicstack/types`, Discord implements the lifecycle/health seam, and no harness SDK is imported by the plugin.
7. `AC-CHN-07`: Focused routing/auth tests, package tests, typecheck, lint, formatting, coverage, independent code/security review, and terminal-green CI pass.
### Constraints, Risks, and Assumptions
- Dependency: Mosaic gateway remains the policy, durable-session, audit, and runtime-provider boundary.
- Constraint: This work must not modify orchestrator-to-Pi migration or #754/#755 lease/fencing files.
- Risk: accepting untagged messages could create noisy or unintended agent input. Mitigation: only explicitly configured channels and paired, role-authorized users are accepted, with bounded per-user/channel message and thread rates.
- Risk: Discord thread creation can fail because of channel permissions, archived state, or API rate limits. Mitigation: fail without dispatching a turn whose response destination cannot be honored, and emit sanitized diagnostics.
- `ASSUMPTION:` Configured channels are dedicated agent interaction surfaces, so authorized untagged human messages are intentional agent input.
- `ASSUMPTION:` Mention in a parent channel selects a public thread; messages already in a thread remain there because Discord has no nested threads.
- `ASSUMPTION:` One Discord bot may serve multiple configuration-owned logical-agent bindings.
- `ASSUMPTION:` Static allowlists and paired-user roles are the authorization administration surface for this slice.
### Testing and Delivery Intent
Use TDD for remote-ingress routing and permission boundaries. Required evidence includes parent-channel mention, untagged parent message, existing-thread follow-up, existing-thread mention, thread reuse, unauthorized side-effect denial, stable harness-neutral conversation identity, adapter health, and regression coverage for signed envelopes and approvals. Deliver through issue #756, a reviewed squash PR to `main`, terminal-green CI, and issue closure.
---
## Mos Runtime Portability Workstream (MOS-PORT)
### Problem and Objective
Mos is currently identified partly by a harness-native session and communication process. Replacement/rebinding exists, but no gateway-enforced logical identity or fencing prevents a stale harness from continuing to reply or execute effects after takeover.
The objective is to make Mos a server-derived logical Mosaic identity whose authority can move safely among runtime connectors. The gateway owns identity, lease, policy, and audit; harnesses remain replaceable adapters.
### M1 Requirements
1. `MOS-PORT-ID-001`: Define a normalized logical-agent identity independent of Claude Code, Pi, Codex, tmux, Matrix, and provider-native session IDs.
2. `MOS-PORT-LEASE-001`: Persist one exclusive connector lease per tenant/logical-agent/binding with CAS acquisition, monotonic fencing epoch, TTL, heartbeat, explicit release, and takeover.
3. `MOS-PORT-FENCE-001`: Bind every connector dispatch/execution grant to the current server-derived tenant, logical identity, binding, connector, scopes, expiry, and lease epoch.
4. `MOS-PORT-FENCE-002`: Reject and audit stale, expired, forged, cross-tenant, cross-binding, and unauthorized grants before connector, channel, provider, or tool side effects.
5. `MOS-PORT-OBS-001`: Emit credential-safe correlation/audit events for lease acquire, renew, takeover, reject, release, and expiry.
6. `MOS-PORT-ARCH-001`: Runtime/provider adapters consume normalized lease context without adding harness-native schemas to Mosaic core.
### M1 Acceptance Criteria
1. `AC-MOS-PORT-01`: Two contenders for one binding cannot simultaneously hold current authority under concurrency.
2. `AC-MOS-PORT-02`: Successful takeover increments the fencing epoch and every operation from the old epoch fails closed before side effects.
3. `AC-MOS-PORT-03`: Gateway/database restart preserves lease and epoch state; expired leases can be recovered only through the authorized takeover path.
4. `AC-MOS-PORT-04`: Cross-tenant, cross-agent, cross-binding, forged, and expired lease/grant cases are denied and audited.
5. `AC-MOS-PORT-05`: Unit, migration, repository close/reopen, concurrency, abuse, gateway integration, independent security review, CI, and documentation gates pass.
### Deferred to Later #754 Milestones
Canonical checkpoint/handoff payloads, exactly-once connector receipts, concrete Claude/Pi/Codex adapters, channel cutover, and full cross-harness failover/rollback E2E are explicitly out of M1 scope.
---
## Workspace placement guard hardening (#1174)
### Problem and objective
The Bash pre-tool guard must prevent Git checkouts and repository state from being placed under
`$HOME` without refusing ordinary Git commands merely because a source, option value, branch name,
or metadata mentions `$HOME`. A guard that over-blocks routine work is unsafe because operators
will route around it.
### Scope and requirements
1. `WPG-REQ-01`: `git clone` and `git worktree add` placement SHALL be judged from their placement
operands, not from every HOME-shaped word in the command.
2. `WPG-REQ-02`: Clone sources, references, templates, environment assignments, and non-placement
worktree metadata MAY resolve under HOME when all placement operands resolve elsewhere.
3. `WPG-REQ-03`: Both attached and separate-value `--separate-git-dir` forms SHALL remain placement
operands and SHALL be refused when they resolve under HOME.
4. `WPG-REQ-04`: Option classification SHALL account for Git's rule-generated boolean negations
without relying on an enumerable allowlist of flag spellings.
5. `WPG-REQ-05`: Quote removal, escapes, shell command boundaries, redirections, and end-of-options
handling SHALL preserve existing fail-closed checkout coverage.
6. `WPG-REQ-06`: Absolute placement aliases SHALL resolve shell-known HOME spellings, dot segments,
repeated separators, and existing symlink parents before the HOME boundary comparison.
7. Relative targets whose effective path depends on the shell cwd are out of scope and tracked by
#1197.
### Acceptance and verification
1. Git's own option parser accepts each tested flag, including generated `--no-*` forms, while the
guard allows a HOME-valued source with an explicit safe destination.
2. Equivalent clone and worktree fixtures cover rule-generated negations and remain discriminating
against the prior head where the defect existed.
3. Real HOME destinations and both `--separate-git-dir` forms remain blocked, including placements
after shell command boundaries.
4. The full hermetic guard suite, syntax/static checks, adversarial probes, independent review, and
terminal-green CI pass before merge.
5. Any option-classification residual is documented with its deliberate failure direction.
### Constraints, risks, and assumptions
- Security and usability are co-equal: neither a placement bypass nor routine over-block is an
acceptable repair.
- `ASSUMPTION:` The value-taking option surface exposed by the installed Git version is closed and
measurable through Git's own parser/help output; rationale: boolean flags are rule-generated,
while separate-value options have explicit grammar and must be classified as such.
- Risk: a future Git release may add a new value-taking placement option. Mitigation: document the
chosen residual direction and pin every currently supported placement option in behavior tests.
- Risk: a symlink can be replaced after pre-execution canonicalization. Mitigation: resolve every
existing parent physically and document the remaining inherent TOCTOU window; the worktree helper
remains the authoritative path-derivation mechanism, with atomic closure tracked by #1199.
---
## Release Integrity Workstream (RI, #1275)
### Problem and objective
At `next` 476db12b (review of 2026-08-17), publication from `next` is not bound to the full verification pipeline for the same commit: the publish pipeline's publish steps depend on `build` only, while ordinary push CI excludes `next`. Public Forge/MACP paths contain false-success placeholders: a stub executor that reports `completed` with exit zero, planning/remediation gates that execute literal `true`, a review gate that echoes an approving verdict, and a gate runner that treats empty commands and unimplemented CI-provider gates as passing. Shipping UI surfaces can render a failed fetch as an empty, healthy collection.
Objective: for alpha 0.0.50, the release cannot publish, report, or display work state that the repository has not actually verified. Decisions SDLC-D-033 through SDLC-D-038 (Jason, 2026-08-17) scope this floor; full decision text and required-behavior lists live in jarvis-brain `docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` and `data/decisions/mosaic-stack-sdlc-protocol.json`. This section restates only the normative requirements.
### Normative requirements
1. **RI-N1 Exact-commit publication verification (SDLC-D-034).** One canonical terminal verification command performs self-contained re-verification in the publish pipeline against the job's checked-out commit before any external publication effect. The command contains or invokes the complete mandatory verification set (semantic parity with the PR merge gate, including sanitization, upgrade-guard, typecheck, lint, format check, tests, and build); CI and publication do not maintain separate semantic checklists. Every publish step depends on the verification step in the executable pipeline DAG. Provider commit identity and `git rev-parse HEAD` must identify the same commit. Missing, skipped, cancelled, stale, or inconclusive checks fail closed. Documentation-only runs may skip publication but cannot bypass verification when a publication effect will occur. A negative control must prove that a broken check blocks every publish step.
2. **RI-N2 Fail-closed Forge/MACP with explicit simulation (SDLC-D-035).** Simulation requires explicit caller intent (e.g. `--simulate`) and produces a distinct typed `simulated` state that can never satisfy dependencies, acceptance criteria, gates, merge, or release. Normal execution exits nonzero with a typed capability failure when a required executor, reviewer, command, or CI provider is absent — no stub completion, no literal-`true` gates, no synthetic approvals, no empty-command passes. A manual gate with no automation enters a waiting state; it does not pass. Positive tests prove explicit simulation still works; negative controls prove simulation and every missing-provider case cannot advance lifecycle state.
3. **RI-N3 One transitional PRD authority (SDLC-D-036).** `@mosaicstack/prdy` structured storage under `docs/prdy/`, driven by `mosaic mission --plan`, is the authoritative PRD representation for the alpha. `mosaic prdy` either routes through the same application service or operates only as an explicit, named Markdown import/export adapter; `docs/PRD.md` is not a peer authority. `mission --plan` must persist the mission↔PRD linkage (mission id/version, PRD id/version, selected requirements). Markdown output is a generated view carrying source identity; editing it cannot mutate authority silently. Import is explicit, validated, and conflict-aware (proposed successor, never overwrite). Structural validity is separate from approval.
4. **RI-N4 One quality-rails evaluator (SDLC-D-037).** The TypeScript quality-rails package is the sole authoritative evaluator. A complete probe inventory maps every current TypeScript and shell check to one canonical check with disposition (preserve/strengthen/retire, each named). Effective shell enforcement probes are absorbed before their independent paths retire; expected-file presence alone is not parity. The evaluator returns typed results (`passed`/`failed`/`blocked`/`error`/`not-applicable`) with check version, subject, and reason; missing implementation, missing input, unknown check, process error, timeout, or malformed output can never become `passed` or an unqualified skip. Check definitions and policy are versioned and digested. Shell commands become thin adapters with no separate verdict logic. The canonical terminal verification command (RI-N1) invokes this evaluator rather than duplicating its logic. Contract, parity, and negative-control tests are required, plus independent review of probe equivalence.
5. **RI-N5 Consequence-aware stale UI (SDLC-D-038).** Mission Control distinguishes typed freshness states (`current`, `stale`, `partial`, `unknown`, `unavailable`) rather than inferring from empty arrays or null. A failed fetch never renders as an empty healthy collection. Last-known data may display for situational awareness only with source identity, version, and age visibly labeled; any derived completion/assurance/release verdict whose inputs are stale becomes `unknown`; all state-changing actions are disabled until fresh state loads and is revalidated. With no verified snapshot, surfaces show an explicit unavailable state. Cache corruption, cross-workspace data, schema mismatch, and version regression invalidate the snapshot. Tests cover the failure matrix (network, auth, malformed, partial, corruption, stale age, schema mismatch, recovery, stale-action rejection) with negative controls proving no case yields a current green verdict or enabled mutation.
### Acceptance criteria
- AC-RI-1: A push to `next` that fails any mandatory verification step publishes nothing (no npm package, no image), demonstrated by a checked-in negative control and by pipeline evidence on a real `next` publish run where the verification step is green and every publish step depends on it.
- AC-RI-2: With no executor/reviewer/CI provider wired, Forge and MACP normal runs exit nonzero with typed capability failures; with `--simulate`, runs complete but every result is typed `simulated` and cannot satisfy any gate, dependency, or completion state — proven by unit tests including negative controls.
- AC-RI-3: A PRD created or revised through either `mosaic mission --plan` or `mosaic prdy` resolves to one authority under `docs/prdy/` with stable identities and versions; the mission↔PRD linkage survives restart; a Markdown export is labeled as generated and cannot silently become a second writer; divergent legacy content blocks baseline claims until explicitly resolved — proven by contract tests.
- AC-RI-4: `quality-rails check` through any entry point (TS CLI, framework shell adapter) returns the same typed verdict for the same subject; the probe inventory names every legacy check's disposition; a deliberately broken probe fails closed — proven by contract/parity/negative-control tests and independent review of probe equivalence.
- AC-RI-5: No shipping surface renders a failed fetch as an empty healthy state; stale/partial/unavailable states are typed, labeled, and mutation-disabled — proven by the failure-matrix tests.
- AC-RI-6: All cards merged to `next` via squash PR with terminal-green CI; release evidence for 0.0.50 records commit, verification run, and published artifacts.
### 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.
-35
View File
@@ -1,35 +0,0 @@
---
kind: record
status: superseded
---
# PRD rev0 — archive record
`PRD.md` in this directory is the 2026-08-26 North Star PRD, archived **verbatim** at
ratification of rev1 (2026-09-01). It is byte-identical to `origin/next:docs/PRD.md` at
commit `9aa4983c` (SHA-256
`60cc2f98697471850caa3440d79139d70f67eda585a2ee465fdcd517bc36afdf`). Per GOV.1 the archived
bytes are never edited — not even to repair links — so the digest stays verifiable.
**Its relative links were written for `docs/PRD.md` and do not resolve from this directory.**
That is an accepted, intentional consequence of archive-never-edit (owner disposition: the
control-plane-surfaces lane, 2026-09-02, review `CPS-PRD-REV1-REVIEW-Q90` F3). Resolve them
with this table; every target still exists in the tree.
| Link text in `PRD.md` (lines) | Resolves to |
| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `./archive/PRD-v0.1.md` (17) | [../../archive/PRD-v0.1.md](../../archive/PRD-v0.1.md) |
| `./ROADMAP.md` (18, 177, 249) | [../../ROADMAP.md](../../ROADMAP.md) |
| `./requirements/native-kanban-sot.md` (98) | [../../requirements/native-kanban-sot.md](../../requirements/native-kanban-sot.md) |
| `./fleet/NORTH_STAR.md` (181) | [../../fleet/NORTH_STAR.md](../../fleet/NORTH_STAR.md) |
| `./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md` (444) | [../../fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](../../fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) |
| `./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` (444) | [../../fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](../../fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) |
| `./TASKS.md` (462) | [../../TASKS.md](../../TASKS.md) |
| `./native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md` (623) | [../../native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md](../../native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md) |
| `./requirements/cli-capability-migration.md` (906) | [../../requirements/cli-capability-migration.md](../../requirements/cli-capability-migration.md) |
Rule for future archives (recorded here; GOV.1 carries the general archive contract): every
`docs/PRDs/<date>_PRD_revN/` archived from a different original location ships a `README.md`
like this one — digest, original path, and a link-resolution table — instead of edited bytes.
Current revision: see [`docs/PRD.md`](../../PRD.md).
@@ -1,85 +0,0 @@
---
id: AUTHN.1
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# AUTHN.1 — Authentication accounts
Agent-side provider credentials: the accounts seats use to reach providers.
(Human login identity is D10 territory — better-auth as system of record — and
is out of this section's scope.)
## Authentication configuration surface (WebUI page + CLI)
| Control | Notes |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| in-browser OAuth establishment | the OAuth flow runs in-browser; whether the backing terminal flow is tmux-bridged is open: [[GOV.5-open-questions]] Q-N1 |
| configured accounts list | provider, mode (OAuth/API), status, holder |
| force renew | |
| deactivate | deactivated accounts drop out of every seat/harness selector |
| allowed harnesses | which harnesses may use this account |
## Custody rules
- Secrets live with the **credential broker** (OpenBao/Vault or flat files per
deployment mode — D3), never in the brain tree, never in manifests, never in
Postgres records. Enforced role manifests declare
`credentials: {store: none, providerTokens: denied}` — the enforced roles
hold no credentials at all; accounts are a launcher/broker concern.
- Multi-account per provider is a requirement (onboarding D4 already captures
multi-account enrollment).
- Account shape in the seat record (single account vs per-provider map) is
open: [[GOV.5-open-questions]] Q-D2.
## Credential-broker custody rules (pulled 2026-08-31, generalized from the vault draft)
- Reads require a token scoped to the needed paths; provisioning and writes go
through a declared channel with documented purpose. An ordinary role never
mints credentials or creates production paths.
- Canonical secret path: `environment / service / component / secret-name`,
lowercase kebab-case, nothing sensitive encoded in the path; environments
never cross-reference each other's mounts. Standard field names
(`username`/`password`, `token`, `host`/`port`/`url`).
- Only the needed field is extracted into the consuming process; values are
never echoed to logs or transcripts — read success is proven by field
presence and digest, never by printing the value.
- Least privilege, short-lived tokens, no local copies, immediate rotation on
compromise; every access audited by the broker.
## E2 inputs (triage 2026-08-31; see [[GOV.2-docs-inventory]])
**Canonical ground truth**: `ADMIN-GUIDE/security/sso-providers.md` (D10 ground
truth: better-auth + Authentik/WorkOS/Keycloak OIDC).
**Pending pulls**: DRAFT S2 `identity-lifecycle.md` (D10 + the #1430 bootstrap
fix) and `custody-schema.md`; brain `docs/guides/proposed/operations/vault.md`
(credential-broker custody rules this section states without operational detail).
## S2 contract feed (extraction 2026-08-31)
Full extraction record: lane `S2-EXTRACTION-2026-08-31.md` (per-contract cores, dependency edges, ruling cross-checks). Pulls binding on this section (identity-lifecycle, contract 4, plus
wizard AUTHN clauses):
- better-auth tables are the **only** account system of record (D10); IdPs are
login methods only; account creation grants nothing.
- `registration_mode` open/invite/closed, defaults **closed** post-bootstrap,
forced closed during the epoch, enforced at a better-auth hook.
- Bootstrap/first-admin invariant (#1430): zero-to-one-admin exactly once per
epoch, one atomic transaction, durable fail-closed `bootstrap_state`,
re-runnable; first-admin-via-SSO runs as a bootstrap-writer transaction,
never JIT. **v1 first admin is password-only — a disclosed PRD deviation.**
- JIT defaults OFF per-provider always; JIT users get `member`, never
elevated; **role/authorization attributes are never mapped from IdP
claims**. Linking keyed `(issuer, subject)`; explicit linking = step-up
reauth ≤10 min; automatic linking gated by off-by-default
`trusted_for_linking` + verified email.
- Deactivation (ban) must bound all entry paths — **live defect: the
admin-bearer-token path does not check banned status**. Deletion deferred;
the existing hard-delete endpoint and `mosaic auth users delete` are
mandated for removal.
## Seat auth shape ruling (Q-D2, Jason 2026-09-01)
Per-provider map in `profile.json`, values are credential-broker references —
never secret material. The broker custody rules above govern resolution;
extraction stays field-scoped and digest-proven.
@@ -1,146 +0,0 @@
---
id: AUTHZ.1
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# AUTHZ.1 — Capability authority, enforcement, and accepted risk
The agent-side authority model: what binds a seat, where it is enforced, what
is closed by construction, and what is accepted as residual risk. Sources: the
L2 authorization contracts, `mosaic-core` (measured 2026-08-31), and the lane's
`AUTHORIZATION-GAPS.md`.
## Glossary
**Privilege escapation** _(Jason, 2026-08-31)_ — the outcome class in which an
agent exercises authority it was never granted, regardless of mechanism.
Deliberately collapses escalation and misdirection: the outcome is identical;
the distinction matters only when choosing a control.
## Authority composition is pure intersection
```
role capability ceiling
∩ assignment scope ∩ lease scope ∩ workflow state
∩ target policy ∩ trusted backend availability
= effective capability grant
```
No operation adds capability. Only an authenticated principal with
role-management authority may create, edit, activate, bind, or roll back roles
(L2-D13); agents cannot, ever. Orchestrators cannot deploy seats at all —
coordination goes through `mosaic coord` requests to the coordinator service
(register OD-08/OD-09). Cycle detection is unnecessary because no grant edge
exists.
## Enforcement point: `mosaic-core`
A tracked, non-npm Pi extension loaded via role-scoped settings. Per seat, per
launch: trusted-path manifest load
(`<brainHome>/fleet/roles/<role>/mosaic-core.manifest.json` — committed,
non-symlink, role cross-checked), fail-closed state machine
(`not-applied | applied | fail-closed`), exact active-tool reconciliation,
per-call interception with read containment (canonicalization + realpath +
credential-path deny), per-incarnation append-only journal. Capabilities C1C8
are the authority; tools are their bound projections; `tools[]` must equal the
bound bindings exactly. The capability map is closed and hardcoded in the
canary phase (gap G7 — by design for now; L2-D21 is the end state).
## The firewall rules
1. **Agents never reach role-management surfaces** (L2-D13). A seat that can
invoke role management can self-grant, voiding the model.
2. **The coordinator is a service, never a seat** (gap G4; register OD-08). A
coordinator-as-seat turns the intersection chain into a cycle.
## Closed vs accepted
**Closed by construction: escalation.** The intersection chain admits no
capability-adding edge.
**Accepted within reason: misdirection.** A rogue or colluding orchestrator can
still _route work_ to a seat that already legitimately holds a capability. The
authority is genuine, so no structural control can refuse it. Jason's ruling:
escalation and misdirection are tiered **by outcome — identically**; locking
down far enough to mechanically prevent misdirection cripples the system to
inoperability. Mitigation posture: **audit and visibility, not prevention**
see the audit surface in [[UI.1-webui-surfaces]] and gap G3 below.
## Role separation as the working mitigation
Design intent (not yet enforced by any manifest — gap C3 in the lane ledger):
| Role | Must NOT |
| -------------- | -------------------------------- |
| coding agent | create remote git repositories |
| gate agent | code |
| review agent | code |
| interact agent | code, create remote repositories |
Breaking the monopoly means no single seat holds an exit path; escape requires
inter-seat coordination, which is exactly what the audit exists to catch.
Register OD-25OD-34 (review/gate/arena families, independence rules) carry the
role taxonomy this matrix projects onto.
## Gap register (measured 2026-08-31)
| ID | Gap | Status |
| --- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| G1 | No least-privilege language anywhere in L1/L2/NORTH-STAR (grep-verified) | **L2-D52 amendment drafted** |
| G2 | Assignment issuance criteria unspecified (renewal names criteria; issuance does not) — full-ceiling requests validate cleanly | **L2-D52 amendment drafted** |
| G3 | No misdirection audit exists — no tooling, agent, or surface | open → [[UI.1-webui-surfaces]] §Audit |
| G4 | Coordinator-as-seat would collapse the model | firewall — never violate |
| G5 | Seat config mixes authority classes (role binding beside a model dropdown) | open → [[SEAT.1-seat-profile]] |
| G6 | `role-harness-config/DESIGN.md` scope defect (unstated surface) | fix drafted (amendment in `proposed/docs/`) |
| G7 | Capability map closed/hardcoded | by design (canary phase) |
Amendments staged in `proposed/docs/` per the lane convention; ledger items
A3/A4 track ratification. The auditor-identity question (an auditor agent is
itself a seat, itself subject to misdirection) is on the grill:
[[GOV.5-open-questions]] Q-A1.
## E2 inputs (triage 2026-08-31; see [[GOV.2-docs-inventory]])
**Canonical ground truth**: `DEVELOPER-GUIDE/architecture/mutator-class-gate.md`
(the default-deny whole-class gate this section's language must match),
`lease-broker-protocol.md` + `lease-broker-security.md` (incl. the named
promote-lease-lost-ACK residual), `ADMIN-GUIDE/security/discord-ingress.md`
(the one implemented admission/role model).
**Pending pulls**: DRAFT S2 `rbac-grant-model.md` (granular RBAC per rev0 §4).
## S2 contract feed (extraction 2026-08-31)
Full extraction record: lane `S2-EXTRACTION-2026-08-31.md` (per-contract cores, dependency edges, ruling cross-checks). Pulls binding on this section:
- **Three-layer authority (contract 2)**: platform role (member/admin,
instance administration only, **no implicit tenant access** — two live admin
bypass paths named non-conformant and scheduled for retirement:
`command-authorization.service.ts` admin short-circuit, `mcp.service.ts`
scope derivation); hierarchy grants (viewer/member/owner, deny-by-default,
down-chain, effective = max, live fail-closed); workspace membership
(its own mechanism, REQ-ID-001). The layers are non-substitutable.
- **Agents are not a valid grant subject** — grant subject is exactly-one-of
user_id/team_id. Structural enforcement of the agents-never-reach-role-
surfaces ruling, stronger than policy.
- **Consent ≠ authorization (contract 7 §5.7)**: consent records govern
agentic/feature data access, are distinct from hierarchy grants, and confer
no platform authorization; default-deny with **no platform-admin bypass**;
consent mutation is subject-only (admins refused at write time).
- **Bounded revocation propagation**: next authz decision denies; open
Socket.IO connections re-evaluated within 30s or next inbound message.
- **company-CRUD capability**: platform-scoped, admin-assigned, audited
delegation of exactly one visibility-mutation command (`platform_capabilities`
table) — the model's template for narrow capability delegation.
- **Membership locality + no-existence-oracle (contract 8 §3)**:
member-readable workspaces contribute only at their own node, never promoted
upward; unreadable vs nonexistent are byte-equivalent.
## Audit implementation ruling (Q-A1/Q-A2, Jason 2026-09-01)
The authorization audit is **mechanical tooling**: deterministic checks over
the grant/assignment record, witness-style (the S2 writer-coverage pattern),
feeding the audit page read-only. Agents may consume audit output but never
produce the verdict — prompt adherence is not an enforcement mechanism. Q-A2
(who audits the auditor) dissolves: the auditor is code, audited by ordinary
review and CI.
@@ -1,158 +0,0 @@
---
id: CLI.1
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# CLI.1 — CLI surface and parity obligation
## The rule
The CLI is the **primary execution method** (D8); the WebUI operates the same
tooling over the Gateway API and never bypasses it (D12). Register OD-49 fixes
`mosaic config` as the stable installation-configuration command family backed
by one desired-state engine; register OD-53 makes every interface (CLI, TUI,
WebUI, API, automation) a client of that same engine.
Parity is therefore **structural, not aspirational**: a capability that exists
in the CLI without a WebUI surface is an incomplete projection; a WebUI wish
with no backing tool is **"blocked on tooling"** and the tool is built first
(D8 consequence). Neither side ever grows private logic.
## Parity matrix obligation
The ratified bundle must carry (or cite, per D8's baseline inputs) three
artifacts, kept current:
1. **Tool inventory** — what official tooling exists and what is missing.
2. **WebUI→tool mapping** — every page control mapped to the tool it calls
([[UI.1-webui-surfaces]] page inventory is the row source).
3. **Measured `next`-branch state** — what actually works today.
All three artifacts were measured 2026-08-31 against `origin/next` commit
`9aa4983c` and appear below. Grill: [[GOV.5-open-questions]] Q-C1 (matrix
freshness ownership after ratification).
## Artifact 1 — tool inventory (measured, `origin/next` @ `9aa4983c`)
Registration root: `packages/mosaic/src/cli.ts` (commander); command modules
under `packages/mosaic/src/commands/`; `coord`/`prdy`/`doctor`/runtime
launchers dispatch to bash tools under `packages/mosaic/framework/tools/`
(subcommand tables at `commands/launch.ts:11511255`); sibling packages
(`brain`, `forge`, `macp`, `quality-rails`, `log`, `memory`, `queue`,
`storage`) register their own families.
Control-plane-relevant families, by rev1 domain:
| Domain | Families (measured) |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| SEAT | `fleet` (init/install/systemd/lifecycle/roster/add/remove/verify/ps), `fleet` roster-v2 CRUD (`get/plan/create/update/delete`), `fleet apply`/`reconcile`/`doctor`/`regen`, `fleet provision`, `fleet migrate-v1 preview`, `agent` (configs + enroll + nested fleet-agent commands), `promote`, `comms send` |
| ROLE | `fleet persona` (`list/show/customize` — baseline ⊕ `roles.local/` overrides), `fleet profile` (`list/show` topology templates) |
| HARN | `config` (framework config + hooks), `compose-contract <harness>`, `skill`, `seq`, `init`/`sync`/`bootstrap`, `doctor`, runtime launchers (`claude`/`codex`/`opencode`/`pi`, experimental `claudex`, `yolo`) |
| PROV | `gateway config` (raw provider API-key env vars only), `wizard` (setup-time provider config) |
| AUTHN | `login`, `auth users {list,create,delete}`, `auth sso {list,test}` (stubbed — see gaps), `auth sessions list` (stubbed), `gateway` token lifecycle (`config rotate-token/recover-token`) |
| SESS | `tui`, `sessions {list,resume,destroy}`, `interaction` (durable-session surface: enroll/attach/send/chat/stop/recover), `coord`, `watch`, `mission` |
| Governance/other | `prdy {init,update,validate,status}`, `federation {grant,peer}`, `macp tasks gate`, `telemetry`, `upgrade`/`update`/`restore`/`uninstall`, `q`, sibling-package families |
Notable structural facts: there is **no top-level `mosaic role` verb** — role
management lives at `fleet persona`, three levels deep; and `doctor`/`status`
exist twice (top-level framework-scoped vs `fleet`-scoped), shadowing by name.
## Artifact 2 + 3 — WebUI→tool mapping with measured state
Rows are the [[UI.1-webui-surfaces]] page domains; measured against
`apps/web/src/spa/pages/` and the gateway controllers on the same commit.
| Surface function | WebUI today | CLI today | Parity state |
| ---------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Seat lifecycle & roster (SEAT) | **no Seats page** (routes are only /admin, /settings, /projects, /tasks, /chat) | complete (`fleet`/`agent` families) | CLI-ahead — page is D2's work, tooling exists |
| Role/persona config (ROLE) | no page | `fleet persona` | CLI-ahead; naming mismatch: no `mosaic role` verb for the ROLE page to mirror |
| User role/ban (AUTHN) | admin UsersTab toggles role/ban via admin endpoints directly | `auth users` lacks `set-role`/`ban`/`unban` | **WebUI-only mutation — violates the D12 rule as implemented** |
| SSO admin (AUTHN) | SsoProviderSection reads _public_ `/api/sso/providers` discovery | `auth sso list/test` stubbed: "admin endpoint missing" | **blocked on gateway tooling**; CLI and WebUI don't even hit the same surface |
| Auth-session admin (AUTHN) | — | `auth sessions list` stubbed (no server endpoint) | blocked on gateway tooling |
| Provider list/test (PROV) | settings ProvidersTab: `GET /api/providers`, `POST /api/providers/test` | none — only `gateway config` raw env-var writes | **WebUI-only read/test — no `mosaic provider` family exists** |
| Default harness/provider/model selection (HARN/SESS) | `GET/PUT /api/chat/preferences/selection` per user | none persists the stored preference (`tui --model` is per-session only) | WebUI-only mutation |
| Authorization hierarchy & grants (UI-audit) | **no page** | **no command** | **the largest D12 gap**: `hierarchy.controller.ts` exposes full CRUD (companies, estates, platform-projects, grants incl. `grants/:id/change`) with audit repository and grant evaluation behind it — reachable only by raw API |
| Federation grants/peers | no page | `federation grant/peer` | CLI-ahead (posture pending Q-T1) |
**Consequences for the build order** (D8: tool first, then surface):
`mosaic provider {list,test}`, `auth users {set-role,ban,unban}`, a stored
harness-selection command, the missing gateway admin endpoints for SSO/session
listing, and a CLI face for the hierarchy/grant surface all precede their
pages. The two **WebUI-only mutations** (role/ban toggle, harness selection)
are standing D12 violations to remediate, not precedents to extend. The
hierarchy CRUD surface is the natural backing for [[UI.1-webui-surfaces]]'s
authorization audit page — but it must get a CLI face and an audit read-path
before the page ships.
## Command families in scope for the control plane
`mosaic config` (OD-49 desired-state engine), `mosaic coord` (agent coordination
boundary — register OD-09), `mosaic prdy` (PRD creation/acceptance — register
OD-22), role management (one canonical API, L2-D14), seat lifecycle
(launch/relaunch per register OD-59), `mosaic doctor` (drift detection classes,
e.g. the #1194 framework-tool drift addendum in [[GOV.4-workstream-contracts]]).
## `mosaic config` v1 subset (pulled 2026-08-31 from the minimal-subset spec)
The Q14 ruling (2026-08-29) fixes the current scope: shipped surface
`edit/get/set/show/hooks/path` **plus exactly two new read-only verbs**
`mosaic config validate` and `mosaic config plan` (`--file` | `--preset`,
mutually exclusive; `--format table|json`). `apply`, `add`, `restructure`,
`migrate`, `remove`, `export` are **out of v1 pending a full-engine ruling**
([[GOV.5-open-questions]] Q-D5) — the configuration-lifecycle draft's "stable
namespace" table describing the full family is aspirational, not current state.
Contract highlights: **valid** vs **conformant** are distinct verdicts with
distinct exit codes (nonconformance is a diagnostic, not a parser failure);
`plan` emits `create|update|blocked` operations with risk classes
(`none|review-required|full-engine-required`), a SHA-256 `planId`, and
`applySupported: false` always in v1; destructive/unsupported drift is
`blocked`, never silently normalized; absolute no-mutation during
validate/plan (no writes, no network, no credential calls); results ride the
T78 `CapabilityResultV1` envelope (capability IDs
`config.installation.validate`/`.plan`); inputs capped, YAML
aliases/anchors/tags rejected, no secret-shaped fields accepted or echoed.
## E2 inputs (triage 2026-08-31; see [[GOV.2-docs-inventory]])
**Canonical ground truth**: `requirements/cli-capability-migration.md` (T78,
`source_of_truth: true`), `fleet/reference/cli.md` (local fleet CLI vs
gateway-backed catalog), `USER-GUIDE/getting-started/quickstart.md`.
**Pending pulls**: DRAFT S2 `tool-gateway-mapping.md`; brain
`docs/specs/2026-08-29_mosaic-config-minimal-subset.md` (`mosaic config
validate/plan`, cites OD-49OD-55) and
`docs/guides/proposed/workflows/configuration-lifecycle.md` (the OD-49 engine
family definition).
## S2 contract feed (extraction 2026-08-31)
Full extraction record: lane `S2-EXTRACTION-2026-08-31.md` (per-contract cores, dependency edges, ruling cross-checks). Pulls binding on this section:
- **Contract 5 §4.5 is the parity clause this section's matrix enforces**:
CLI remains the primary execution method for every Gateway command; no
WebUI-only command exists; a Gateway command without CLI exposure is a
conformance gap tracked at the family's implementing issue. The
hierarchy/grants CRUD gap measured in this section is exactly such a
tracked conformance gap once contract 5 ratifies.
- **Command envelope**: typed request/result DTOs (no `any`), closed
per-family error taxonomy, audit correlation id, fail-closed — aligns with
the T78 `CapabilityResultV1` direction already in this section.
- **Contract 9 (api-artifacts)**: `ApiAuthClass` closed six-value enum
(`none`/`session`/`api-key`/`admin`/`federation`/`bootstrap`); OPENAPI.yaml
generated, CI byte-drift-gated, never hand-edited; hard ordering — nothing
under contract 9 lands before contract 5 (PR #1438) is on the trunk.
- **Roll-up (contract 8)** ships as a query-only tool with no command
counterpart (A5 rank 5) — the taxonomy precedent for read-only surfaces in
the parity matrix.
- **Mandated removals** the CLI inventory must track: `mosaic auth users
delete` (with the hard-delete endpoint) is required to be disabled/removed
by contract 4.
## Parity freshness ruling (Q-C1, Jason 2026-09-01)
The parity matrix becomes a generated artifact with a CI drift-gate witness in
the stack repo (the contract-9 pattern): CI regenerates the tool inventory and
WebUI→tool mapping from code and fails on divergence from the committed
matrix. No human cadence to forget. Building the witness is E6-return
follow-up work.
@@ -1,159 +0,0 @@
---
id: DATA.1
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# DATA.1 — Record-class authority and the configuration data model
Merges J1 (2026-08-23), operator register OD-13/OD-48/OD-49OD-52, and the lane's
`CONFIG-MODEL.md` findings into one authority table. See
[[GOV.3-decision-map]] for registry identities.
## The rule (J1)
- **Git owns** reviewed governance and declarative definitions.
- **PostgreSQL owns** runtime state and projections.
- Flat files on disk are **generated projections**, never authority (L2-D19).
## Authority table
| Record | Authority | Rationale |
| --------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------- |
| Role Definition / Role Revision | **Git** | reviewed governance; revisions immutable and digested |
| `mosaic-core.manifest.json` | **Git** | committed, non-symlink, trusted-path — the loader refuses anything else |
| PRD revision bundles (`docs/PRDs/`) | **Git** | immutable accepted versions (register OD-17) |
| Portable config blueprint | **Git** (`fleet/configuration/installation.yaml`, register OD-50) | declarative desired state |
| Host bindings | ignored `config/installation.local.yaml` (OD-50) | host-local, never authority over roles/gates (OD-52) |
| Role Binding (seat → revision) | **Postgres** | runtime state; control-plane mutable |
| Seat record (harness, model, workdir, auth account) | **Postgres**, projected to flat files | runtime state |
| Leases, checkpoints, session/incarnation state | **Postgres** / coordinator (register OD-57OD-60) | runtime state with fencing |
| `settings.json`, `launch.env` | **generated projection** | L2-D19; no writer may treat them as source |
Transition rule: the WebUI may edit flat files during the transition, but the
end state is exactly the table above — every flat file regenerated from Git or
Postgres, never authored directly.
## Seat file consolidation (lane Q1 — proposed, not ratified)
Current flat-file state (measured 2026-08-31):
| File | Carries |
| ---------------------------------------------- | -------------------------------------------------------------- |
| `fleet/agents/<seat>/launch.env` | model, workdir, reasoning level (hand-maintained, git-ignored) |
| `fleet/agents/<seat>/profile.json` | role — read by `mosaic-core`'s trusted-path loader |
| `fleet/roles/<role>/.pi/agent/settings.json` | provider, model, extensions, skills paths |
| `fleet/roles/<role>/mosaic-core.manifest.json` | capability/tool authority (schema v3) |
Proposal: one `profile.json` rules seat information (role, harness, model,
reasoning, workdir, overlay, authentication account); `launch.sh` reads it
instead of `launch.env`. Register OD-48 already ratifies `profile.json` as the
seat's **structured identity** file, which this consolidation completes.
**Blocking consideration:** `mosaic-core` reads `profile.json` at every
`session_start` to resolve the role. Widening the file widens the read surface
of a trusted-path load. The loader must keep ignoring unknown keys (it reads
only `.role` and already does); the file must stay non-symlink and committed.
Verify `lib/loader.ts seatRole()` before landing. Open on the grill list:
[[GOV.5-open-questions]] Q-D1.
## Multi-provider authentication shape (lane Q2 — open)
`profile.json` must name the authentication account a seat uses, across
providers (Claude, OpenAI, ZAI, N others; OAuth or API key; local providers).
Unresolved: one account vs a per-provider map, and the credential-broker
relationship. The enforced role manifests hold **no** credentials
(`store: none, providerTokens: denied`) — this is a launcher/broker concern,
never a manifest concern. Grill: [[GOV.5-open-questions]] Q-D2. See
[[AUTHN.1-auth-accounts]] for the account model itself.
## Reconciliation obligation (lane Q4)
Every change made through CLI or WebUI automatically configures authentication,
`settings.json`, and required symlinks — the user never touches a file. Two
directions with different timing (L2-D17): capability **removal** denies
centrally and immediately; capability **addition** waits for runtime
reconciliation and attestation.
**Hazard to settle first:** Pi settings ownership is ambiguous today
(`launch-seat.sh:259261` symlinks `.pi/agent/settings.json` under
`MOSAIC_SEAT_HOME=1` while the `MOSAIC_SEAT_CONFIG=1` seed fires on `! -s`,
which the symlink satisfies). Settle ownership before the WebUI becomes a third
writer. Grill: [[GOV.5-open-questions]] Q-D3.
## Configuration file authority (pulled 2026-08-31 from the mosaic-config v1 spec)
Four config records with fixed authority (brain spec `2026-08-29_mosaic-config-minimal-subset.md`, register OD-49OD-55):
| Record | Path | Authority |
| ------------------ | --------------------------------------------------- | --------------------------------------------------- |
| Central registry | `~/.config/mosaic/config.json` | resolves brainHome/socket/paths |
| Portable blueprint | `<brainHome>/fleet/configuration/installation.yaml` | tracked, secret-free desired state |
| Host bindings | `<brainHome>/config/installation.local.yaml` | git-ignored; **runtime and working directory only** |
| Packaged presets | immutable, versioned (`bootstrap-minimal@1`) | never `latest` |
Precedence, high to low: constitution/safety (deny-wins, OD-51) → framework
schema/profile/role/roster contracts → blueprint/preset → host bindings →
framework binding defaults → observed state (**compared, never authoritative**).
Host bindings can never change profile, seat selection, roles, authority,
reviews, gates, or safety (OD-52) — the data-model enforcement of the
[[AUTHZ.1-capability-authority]] intersection chain. Validation distinguishes
**valid** (structurally sound) from **conformant** (observed == desired) with
distinct exit codes.
_Triage note:_ the intended-state reconciler spec was judged **operator
host-ops tooling** on full read (systemd/tmux monitoring of the operator
estate) — not product scope; its conformance idea is already covered by the
validate/plan model above. E2-inputs pull downgraded to SKIP.
## E2 inputs (triage 2026-08-31; see [[GOV.2-docs-inventory]])
**Canonical ground truth**: `requirements/native-kanban-sot.md` (ratified, the
D13 base), `native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md` + `KBN-101-ENVELOPE-A.md`
- `SHARED-CONTRACT.md` (frozen contracts), `ADMIN-GUIDE/operations/upgrade-safety-and-recovery.md`
(PGlite tier support boundary), `fleet/reference/roster-v2-fields.md`.
**Pending pulls**: DRAFT S2 contracts `hierarchy-schema.md`, `custody-schema.md`,
`rollup-projection.md`, `mode-conversion.md` (**predates D15 — reconcile first**,
Q-T4); brain `docs/specs/2026-08-28_intended-state-reconciler.md` (reconciler spec).
**Conflicts on the grill**: deployment/federation posture, Q-T1.
## S2 contract feed (extraction 2026-08-31)
Full extraction record: lane `S2-EXTRACTION-2026-08-31.md` (per-contract cores, dependency edges, ruling cross-checks). Pulls binding on this section:
- **Hierarchy schema (contract 1)**: five tables, single-parent FK chains, no
parentage edge tables, **no `owner_id` column** — ownership only via grants.
All mutations through the sole-writable-SOT audited Gateway command path;
three-prong writer-coverage CI witness.
- **Custody schema (contract 7)**: sensitive content only in the user's
git-tracked brain; Postgres holds pointers/consent/registry only ("not as
text, not as excerpts, not as embeddings"); content-first-then-pointer write
protocol with brain fence; HMAC content hashes (no oracle); mode-independent
schemas with a `custody_config` singleton.
- **Roll-up (contract 8)**: the corpus's strongest projection-never-authority
statement — non-authoritative, recomputable, never gates work, enforced by
read-only DB transactions (mechanical, not conventional). Direct precedent
for this section's record-authority chain.
- **Route metadata records (contract 9)**: metadata as _registration input_
auth guard derived from the record makes record-vs-code divergence on those
fields structurally impossible; generated OPENAPI.yaml is committed and
PR-reviewed yet strictly non-authoritative ("generation documents the code;
it does not ratify it") — drafting precedent: "generated" ≠ "uncommitted".
- **Authoritative DB settings rows** — ruled (Q-T5, Jason 2026-09-01):
generated settings _files_ are projections of the active Role Revision,
never authority (L2-D19); DB settings records written through audited
Gateway commands (`platform_mode`, `registration_mode`, `custody_config`,
`bootstrap.seed-company-name`, and successors) are records of authority
like any other SOT row.
## Seat-record rulings (Jason 2026-09-01)
- **Q-D1 — one seat record**: `launch.env` folds into `profile.json`; loader
tolerance verified (`seatRole()` reads only `role` from a generic record).
- **Q-D2 — per-provider auth map**: `profile.json` carries a per-provider map
of credential-broker references (`environment/service/component/secret-name`),
never secrets; one seat, N providers, zero secrets in the brain tree.
- **Q-D3 — one settings writer**: the role-projection engine is the sole
writer of seat settings files; the launcher invokes the projector rather
than seeding; hand edits are drift flagged by `validate` (L2-D19 + Q-T5).
@@ -1,107 +0,0 @@
---
id: GOV.1
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# GOV.1 — PRD lifecycle: SOT, shim, revisions, archival
Ratified structurally by Jason, 2026-08-31 (this lane's grill session). Applies
to the Mosaic Stack PRD in `mosaicstack/stack` (integration trunk `next`).
## The PRD is the project SOT
The PRD is the source of truth for the entire project, independent of any
mission. It is not linked to a current mission and is never overwritten by
milestone work — the pre-2026-08-26 pattern of repurposing `docs/PRD.md` per
milestone is retired. Mission documents are separate: they **reference** the
PRD; they never usurp it. This maintains alignment over time.
## Shim
`docs/PRD.md` is a permanent shim, not the PRD body:
- Frontmatter: `kind: shim`, `current_rev:` pointing at the live revision
bundle.
- Body: one-paragraph summary and a link into `docs/PRDs/`.
- Updating the PRD means ratifying a new revision bundle and repointing the
shim. The shim's path never changes, so every external reference to
`docs/PRD.md` stays valid forever.
## Revision bundles
Each ratified revision is a **frozen bundle directory**:
```
docs/PRDs/YYYY-MM-DD_PRD_revN/
PRD.md # the assembled PRD for this revision
PRD.0-index.md # order authority + domain registry as of this revision
<DOMAIN>.<n>-*.md # every section document, frozen with the PRD
```
The PRD and its supporting sections freeze **as a set** — a revision whose
sections keep moving underneath it is not a revision. Live editing never
happens in `docs/PRDs/`; the next revision is drafted in a lane
(class2 draft-natives per the lane's `proposed/README.md`) and lands as a new
bundle.
## Archival, never deletion
A superseded revision is never deleted and never edited. Versioning is
maintained: every revision that was ever current remains in `docs/PRDs/`
verbatim. Supersession is expressed only from outside the bundle: the shim
points elsewhere, and the dated directory names plus the shim's git history are
the supersession record. The frozen bundle itself is never touched — not even
to add a `superseded_by:` marker.
## Immutability is convention, not enforcement
No hook or CI guard protects `docs/PRDs/` today. If teeth are wanted later, a
CI check that files under `docs/PRDs/` never change after merge is cheap; that
is a separate, future decision.
## Lineage
- rev0 = the 2026-08-26 "North Star" PRD currently at `origin/next:docs/PRD.md`
(commit `9aa4983c`, sha256 `60cc2f98…36afdf`; lane snapshot
[rev0 PRD](../2026-08-26_PRD_rev0/PRD.md)). On ratification of rev1 it archives as
`docs/PRDs/2026-08-26_PRD_rev0/PRD.md` — a one-file bundle, so every revision
has the same shape.
- rev1 = this lane's draft bundle (`proposed/docs/PRDs/2026-08-31_PRD_rev1/`),
combining rev0 with the control-plane-surfaces and agent-runtime-ng lane
findings and the reconciled docs corpus.
Related: [[PRD.0-index]] for naming and ordering; the lane `proposed/README.md`
for draft-stage conventions.
## Registry mechanics (pulled 2026-08-31 from the prd-registry draft)
The operator draft `operations/prd-registry.md` independently specifies the
same lifecycle and adds mechanics this doc adopts:
- **The registry, not the shim, is authoritative.** The shim is generated,
regenerated on every acceptance and amendment; a missing or ambiguous shim
entry is a generation defect, never an authority question.
- Registered versions carry: stable PRD ID, canonical filename/slug, version +
status, acceptance timestamp **and actor**, lineage, content digest,
requirement IDs. Anonymous or inferred acceptance is invalid.
- Missions pin PRD ID + version + digest + in-scope requirement IDs; a digest
mismatch between pin and artifact **blocks the readiness transition** (both
digests shown as evidence).
- Amendment creates a new immutable version; superseded versions remain
queryable as lineage; **requirement IDs are never reused**.
- Agents read and resolve; they never register versions, rewrite artifacts, or
select an implicit latest.
## Registry prefixes (Q-G2, 2026-09-01)
Every decision-bearing document declares a unique registry prefix; file-local
bare D-numbering is prohibited. Three registries are live: the stack PRD
registry (**D1D15**, and successors **Dn** as new stack decisions ratify),
the operator DECISION-REGISTER (**OD-01…OD-65**, renamed from its former
zero-padded `D01``D65` form per this ruling), and the agent-runtime-ng
contract decisions (**L1-Dnn**/**L2-Dnn**) — three distinct namespaces that
must never be conflated. Any new decision-bearing document must declare its
own unique prefix in its header before citing decisions. In text written
before 2026-09-01, a zero-padded bare `Dnn` reads as the operator register's
`OD-nn`.
@@ -1,151 +0,0 @@
---
id: GOV.2
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# GOV.2 — Documentation inventory & supersession triage
E2 record and, at ratification, the PRD's answer to mandate item 4 (no central
location; drift and naming confusion). Verdict vocabulary, per document:
**canonical** | **superseded-by <ref>** | **conflict-with <ref>** |
**working-notes** | **dead** | **operator-only** (brain corpora: correct home is
the operator estate; nothing migrates).
Full per-file verdict tables live in the lane evidence record
`fleet/lanes/control-plane-surfaces/TRIAGE-2026-08-31_e2-verdicts.md`
(point-in-time; this section carries the durable conclusions).
## Corpora
| # | Corpus | Files | Scanned at |
| --- | ----------------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------- |
| 1 | `mosaicstack/stack` `origin/next:docs/` | 346 | commit `9aa4983c`, triaged 2026-08-31 (141 live files per-file; archive dirs swept for orphaned decisions) |
| 2 | `~/.mosaic/docs/` (excl. guides/proposed) | ~76 | triaged 2026-08-31 |
| 3 | `~/.mosaic/docs/guides/proposed/` | 79 | triaged 2026-08-31 |
| 4 | `fleet/lanes/agent-runtime-ng/` + `fleet/lanes/control-plane-surfaces/` | — | live lanes, canonical by definition for their scope |
## Corpus 1 — stack `origin/next:docs/` — conclusions
195 of 346 files (56%) were pre-triaged by the repo's own archive structure
(`archive/` 135, `_old_structure/` 60). The 141 live files triaged per-file:
**The healthy core.** The five guide trees (DEVELOPER-GUIDE, ADMIN-GUIDE,
USER-GUIDE), `fleet/` (concepts/how-to/reference/operations/migration),
`native-kanban-sot/`, `webui/`, `API/`, `tess/`, `release-integrity/`, and the
root atlas docs (README, ROADMAP, SITEMAP) are overwhelmingly **canonical** and
internally consistent. Load-bearing canonical anchors for this PRD:
`requirements/native-kanban-sot.md` (ratified, D13), `KBN-101-DB-ROLE-SPLIT.md`
(frozen), `requirements/cli-capability-migration.md` (T78),
`fleet/NORTH_STAR.md` + `FLEET-DOCTRINE.md`, `mutator-class-gate.md` and the
lease-broker pair (AUTHZ ground truth), `compaction-revocation.md` (SESS ground
truth), `sso-providers.md` (D10/AUTHN ground truth),
`mos-runtime-portability-m1.md` (the only current PROV identity ADR),
`web-dashboard.md` (UI route-by-route ground truth).
**The prime successor material.** The nine DRAFT "webui-audit S2" contracts in
`requirements/` (hierarchy-schema, rbac-grant-model, onboarding-wizard,
identity-lifecycle, tool-gateway-mapping, mode-conversion, custody-schema,
rollup-projection, api-artifacts) are unratified but decision-traceable per
clause to rev0 D-numbers — the most direct feed for DATA/AUTHZ/AUTHN/UI/CLI.
`mode-conversion.md` predates D15 and needs reconciliation before it ratifies
([[GOV.5-open-questions]] Q-T4). Per-contract extraction completed 2026-08-31
(lane `S2-EXTRACTION-2026-08-31.md`): normative cores, dependency edges, and
ruling cross-checks pulled into the DATA/AUTHZ/AUTHN/UI/CLI sections; two
reconciliation questions raised (Q-T4 sharpened — no S2 file references D15;
Q-T5 — projection-rule scope vs authoritative DB settings records).
**Conflicts requiring a ruling** (all carried in [[GOV.5-open-questions]] Q-T1):
| Document | Conflict |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| root `MISSION-MANIFEST.md` (2026-07-14) | makes federated-tier the "canonical MVP deployment topology", Federation v1 top-priority — vs D3 (deferred) and D15 (compose standalone canonical) |
| `federation/MISSION-MANIFEST.md` (2026-04-21) | Federation v1 as active in-progress M3 — vs D3 and ROADMAP P5 "deliberately undesigned" |
| `guides/deployment.md` | blocks Compose activation pending KBN-101 gates — vs D15's `docker compose up` v1 bar |
| `scratchpads/mvp-20260312.md` | records a _completed_ Federation M2 milestone (peer certs, grants, ScopeService, Step-CA) — vs D3's "deferred" framing |
Code reality, verified 2026-08-31 at `9aa4983c` (dossier: lane
`FEDERATION-DOSSIER-2026-08-31.md`): federation M1M3 are shipped and wired
behind a `tier === 'federated'` gate — M3 landed 2026-06-24/25, beyond what any
doc records — M4M7 absent, dormant since 2026-06-25, absent from the canonical
`docker-compose.yml` (so code topology is consistent with D15), and tracked
nowhere since the TASKS.md → NORTH_STAR.yaml supersession. The three stale docs'
claims date to 2026-04 by true content edits. **Ruled B ("shipped but
frozen"), Jason 2026-09-01** — see [[GOV.5-open-questions]] Q-T1 for the
amendment consequences the E6 return carries.
**Superseded set** (all with explicit in-file or index-level signals): root
`TASKS.md`, `fleet/TASKS.md`, `federation/TASKS.md` (→ `fleet/NORTH_STAR.yaml`);
`fleet/PRD.md` and `fleet/PRD-fleet-suite.md` (→ root `PRD.md`, per
`fleet/README.md`); `native-kanban-sot` initial NO-GO review (→ GO re-review).
`plans/`, `reports/`, `scratchpads/` are working-notes/evidence, never spec —
consistent with their own README disclaimers.
**Orphaned decisions found in the archive sweep** (ratified once, absent from
D1D15 and every live doc; disposition on the grill, Q-T2):
1. "No Python" monorepo ruling (`archive/planning/monorepo-consolidation/board-review.md:742`).
2. Matrix/MACP "exactly three supported modes" install-topology ruling, Mode A
split-domain primary; its DNS/domain prerequisite ruling still open
(`archive/planning/matrix-macp/rfc-002:133`, `rfc-001:428`).
3. OpenBrain cut from WP1/WP2 consolidation scope (`board-review.md:611`).
## Corpora 2 + 3 — `~/.mosaic/docs/` — conclusions
The overwhelming majority is **operator-only**: generic engineering standards,
fleet role playbooks, SDLC gates, ops pages, fleet Q&A rulings, incident
methods, host-specific plans. Correct home is the brain; nothing migrates.
Notable verdicts:
- `docs/PRD.md`**trap confirmed** (N1): it is the pi `/goal` extension PRD.
Superseded in substance by [[GOV.4-workstream-contracts]] §Pi Persistent Goal
Loop (#1150).
- `docs/guides/proposed/workflows/prd-lifecycle.md` — superseded by
[[GOV.1-prd-lifecycle]] (the draft lifecycle this bundle ratified).
- `docs/plans/2026-08-25_unified-roadmap.md` (T72 consolidation charter) —
superseded by this rev1 consolidation, its successor.
- `docs/MOSAIC-CANON.md` vs `docs/STRUCTURE-CANON.md` — mutual conflict: both
claim to be "the canonical definition of a mosaic-brain" (653 vs 127 lines,
divergent sections); STRUCTURE-CANON is the copy everything links to.
Operator-side ruling needed (Q-T3).
- Dead: `plans/2026-08-22_config-json-schema.md` (delivered as stack#1382), two
closed questions.
**Migration candidates** — nine working-notes whose product content should feed
rev1 sections during refinement (full list with rationale in the lane evidence
record): `specs/2026-08-29_mosaic-config-minimal-subset.md` → CLI;
`specs/2026-08-28_intended-state-reconciler.md` → DATA;
`operations/seat-identity.md` → SEAT; `operations/vault.md` → AUTHN;
`runtime/adapter-contract.md` → HARN; `SPECIALIZATION-MODEL.md` → ROLE;
`workflows/session-lifecycle.md` → SESS; `operations/prd-registry.md` → GOV;
`workflows/configuration-lifecycle.md` → CLI/GOV.
`COORDINATION-CONTROL-PLANE.md` and `workflows/coordination-lifecycle.md` are
product-adjacent but belong to the `agent-runtime-ng` lane's L1/L2 scope, not
this bundle (corpus-4 boundary).
## Corpus 4 — the two lanes
Canonical for their scope by definition (they are the drafting record).
`agent-runtime-ng` owns L1/L2 contract text; `control-plane-surfaces` owns
surface/config findings and this bundle. One-way dependency: surfaces cite
contracts, never the reverse.
## Naming defects register (mandate item 4)
| # | Defect | Fix proposed |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| N1 | `~/.mosaic/docs/PRD.md` is the pi goal-extension PRD wearing the project-PRD name (confirmed 2026-08-31) | rename to a goal-extension-scoped name during return |
| N2 | Stack local `main` is a divergent unpushed fork that shadows `next` | already a lane convention; PRD states trunk identity explicitly |
| N3 | Pre-2026-08-26 pattern: `docs/PRD.md` overwritten per milestone | retired by [[GOV.1-prd-lifecycle]] shim model |
| N4 | Two decision registries share the D-prefix ID space (stack D1D15 vs operator D01D65); "D8" is ambiguous without registry name | OD- prefix applied in rev1 (Q-G2 ruled 2026-09-01); collision rule in [[GOV.3-decision-map]] |
| N5 | Triple "PRD" collision in the stack tree: root `PRD.md` vs superseded `fleet/PRD.md` and `fleet/PRD-fleet-suite.md`, with no local supersession signal on the latter two | supersession banner in-file; long-term, the GOV.1 rule that the bare name `PRD.md` is reserved for the shim |
| N6 | "Tess" and "Ultron" each name two different things: non-authoritative roster-class display aliases (fleet how-tos) vs the named product agent / validator identity (TESS workstream, native-kanban-sot) | rev1 text always qualifies which sense is meant; flag for upstream rename of the aliases |
| N7 | Six stack `TASKS.md` files under three authority regimes (banner-superseded / explicitly-not-superseded / silently active) — the filename signals nothing | uniform status frontmatter on every TASKS.md; superseded ones point at NORTH_STAR.yaml |
| N8 | Duplicate basenames across stack dirs: `gateway-security-20260313.md` (qa vs code-review, different content), `2026-08-10-docs-catalog-audit.md` (plan vs report), `1099-pipefail-sweep.md` (report vs scratchpad copy) | disambiguate on next touch; prune the unpromoted scratchpad copy |
| N9 | `guides/` is outside the canonical tree per `docs/README.md` yet "protected current authority" per `SITEMAP.md` — contract and sitemap disagree | reconcile the documentation contract; likely fold the four guides into the guide trees |
| N10 | Two live front-matter schemas (`type`/`status: current…` per docs/README.md vs the newer `kind`/`status: active…` used by most files) — collision documented in the w4 worklist, unresolved | settle the schema in the documentation contract as part of E6 return |
| N11 | Three uncross-referenced descriptions of the `/goal` capability: brain `docs/PRD.md`, `operations/goals.md`, and GOV.4 §#1150 | reconcile under the #1150 identity; brain docs cite it |
| N12 | Brain-side: `MOSAIC-CANON.md` vs `STRUCTURE-CANON.md` both claim canon status | operator ruling (Q-T3); retire or fold the unreferenced copy |
| N13 | Forward-looking: rev0 Part II (RI-N3) rules `docs/PRD.md` "not a peer authority" once `docs/prdy/` lands — a third contender in "which PRD is real" | GOV.1 disambiguation: prdy is tooling-facing storage; the shim + bundle remain the human-facing SOT chain |
| N14 | Commit `a480ee83` (2026-08-21) mass-stamped `status: active` frontmatter across stack docs without content review — status metadata rubber-stamps stale docs as current (root MISSION-MANIFEST's "Last Updated: 2026-07-14" is likewise cosmetic; true content edit 2026-04-19) | status/date frontmatter changes only alongside content review; triage dates by git content edits, never frontmatter |
@@ -1,121 +0,0 @@
---
id: GOV.3
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# GOV.3 — Consolidated decision map
Every ratified decision set that binds this PRD, in one place, with the
collisions between their numbering spaces made explicit. This section exists
because the estate carried at least four independent decision registries whose
IDs overlap — a reader seeing "D8" could not know which law was meant.
## The registries
| Registry | IDs | Ratified | Where | Scope |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| Stack PRD registry | **D1D15** | 2026-08-25/30 | rev0 §12 → [[GOV.3-decision-map]] (this file, below) | product north star |
| Operator decision register | **OD-01OD-65** (renamed from D01D65 per Q-G2, 2026-09-01; the brain-side source doc renames on its next touch and carries a redirect table) | 2026-08-28 (Q1Q92 review) | the operator DECISION-REGISTER (estate brain `docs/guides/proposed/DECISION-REGISTER.md`, snapshot 2026-08-28, sha256 `2cc81be1…aabec`; operator-only corpus, not shipped) | roles, coordination, PRD lifecycle, configuration, checkpoints |
| L2 authorization decisions | **L2-D01L2-D51** (+ proposed **L2-D52**) | rolling | `fleet/lanes/agent-runtime-ng/MECHANICAL-AGENT-RUNTIME-L2-AUTHORIZATION.md` | mechanical agent-runtime authorization |
| Control-plane rulings | **J1J5** | 2026-08-23 | `fleet/lanes/docs/mosaic-control-plane/rulings-J1-J5.md` | record-class authority |
| PRD structural rulings | (unnumbered, 8 rulings) | 2026-08-31 | [[PRD.0-index]] §Structural rulings | this bundle's lifecycle |
**Collision rule:** zero-padded `D01`-form IDs = operator register; bare `D1`-form
= stack PRD registry; `L2-D` = L2; `J` = control-plane rulings. Writing a bare
"D8"-style reference without its registry name is a defect (naming register
[[GOV.2-docs-inventory]] N4).
## Stack PRD registry D1D15 (carried from rev0 §12)
| ID | Decision (short form) |
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred. **Amended 2026-09-01 (Q-T1 ruling B, "shipped but frozen")**: federation M1M3 exist in code behind `tier === 'federated'` (M3 landed 2026-06-24/25), are excluded from the v1 bar and frozen; tracked as a dormant workstream in `docs/fleet/NORTH_STAR.yaml`; the frozen cert/auth code carries a security re-audit gate before any resumption; the design itself stays deferred and unforeclosed |
| D4 | Re-runnable, extensible, per-mode onboarding wizards |
| D5 | North star = docs/PRD.md rewrite; stack docs/ = product SSOT |
| D6 | Only product-relevant material migrates from brains; operational records stay and link |
| D7 | Spec-inventory sweep (executed; T2 baseline frozen 2026-08-25) |
| D8 | webUI sits over official framework tooling; CLI primary |
| D9 | Not a hosted business; company = organizational separation for one operator |
| D10 | better-auth is the account system of record; external IdPs via OIDC |
| D11 | Small v1 slice; ALL phases on the documented roadmap from day one |
| D12 | HARD RULE: webUI never bypasses tooling; missing tool ⇒ build the tool first |
| D13 | workspace_id stays the hard isolation unit; kanban SOT amended, not rewritten |
| D14 | Sensitive profile data in the user's own brain only |
| D15 | Tiered containerized deployment: compose standalone + phase-gated k8s |
Full texts: rev0 §12 and the operator decision log (USC estate brain,
webui-audit lane, `GRILL.md`).
## Operator register decisions this PRD leans on hardest
Full set: the operator DECISION-REGISTER (estate brain `docs/guides/proposed/DECISION-REGISTER.md`, snapshot 2026-08-28, sha256 `2cc81be1…aabec`; operator-only corpus, not shipped). Load-bearing here:
| ID | Ruling (short) | Consumed by |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| OD-02/OD-03 | one role per seat; role change = clean session, ephemeral context discarded | [[SEAT.1-seat-profile]], [[SESS.1-session-continuity]] |
| OD-08/OD-09 | coordinator service owns leases/deployment; orchestrators never deploy seats directly | [[AUTHZ.1-capability-authority]] |
| OD-16OD-23 | PRD owns requirements; immutable accepted versions; `docs/PRD.md` = generated pointer under `docs/PRDs/`; missions pin PRD version+digest; `mosaic prdy` owns PRD creation | [[GOV.1-prd-lifecycle]] — **independently re-derived in the 2026-08-31 grill before this register was consulted; the two agree** |
| OD-48 | instance contract: `profile.json` structured identity, `overlay.json` generated composition | [[SEAT.1-seat-profile]] |
| OD-49OD-53 | `mosaic config` desired-state engine; blueprint + host-binding split; precedence chain; **all interfaces (CLI/TUI/WebUI/API) share one CLI-backed engine** | [[DATA.1-record-authority]], [[CLI.1-parity]], [[UI.1-webui-surfaces]] |
| OD-54 | WebUI drafts are revisioned server-side desired-state; no effect until planned and applied | [[UI.1-webui-surfaces]] |
| OD-57OD-61 | checkpoints tied to incarnation+lease; coordinator-run relaunch (checkpoint→stop→apply→clean incarnation→restore); fencing; full restart recovery | [[SESS.1-session-continuity]] — **this is the ratified mechanism for mid-stream harness/model/provider switching** |
| OD-62OD-65 | watchdog, outage fail-closed, failure isolation/reporting | [[AUTHZ.1-capability-authority]], [[UI.1-webui-surfaces]] (audit/alerts) |
## Reconciliation notes
- Register OD-13 (repository-backed mission state canonical first, DB later behind
the same interface) and J1 (Git owns governance, PostgreSQL owns runtime
state) are compatible: OD-13 governs _mission_ state migration order; J1 governs
steady-state record classes. [[DATA.1-record-authority]] carries the merged
table.
- Register OD-18's "generated pointer" is stricter than the 2026-08-31 grill's
hand-maintained shim: **adopted** — the shim should be generated by tooling,
not hand-edited ([[GOV.1-prd-lifecycle]] inherits this).
- Proposed, not yet ratified: **L2-D52** (least-privilege Assignment issuance),
staged at `proposed/docs/MECHANICAL-AGENT-RUNTIME-L2-AUTHORIZATION--least-privilege-issuance.md`.
## Extraction cross-check notes (2026-08-31)
- Five highly product-normative operator drafts carry **no decision-register
citations at all** (seat-identity, vault, adapter-contract,
SPECIALIZATION-MODEL, prd-registry). Their rules were pulled into sections on
their merits; before E6 they must be cross-checked against the register
rather than assumed pre-vetted.
- The intended-state-reconciler spec uses a **file-local D1D6 numbering** that
is neither the stack registry nor the operator register — a live instance of
the N4 prefix-collision defect. Do not conflate when compiling
cross-references.
- The session-lifecycle draft is the densest register consumer (OD-03/OD-04/OD-08,
OD-56OD-65) and is likely the canonical drafting source for OD-56OD-65; its
one-relaunch-path gap is Q-S4.
## Re-ratified orphaned decisions (Q-T2, Jason 2026-09-01)
Ratified once in archived planning docs, absent from every live document until
this map; re-ratified as live constraints:
- **No Python in the monorepo** (source:
`archive/planning/monorepo-consolidation/board-review.md:742`).
- **Matrix/MACP: exactly three supported install modes, Mode A (split-domain)
primary** (source: `archive/planning/matrix-macp/rfc-002:133`). Its
DNS/domain prerequisite ruling remains open — [[GOV.5-open-questions]] Q-T6
blocks Matrix install work, not this map.
- **OpenBrain excluded from WP1/WP2 consolidation scope** (source:
`board-review.md:611`).
## Registry prefix ruling (Q-G2, Jason 2026-09-01)
Distinct prefixes at source: stack keeps **D1D15**; the operator
DECISION-REGISTER renames to **OD-01…OD-65** with a redirect table in the
source doc. Applied in this bundle: every stack-side citation of the operator
register now reads **OD-nn**; the brain-side source doc itself still carries
its old zero-padded `D01``D65` numbering and renames (with the redirect
table) on its next touch. File-local D-numbering in drafts (the live N4
instance: the reconciler spec's D1D6) is prohibited — every decision doc
declares a unique registry prefix. For any text predating 2026-09-01 not yet
swept into this bundle, the old reading rule still applies: a zero-padded bare
`Dnn` is the operator register (now read as `OD-nn`); a bare `Dn`/`Dnn` in the
115 range without a zero pad is the stack registry.
@@ -1,671 +0,0 @@
---
id: GOV.4
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# GOV.4 — Active workstream contracts (preserved unchanged)
Carried verbatim from rev0 ([rev0 PRD](../2026-08-26_PRD_rev0/PRD.md) lines
258910) under rev0's own rule: open issues bind to these contracts; this
revision moves no text and changes no requirement in them. They graduate out
individually when their workstreams close.
The sections below are normative, in-flight workstream contracts carried over
verbatim from the previous revision of this file. Open issues bind to them.
This rewrite moved no text and changed no requirement in them; they are
governed by their own issues and review gates, and they graduate out of this
file individually when their workstreams close.
## Current addendum: #1194 — Installed framework-tool drift detection
- Compare the framework tools shipped with the executing Mosaic package against the deployed `$MOSAIC_HOME/tools` tree by content hash.
- Treat every shipped `tools/**` file as framework-owned/required according to `framework-manifest.txt`, while excluding the explicit operator-owned credential carve-out and preserving installed-only operator/unknown files.
- Distinguish and count `IN_SYNC`, `STALE`, `NOT_INSTALLED`, and installed-only classifications; fail non-zero when shipped tools are stale or absent and refuse self-comparison that would make drift unobservable.
- Surface the observational check through `mosaic doctor`; do not refresh files, restart seats, or mutate live tooling.
- Document identity/messaging/gate behavior changes in the current stale set, the reviewed quiet-window keep-mode refresh command, and post-refresh probes against the installed path.
- Prove by construction that a stale and missing deployed tool are detected; that regression must fail before this checker exists.
## Compaction Refresh Trust Lifecycle (M1, #827#830)
### Problem and objective
Context compaction, session replacement, and same-PID runtime reloads can leave a previously VERIFIED runtime lease attached to stale directives. M1 must revoke that authority mechanically for Claude (including Claudex) and Pi without trusting caller-asserted identity or forking the external broker state machine.
### Requirements
1. `CR-REQ-01`: Claude `PreCompact` and `SessionStart` with matcher `compact`, plus Pi `session_before_compact` and the first post-`session_compact` `context`, SHALL independently revoke the active broker lease.
2. `CR-REQ-02`: Runtime generation increases—including same-PID Pi reload/new/resume/fork and Claude resume/clear—SHALL monotonically replace the prior broker incarnation and inherit no VERIFIED lease.
3. `CR-REQ-03`: A fired observer that cannot confirm broker revocation SHALL fail closed through lifecycle cancellation, a private local generation fence, and/or a runtime-local tool latch. The existing all-tools broker gate remains authoritative.
4. `CR-REQ-04`: The lease TTL SHALL remain monotonic and capped at 300 seconds. If both observers are missed, within-TTL consequential actions remain allowed and after-TTL actions are denied. This named bounded residual stale window SHALL be documented without claiming a mutator-action bound inside the window.
5. `CR-REQ-05`: Hook descendants SHALL use the broker-minted session and owner-only current-generation state inherited from register-before-exec. Caller-minted sessions and parallel lease state machines remain forbidden.
### Acceptance criteria
1. `AC-CR-01`: Real-socket tests prove each Claude observer revokes, Pi lifecycle tests prove both observer paths, and Claudex isolated settings preserve and install the mandatory hooks.
2. `AC-CR-02`: A same-PID generation test proves the old generation is stale and the replacement generation is UNVERIFIED across reload/resume/fork-equivalent lifecycle events.
3. `AC-CR-03`: RED-first T12b/T30 evidence explicitly reports dual-hook miss within TTL as **ALLOWED** and after TTL as **DENIED**.
4. `AC-CR-04`: Attributable executable coverage is at least 85%, the full repository suite is green on deterministic main, and independent code/security review completes before merge.
---
## Pi Persistent Goal Loop (#1150)
### Problem and objective
A Pi agent can stop after a plausible-looking answer even when the operator's broader objective is
not complete, and ordinary compaction can weaken or omit the original objective. Mosaic needs an
optional, operator-controlled goal loop that keeps a Pi session oriented, checks progress at native
lifecycle boundaries, and resumes work until completion is verified or a bounded safety state is
reached.
The objective is a Mosaic-owned Pi extension deployed from the framework into
`~/.config/mosaic/runtime/pi/`. It must not install into or depend on `~/.pi/agent/extensions/`.
### Scope
#### In scope
1. `PGL-REQ-01`: The framework SHALL ship a dedicated Pi goal extension under
`packages/mosaic/framework/runtime/pi/`, seed it under `$MOSAIC_HOME/runtime/pi/`, and make
`mosaic pi` load it alongside the core Mosaic extension when present.
2. `PGL-REQ-02`: `/goal` SHALL support setting a goal plus status, pause, resume, cancel, and help
operations without silently replacing an active goal.
3. `PGL-REQ-03`: Active branch-specific goal state SHALL be persisted in Pi custom session entries,
restored on session start and tree navigation, and never rely on a compaction summary as its
source of truth.
4. `PGL-REQ-04`: A hidden goal contract SHALL be injected through Pi's `context` event before every
model request so it remains effective across tool turns, retries, and post-compaction requests.
5. `PGL-REQ-05`: The harness SHALL inspect every `turn_end` and successful `session_compact` event.
A structured terminating goal-report tool SHALL capture `continue`, evidence-bearing `achieved`,
or `blocked` status without requiring a redundant model turn.
6. `PGL-REQ-06`: An achievement claim SHALL remain provisional until a second consecutive
evidence-bearing verification report. Any continuation report or successful compaction during
verification SHALL reset the verification sequence.
7. `PGL-REQ-07`: Continuation SHALL be initiated at safe lifecycle boundaries, primarily
`agent_settled`; manual compaction and restored active sessions may schedule a deferred idle
continuation without re-entering compaction handlers.
8. `PGL-REQ-08`: The loop SHALL have operator cancellation plus bounded turn and repeated-no-progress
limits. Exhausted or blocked goals pause rather than continuing indefinitely.
9. `PGL-REQ-09`: Framework installation and update SHALL preserve normal manifest ownership: the
goal extension is framework-owned under `runtime/**`, while no goal extension or configuration
asset is created or modified under the operator's main Pi configuration. Pi remains the owner of
its native session files used by `appendEntry()`.
#### Out of scope
1. A mathematical guarantee that an arbitrary natural-language goal is semantically complete.
2. Automatically executing user-supplied shell predicates or accepting executable validation code in
`/goal` arguments.
3. Restarting Pi after process, host, or supervisor failure; the existing Mosaic fleet/runtime
supervisor owns process durability.
4. Gateway, database, web UI, Discord, or cross-harness goal orchestration in this slice.
### User and stakeholder requirements
- An operator can start a goal from Pi and see its current phase, evidence, limits, and latest report.
- The agent remains oriented after each turn and compaction until verified, paused, blocked,
exhausted, or cancelled.
- Local testing uses a file under `~/.config/mosaic/runtime/pi/`; the feature never writes an
extension asset to `~/.pi/agent/extensions/`.
- Framework updates deploy the same reviewed extension source through Mosaic's existing manifest
sync path.
### Non-functional requirements
1. **Safety:** bounded continuation, explicit cancellation, no arbitrary command execution, and no
completion without non-empty reported evidence.
2. **Reliability:** serialized continuation scheduling, branch-aware restoration, compaction-safe
context injection, and stale-timer cancellation on session shutdown.
3. **Performance:** no extra nested judge-model request on every turn; structured reporting uses the
active agent's final terminating tool call.
4. **Observability:** Pi status/notifications expose phase and bounded counters without recording
credentials or hidden model reasoning.
5. **Maintainability:** the state machine is deterministic and behavior-tested independently from Pi
provider/network access.
### Acceptance criteria
1. `AC-PGL-01`: A framework-sync fixture installs the extension at
`$MOSAIC_HOME/runtime/pi/goal-extension.ts`, and launcher tests prove both Mosaic Pi extensions are
emitted in deterministic order while absent optional files remain backward-compatible.
2. `AC-PGL-02`: Command tests prove set/status/pause/resume/cancel behavior, active-goal replacement
refusal, and bounded input handling.
3. `AC-PGL-03`: Lifecycle tests prove every turn is recorded, active context is injected on every
request, two evidence-bearing achievement reports are required, and `agent_settled` continues an
unmet goal without duplicate scheduling.
4. `AC-PGL-04`: Compaction and restoration tests prove goal state survives, verification is reset and
rechecked after compaction, manual compaction continuation is deferred until idle, and tree/session
branch state is reconstructed correctly.
5. `AC-PGL-05`: Limit tests prove max-turn and repeated-no-progress exhaustion stop autonomous
continuation, while pause/cancel/blocked states do not restart.
6. `AC-PGL-06`: Focused tests, package typecheck/lint/test, repository quality gates, a local Pi load
smoke test from `~/.config/mosaic/runtime/pi/`, independent review, and terminal-green CI pass before
issue #1150 closes.
### Constraints, risks, and assumptions
- Dependency: Pi's extension API must continue to provide `registerCommand`, `registerTool`,
`context`, `turn_end`, `agent_settled`, `session_compact`, session custom entries, and terminating
tool results.
- Risk: the working agent can overstate completion. Mitigation: structured evidence, a mandatory
second verification pass, explicit semantic limitations, and operator-visible reports.
- Risk: an impossible goal can consume unbounded resources. Mitigation: hard turn/no-progress bounds
and paused terminal states.
- Risk: automatic continuation can race compaction or session replacement. Mitigation: drive from
`agent_settled`, defer idle restarts, generation-check timers, and clear timers on shutdown.
- `ASSUMPTION:` Two consecutive evidence-bearing reports are the initial local verification policy;
rationale: it provides a real recheck without doubling every turn's model cost. Future policy may
add independent or deterministic validators.
- `ASSUMPTION:` Default limits are 40 turns and 6 repeated no-progress reports, configurable only by
bounded Mosaic environment settings; rationale: useful persistence with a finite autonomous budget.
- `ASSUMPTION:` Documentation remains canonical in-repo for this slice; no external docs publication
is requested.
### Testing and delivery intent
Use TDD for the deterministic controller and lifecycle invariants. Test with fake Pi lifecycle
objects first, then run a local load/smoke test from the deployed Mosaic path. Deliver source, tests,
launcher wiring, framework/runtime documentation, user/developer guides, and sitemap updates in one
reviewed squash PR to `main` with terminal-green CI.
---
## Fleet Declarative Configuration Management Workstream (FCM, #758)
### Problem and objective
The local Mosaic fleet has a roster, generated agent environment files, user-systemd units, tmux
sessions, heartbeat files, examples, profiles, and separate gateway-backed agent records. These
planes have drifted and are not one safe operator lifecycle. The objective is one **local fleet
roster** as the desired-state SSOT, with generated environment, systemd, tmux, and heartbeat
artifacts as rebuildable projections; it does not merge the local fleet control plane with the
gateway-backed agent catalog.
### Normative requirements
| ID | Requirement |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
| `FCM-REQ-09` | M1M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](../../fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](../../fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
### Acceptance criteria
1. `AC-FCM-01`: A valid local v2 roster can be parsed from YAML or JSON, validated structurally and semantically through the shared resolver, and rendered canonically; invalid fields, duplicate names, unresolved classes, unsupported runtime/model combinations, socket ambiguity, and incompatible options fail closed.
2. `AC-FCM-02`: `plan` reports deterministic desired-versus-observed differences for roster, generated environment, systemd enablement, tmux/session, heartbeat, installed-asset revision, and provable orphans without mutation; `apply --check` reports drift without mutation.
3. `AC-FCM-03`: Local create/update/delete is generation-guarded, atomic, idempotent, and safe by default; it permits supported runtime/model/harness/effort/workdir/role changes without direct editing of generated environment files and does not start a newly created agent unless explicitly persisted.
4. `AC-FCM-04`: The generated-env/local-override launch chain rejects generated-key shadowing, arbitrary command override, unknown keys, shell evaluation, and sensitive-value diagnostics before any agent starts; known-safe legacy input is regenerated or strictly relocated, and forbidden input is quarantined.
5. `AC-FCM-05`: Local lifecycle reconciliation implements the persisted/transient start-stop rules, exact default/named tmux socket targeting, systemd/tmux status, stale generated state, unmanaged-session reporting, and rollback without surprise restarts or fuzzy destructive targeting.
6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory.
7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers.
8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes.
### M0 implementation gate
No source, schema, role, example, profile, systemd, or live-fleet change is authorized before M0
lands. M0 consists only of these normative requirements, the complete task DAG, the scoped
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
are defined in [docs/TASKS.md](../../TASKS.md) and must remain one card/one PR.
### Fleet git identity launch propagation (#1043)
#### Problem and objective
A fleet seat can have a registered per-agent Git credential while its launched runtime process lacks
`MOSAIC_GIT_IDENTITY`. The credential resolver then cannot select the seat identity reliably, which
blocks repository operations on fail-closed estates and can fall through to an unrelated identity on
estates where that refusal is not active. The objective is to make Git identity a deterministic,
roster-derived part of the generated launch projection and prove it reaches the launched process.
#### Normative requirements
1. `FGI-REQ-01`: Every generated fleet agent projection SHALL declare
`MOSAIC_GIT_IDENTITY=<MOSAIC_AGENT_NAME>`; a differing or unsafe identity SHALL fail closed before
tmux launch.
2. `FGI-REQ-02`: The clean `/usr/bin/env -i` pane boundary SHALL pass every variable declared by the
generated projection, including `MOSAIC_GIT_IDENTITY`, to the launched runtime process.
3. `FGI-REQ-03`: A behavioral integration test SHALL set-compare the complete generated projection
against the launched process environment. Source-text/string-presence assertions are insufficient.
4. `FGI-REQ-04`: Verification SHALL include RED-first evidence and a delete-the-subject mutation that
removes Git-identity pane propagation and makes the behavioral test fail.
#### Acceptance criteria
1. `AC-FGI-01`: A launched seat process contains every key/value pair declared by its generated
environment projection, including the roster-derived Git identity.
2. `AC-FGI-02`: Missing, unsafe, or split Git identity is rejected before a tmux session is created.
3. `AC-FGI-03`: Focused launcher and generated-environment tests, repository quality gates,
independent review, and the required RED/green/R7 evidence are recorded before push.
### Framework shell assertion portability (#1098)
#### Problem and objective
The blocking framework-shell chain can report that a pane command omitted `/usr/bin/env -i` even when
`-i` matched successfully. A short-circuiting `grep -q` under `set -o pipefail` may close its pipe after
the match and cause an upstream producer to exit with SIGPIPE, turning a valid semantic result into a
nonzero aggregate pipeline. The objective is to inspect the captured NUL-delimited argv directly and
make failures carry the observed records needed for diagnosis.
#### Normative requirements
1. `FSP-REQ-01`: The pane-boundary test SHALL validate an adjacent `/usr/bin/env`, `-i` argv pair from
the authoritative NUL-delimited tmux capture without a short-circuit pipeline whose upstream status
can override a successful match.
2. `FSP-REQ-02`: Missing, reversed, or non-adjacent boundary tokens SHALL fail, while valid boundaries
SHALL remain valid regardless of trailing argv size, pipe capacity, process scheduling, or host/CI
utility implementation.
3. `FSP-REQ-03`: A failed boundary check SHALL print stable indexed, shell-escaped observed argv records
before exiting nonzero; the fixture SHALL continue to contain generated non-secret launch data only.
4. `FSP-REQ-04`: Verification SHALL include RED-first large-payload evidence, negative token-order
controls, the complete focused launcher suite, canonical Woodpecker CI, and independent review.
#### Acceptance criteria
1. `AC-FSP-01`: A large captured argv with adjacent `/usr/bin/env`, `-i` passes even when the former
`grep -q` pipeline returns nonzero from an upstream SIGPIPE.
2. `AC-FSP-02`: Missing executable, missing flag, and detached/reversed flag fixtures return nonzero and
emit the indexed observed argv.
3. `AC-FSP-03`: The focused suite passes on the development host and CI image, and the merged-main
Woodpecker pipeline is terminal green before #1098 closes.
---
## Exact Cross-Harness Fleet Communications Contract (#766)
### Problem and objective
Fleet runtime contracts currently combine exact peer rows with generic operational metavariables and
independently parsed roster data. Non-Claude harnesses can mistake those metavariables for values to
infer, producing incorrect host, session, socket, or helper targets. The objective is one
roster-resolved communications contract that every supported harness receives unchanged.
### Normative requirements
1. `FCOM-REQ-01`: Fleet commands and runtime composition SHALL use one shared v1 roster structural
resolver. A second lenient communications parser is forbidden.
2. `FCOM-REQ-02`: The composed contract SHALL render the local roster member's authoritative host,
exact agent/session name, resolved tmux socket, exact helper path, and deterministic communications
generation.
3. `FCOM-REQ-03`: Every known peer SHALL have one exact executable command. Same-host commands SHALL
omit `-H`; cross-host commands SHALL use only that peer's explicit roster `ssh` target; the one
supported fleet-wide named socket SHALL use `-L` with its exact value. A per-agent socket declaration
must equal that fleet-wide value; unsupported independent sockets and missing cross-host SSH data SHALL
fail closed.
4. `FCOM-REQ-04`: Operational fleet examples SHALL not contain unresolved host, session, socket, or
helper-path metavariables. Agents SHALL select an exact rendered peer row and SHALL NOT infer,
substitute, or fuzzy-match targeting values.
5. `FCOM-REQ-05`: An unknown local member or requested peer SHALL fail closed with exact-name discovery
guidance. Runtime composition SHALL not silently omit a requested fleet member's communications
contract.
6. `FCOM-REQ-06`: Claude Code, Codex, OpenCode, and Pi SHALL receive equivalent authoritative
communications data through the common runtime composer.
7. `FCOM-REQ-07`: Tests SHALL prove the contract from framework-source `TOOLS.md`, through a fresh
installed `TOOLS.md`, to final runtime composition and helper executability. User-owned installed
`TOOLS.md` content SHALL remain preserved.
8. `FCOM-REQ-08`: Stale installed or active composed context SHALL be reported with deterministic
generation/repair/relaunch guidance. Currency requires the expected source and installed contract
marker/version plus bounded byte equality. The supported current-version repair SHALL run independently
of package updates, preserve divergent `TOOLS.md` bytes in a digest-qualified no-clobber backup, restore
a regular executable helper without following symlinks, and be idempotent. Detection and reporting SHALL
NOT rewrite active context, restart a session, or mutate a live fleet.
9. `FCOM-REQ-09`: The shared resolver SHALL preserve and strictly validate every schema-supported v1
connector kind (`tmux`, `discord`, and `matrix`) from YAML and JSON. Every accepted snake/camel alias
pair SHALL reject differing dual declarations and accept identical declarations. JSON roster fallback
SHALL occur only when `roster.yaml` is absent; all other YAML access failures SHALL fail closed.
10. `FCOM-REQ-10`: The communications generation SHALL cover the complete canonical rendered semantic
contract, including identity, role/class, resolved host/socket/helper, peer metadata, and exact commands.
Installed helpers SHALL be validated with no-follow filesystem inspection as regular executable files.
Keep-mode reseed and relaunch discovery SHALL preserve and support both YAML and JSON rosters.
### Acceptance criteria
1. `AC-FCOM-01`: Contract fixtures contain no unresolved operational targeting metavariables; local
identity contains exact host/session/socket/helper values.
2. `AC-FCOM-02`: Same-host, cross-host, named-socket, literal-default-socket, and missing-SSH tests prove
exact targeting and fail-closed behavior.
3. `AC-FCOM-03`: Unknown identities and peers report known exact names plus an exact self-scoped
discovery command; no fuzzy session selection is emitted.
4. `AC-FCOM-04`: Four-harness tests prove byte-equal authoritative communications sections.
5. `AC-FCOM-05`: Source, fresh-install, preserved-custom-install, stale-installed, composed-generation,
helper executable, agent-send socket isolation, and exact-target tests pass.
6. `AC-FCOM-06`: Documentation defines non-mutating stale-context detection and operator-authorized,
exact-agent relaunch; no implementation path performs automatic session mutation.
7. `AC-FCOM-07`: YAML and JSON fixtures cover every connector kind; all snake/camel aliases cover
identical acceptance and conflicting rejection; non-`ENOENT` YAML failures do not fall back.
8. `AC-FCOM-08`: Missing, directory, symlink, and non-executable installed helpers fail closed. Explicit
current-version repair proves partial-deletion recovery, digest-qualified backup collision safety,
symlink-target safety, and repeated-run idempotence.
9. `AC-FCOM-09`: Markerless-equal and wrong-version source/installed contracts are stale, and a rendered
role/class change produces a different communications generation.
---
## KBN-101 Database Runtime/Migration Role Split (#771)
### Problem and objective
PostgreSQL Gateway/storage currently uses one `DATABASE_URL` for runtime queries and migrations. That makes the deployed application identity an owner and prevents certification that KBN immutable event, artifact, checkpoint, and evidence relations reject runtime `UPDATE`/`DELETE`. KBN-101 freezes a least-privilege runtime/migration split before KBN-100 schema work.
### Normative requirements
1. `K101-REQ-01`: `DATABASE_URL` SHALL be the non-owner PostgreSQL runtime connection and `DATABASE_MIGRATION_URL` SHALL be the migration-only owner/migrator connection. They are required respectively for runtime and the dedicated `mosaic-db-migrator --run|--verify` phase in `standalone`/`federated`; local PGlite is the explicit exception. The published `@mosaicstack/db` bin maps exactly `mosaic-db-migrator` to `./dist/cli.js`, its image entrypoint is exactly `mosaic-db-migrator`, accepts no URL/SQL/schema/role argv, and returns stable sanitized exits. Every current/future PostgreSQL DDL entrypoint SHALL route to that runner or be denied, and SHALL reject `DATABASE_URL`-only execution before connection/DDL. Data migration may connect only after the runner prepares and verifies the PostgreSQL target, through dedicated non-DDL `mosaic_data_importer` and exactly `--target-url-file /run/secrets/mosaic-migrate-target-url`, its fixed paired authenticated provider-version file `/run/secrets/mosaic-migrate-target-version`, plus `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`. KBN-101-05 obtains URL key `url` and version only from the same successful Vault KV-v2 response at `secret-{env}/mosaic-stack/database/importer` (`data.metadata.version`), renders them as one immutable generation into separate consumer copies, and never infers a provider version from DSN bytes. The trusted runner verifies TLS/identity/manifest, reads its fixed importer URL/version copies only for binding through safe no-follow fd checks, and signs a credential-free JCS/Ed25519 attestation using its runner-only fixed root-owned private-key file; no signing key reaches importer/runtime. The artifact binds secret version and SHA-256 of exact high-entropy credential-file bytes, canonical TLS host/port/database, CA/SPKI, PostgreSQL system identifier/database OID, importer role, manifest/schema fingerprints, producer invocation/build/image digest, issued/expires/nonce, and correlation. Before target connection the importer validates URL/version/attestation/public-key files, signature/key/expiry/replay/authenticated provider version/digest/generation/bindings and the importer-only CA at exact `DATABASE_TLS_CA_CERT_PATH`; after verified TLS and before DML it validates server/database/role/CA/schema identity, with same-fd/in-memory-byte TOCTOU protection, rotation/revocation, a privileged producer-only-to-importer-only artifact handoff controller that verifies/copies/fsyncs/atomically renames/seals before importer start, consumer isolation/no logging-oracle, and sanitized errors. Raw `--target-url`, `DATABASE_URL` fallback, runtime-owner use, missing/unsafe/substituted files, stale/replayed/tampered/wrong-key attestation, wrong binding, and DDL attempt fail before target connection/DDL; post-connect mismatch closes with zero DML/DDL. A reviewed finite classifier inventories executable current source/scripts/package bins, operator docs, deploy manifests, and exact normative contracts by path; active secure records pin both options/files, producer/key/bindings/tests, while normative contracts cannot mask instructions. Unknown active commands, duplicate-owner, ownerless, missing-path, and historical/status-only masking hits fail. `db:push` is forbidden outside an explicitly disposable local developer database and cannot accept a production-like URL.
2. `K101-REQ-02`: Gateway runtime/replicas SHALL not execute migrations or DDL. The runner SHALL hold one `max:1` session and fixed two-int advisory namespace `1297044289` (`MOSA`), `1262636593` (`KBN1`) across preflight, reconciliation, migration, verification, and release. It SHALL compare the versioned canonical manifest v1 tuple (journal logical index/tag plus exact SQL-byte SHA-256) to the complete observed ledger mapping; count/set-only, timestamps, and physical insertion order are non-normative and insufficient.
3. `K101-REQ-03`: PostgreSQL SHALL separate non-login platform database owner, non-login schema owner, dedicated `NOLOGIN SUPERUSER` `mosaic_extension_owner`, login migrator, dedicated login non-DDL data importer, non-login runtime capability, and login runtime roles. For PostgreSQL 17 + pgvector 0.8.2, `vector` is untrusted (`trusted` is absent and `relocatable=true`): only an externally controlled audited platform-bootstrap superuser session may `SET ROLE mosaic_extension_owner` for CREATE/UPDATE/SET SCHEMA, then `RESET ROLE`; the role has `rolcanlogin=false`, `rolsuper=true`, zero members, no runtime credential/Vault secret, and is never provided to app containers. It owns `mosaic_extensions`, fresh `vector`, and owner-bearing extension members, while `mosaic_schema_owner` receives only `USAGE` for type resolution and never ownership/`CREATE`/`ALTER`/`DROP`/member-change/default-privilege authority there. Superuser cannot be constrained by `GRANT`/`REVOKE`; this is identity/non-login/no-membership/external-control/audit isolation, not a false least-privilege claim. Extension operations require control-plane change, independent review, backup/rollback, maintenance window, and audit evidence. Managed targets that cannot establish this exact role are ineligible until an independently approved versioned provider-owned extension-owner profile exists; app/migrator ownership is never silently retained. Existing approved-owner extension relocation validates exact `pg_namespace.nspowner`, `pg_extension.extowner`, member ownership/schema/version, while legacy runtime-owned extension fails closed to a controlled shadow-database migration—never unsupported ownership alteration, catalog mutation, ownership adoption, or `DROP CASCADE`. Runtime, migrator, schema owner, importer, and all service roles must fail `SET ROLE`, catalog/direct `ALTER`/`UPDATE`/`DROP`/membership-change denial, role ownership, superuser/role-creation/schema-creation/TEMPORARY, unsafe membership, untrusted search path, missing grants, unauthenticated TLS, and immutable privilege drift checks. Application schema is fixed `mosaic` with exact `pg_catalog,mosaic` session path; historical public migrations remain byte-immutable legacy bootstrap only, every future Drizzle application declaration targets `mosaic`, and `vector` is explicitly qualified from non-writable `mosaic_extensions`. No config-derived SQL identifier is permitted.
4. `K101-REQ-04`: `mosaicstack/stack` KBN-101-00 SHALL exclusively own `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, and bootstrap tests; KBN-101-05 SHALL exclusively own `tools/db/render-postgres-secrets.ts`, its tests, and current Compose/Portainer/two-gateway deployment declarations, consuming the versioned bootstrap interface without overlap. Environment IaC/Vault is named input and Mosaic deployment control plane/Jason is activation authority. Distinct runtime/migrator/importer URL, importer authenticated provider-version, DB-client CA, Gateway leaf, and PostgreSQL server key/certificate materials are provisioned before a production-like database starts. Importer and migrator have separate immutable URL/version copies at fixed `10002:10002`/`10003:10003` identities; runtime/unrelated containers receive neither importer material, attestation private key, or importer artifact. Runtime, migrator, and importer require their mounted CA plus `sslmode=verify-full`. Exact UID/GID/mode/rendering, service-DNS SANs, Vault/compose/Swarm consumer isolation, two-gateway pair ordering, server activation, pre-enforcement legacy-client drain and `hostssl` zero-plaintext-session proof, fresh/existing transition, CA-overlap rotation, TLS-only rollback, and standalone/federated/Swarm/two-gateway positive/negative TLS evidence are required. No application-generated production certificate or plaintext bootstrap exception is permitted.
5. `K101-REQ-05`: KBN immutable relations SHALL permit the real runtime role INSERT/SELECT only and deny UPDATE/DELETE; parent retention remains RESTRICT/no-cascade. Role/password/Vault creation is external platform control, never application migration/source.
6. `K101-REQ-06`: N-1 single-URL compatibility, rollout/rollback, Vault ownership/rotation/redaction, CI, installer, compose/Portainer, observability, and deployment handoffs SHALL be separately bounded one-card/one-PR work. Prepared slices remain inactive while current owner-runtime deployments stay N-1; Mosaic control plane/Jason alone authorizes one final atomic activation or rollback, with no force-on-red/bypass. KBN-101 planning itself SHALL not mutate production.
7. `K101-REQ-07`: KBN-100 SHALL begin only after the KBN-101 foundation role/schema-boundary certificate; it SHALL rebase on that main head, restore generated Drizzle declaration/snapshot/journal consistency, and bound procedural immutable-table grant/trigger/backfill additions to its schema slice. KBN-101 real deployed-role immutable-operation certification SHALL complete after KBN-100 creates those relations and before KBN-105.
### Acceptance criteria
1. `AC-K101-01`: DTO/command-matrix tests prove required modes, PGlite exception, `mosaic-db-migrator --help|--run|--verify`/stable exits/argv refusal, public-import negative, every finite classified DDL/static-bypass inventory path and both harness pairs reject `DATABASE_URL`-only before connection/DDL, no migration-to-runtime fallback, and `db:push` refusal outside an allowlisted disposable DB. Before inventory, ownership, or status masking, the semantic fixture fails README's exact former commented code-fence generic-wrapper form and the user guide's exact former executable generic-wrapper form; source-consistency proves current `packages/storage/src/cli.ts` directly `execSync`s `pnpm --filter @mosaicstack/db db:migrate` and no `mosaic-db-migrator` bin exists, so runner-delegation documentation fails. The active `docs/guides/migrate-tier.md` route is inventoried to KBN-101-07 and proves runner-produced `--target-url-file /run/secrets/mosaic-migrate-target-url`, fixed paired provider-version file, and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`; runner-only signing/private-key isolation; Vault KV-v2 same-response version provenance, separate immutable generation mounts, importer CA, JCS/Ed25519 signature/key rotation/revocation, atomic artifact, expiry/replay, safe-fd secret-version/digest, canonical TLS/CA/server/database/role/manifest/schema bindings, dedicated non-DDL importer, consumer isolation/no log-oracle, and exact no-connection versus zero-DML rejection for missing/wrong/stale/replayed/tampered/wrong-key/substituted/generation-mismatched inputs. The full current non-normative docs inventory—including user guide, federation historical task/MILESTONES status, and non-operative SETUP—has an exact safe disposition. Scanner semantic checks reject automatic first-boot/startup extension/schema/migration wording, Compose-up-before-runner, init-script authority, production `.env`/monorepo auto-load/`EnvironmentFile=`/credential-export-or-argv/restart-as-secret-activation routes, and every unqualified operator-document `mosaic-db-migrator --run|--verify` hit regardless of named/normative/status classification. The exact former README/dev/deployment Compose-first sequences, former SETUP wording, exact former MILESTONES wording `pgvector extension installed + verified on startup`, former architecture-plan/PERFORMANCE/backlog runner routes, and any unqualified runner fixture fail before inventory masking. Only one `Held future procedure` Markdown section—bounded through the next equal-or-higher heading—may contain the explicit non-operative/no-current-command-authority form that names KBN-101-00/-03/-05 and preserves external bootstrap → TLS/roles → `mosaic-db-migrator --run``mosaic-db-migrator --verify` → Gateway/Compose readiness; every runner hit outside that section fails. The README assertion for the checked-in direct CI `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` passes only as active legacy N-1, uncertified, non-authorizing-as-an-operator-route status against an isolated disposable CI database pending KBN-101-06 removal—not as an ordinary operator or approved DDL-authority route. Only local PGlite data-layer work or non-PostgreSQL Compose is current (Gateway/Web local startup is held pending daemon/inherited/project-DSN rejection).
2. `AC-K101-02`: Fixed namespace lock contention/crash/readiness/non-interference and exact manifest-v1 reconciliation tests prove no replica race/runtime auto-migration and fail closed on every missing/unknown/duplicate/ambiguous/corrupt/stale ledger state.
3. `AC-K101-03`: Actual PostgreSQL 17 + pgvector 0.8.2 control-file, catalog, Drizzle-generation, vector-query/operator, fresh/approved-owner/legacy-shadow/partial/resume/rollback/N-1, and real deployed-role tests prove `trusted` absent/untrusted plus relocatability, external-superuser `SET ROLE` create/update/`RESET ROLE` audit, exact `rolcanlogin=false`/`rolsuper=true`/zero-membership/no-runtime-secret state, platform/schema/extension-owner/migrator/importer/runtime separation, `pg_extension.extowner` plus owner-bearing extension-member/schema/version assertions, and runtime/migrator/schema-owner/importer/all-service-role `SET ROLE`/ALTER/DROP/member-update denial. They also prove `pg_catalog,mosaic` per-session pool safety, `mosaic_extensions` qualification, identifier injection denial, ownership/membership/ledger-read/TEMP/default grants, and unsafe privilege denial.
4. `AC-K101-04`: Disposable standalone, federated/Swarm, and two-gateway verified-TLS positives plus for both pairs missing CA/wrong CA/wrong SAN/sslmode downgrade, server/Gateway key mode, UID/GID, secret-consumer isolation, and legacy-drain/`hostssl` negatives prove server bootstrap, ordering, and readiness; PGlite is expressly excluded from this PostgreSQL evidence.
5. `AC-K101-05`: Real runtime-role evidence proves INSERT/SELECT succeeds and UPDATE/DELETE fails for every frozen immutable KBN relation.
6. `AC-K101-06`: N-1/atomic activation/rollback, Vault/CA-overlap rotation/redaction, health/operator behavior, CI/deployment handoff, independent exact-head security review, and terminal-green CI evidence the foundation before KBN-100; after KBN-100, the real deployed-role immutable-operation certificate and Ultron approval release KBN-105.
**Normative implementation contract:** [`docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md`](../../native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md). `ASSUMPTION:` existing `standalone` and `federated` are all PostgreSQL production-like modes; any new PostgreSQL tier inherits these requirements until an explicit versioned amendment.
---
## Tess Interaction Agent Workstream (TESS)
### Problem and Objective
Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent.
The objective is to ship **Tess** (from _tessera_, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency.
### Scope
#### In Scope
1. `TESS-ARP-001`: A runtime-neutral `AgentRuntimeProvider` contract supporting `listSessions`, `streamSession`, `sendMessage`, `terminate`, `getSessionTree`, `attach`, health, capability discovery, and normalized events/errors.
2. `TESS-PI-001`: A long-running Pi-native Tess agent profile/service pinned to GPT-5.6 Sol with high reasoning, explicit tool policy, lifecycle hooks, durable checkpoints, and restart recovery.
3. `TESS-DSC-001`: Dedicated Discord channel binding to Tess through the Mosaic gateway, with allowlists/RBAC, thread/reply policy, streaming, attachments, approvals, and correlation IDs.
4. `TESS-CLI-001`: `mosaic tess` CLI commands for chat, status, session listing, attach/detach, send/steer/stop, provider health, and recovery.
5. `TESS-FLT-001`: Fleet plugin capabilities for roster/status/heartbeat inspection, message delivery, session hierarchy, safe attach, and controlled restart/recovery.
6. `TESS-MOS-001`: Explicit Mos coordination boundary and tools: hand off orchestration requests, observe mission/task state, receive results, and never silently compete for orchestration authority.
7. `TESS-HRM-001`: Transitional Hermes adapter for profiles/agents, sessions, streaming/messages, Kanban, skills, memory, tools, cron, and health, using capability negotiation and fail-closed unsupported operations.
8. `TESS-MEM-001`: Unified memory/retrieval plugin with scoped search/recent/capture/stats, startup context injection, provenance, redaction, namespace isolation, and flat-file/project truth precedence.
9. `TESS-STA-001`: Durable agent state, inbox, handoff, compaction-recovery, and resume reconstruction.
10. `TESS-PLG-001`: Plugin/tool catalog covering runtime bootstrap, repository/PR workflow, fleet diagnostics, incident-safe read operations, Discord interaction, and extensible MCP/skill discovery.
11. `TESS-TRN-001`: Replaceable transport providers: tmux/fleet now, Matrix/native Mosaic transport later, with no Discord/CLI business logic coupled to transport details.
12. `TESS-SEC-001`: RBAC, per-operation authorization, explicit approval for destructive/privileged/customer-visible actions, audit events, secret/PII redaction, tenant isolation, and bounded command execution.
13. `TESS-SEC-002`: Command execution SHALL enforce declared scope/role server-side; admin/system and destructive operations SHALL require policy-bound durable approval.
14. `TESS-SEC-003`: Every session list/read/attach/send/terminate operation SHALL enforce server-derived owner and tenant scope; guessed or client-supplied IDs SHALL grant no authority.
15. `TESS-SEC-004`: MCP tools SHALL derive actor/tenant from authenticated context and SHALL NOT accept caller-controlled identity fields.
16. `TESS-SEC-005`: Discord plugin ingress SHALL authenticate service identity, enforce guild/channel/user allowlists, propagate correlation/message IDs, and reject replay.
17. `TESS-SEC-006`: Secret/PII classification and redaction SHALL occur before persistence and before channel egress, including tool metadata and authentication flows.
18. `TESS-SEC-007`: Approvals SHALL be one-time, expiring, actor/tenant-bound, and cryptographically bound to the exact structured action digest.
19. `TESS-SEC-008`: Ingress, provider sends, tool side effects, and responses SHALL use durable inbox/outbox/checkpoints and idempotency records for restart-safe replay.
20. `TESS-SEC-009`: Garbage collection and retention SHALL be session/tenant scoped unless executed as a separately authorized and audited system-wide job.
21. `TESS-OBS-001`: Structured logs, traces, health/readiness, provider latency/errors, session lifecycle, tool audit, and actionable recovery diagnostics.
22. `TESS-MIG-001`: Capability inventory and staged Hermes-to-Mosaic migration matrix with coexistence, cutover, rollback, and deprecation gates.
#### Out of Scope
1. Replacing Mos as coding/general fleet orchestrator.
2. Making Hermes the Mosaic core or coupling Mosaic domain logic to Hermes schemas.
3. Migrating every historical chat verbatim; only policy-compliant indexed summaries and user-selected sessions are migrated.
4. Unrestricted shell execution from Discord.
5. Full web UI parity in the first Tess operational milestone; gateway contracts must remain web-consumable.
6. Replacing tmux before Matrix/native transport reaches operational parity.
### Stakeholder and User Requirements
- Jason must be able to converse with the same Tess session from Discord and CLI.
- Jason must be able to see what is running, stale, blocked, or unhealthy without attaching manually to every session.
- Jason must be able to attach to Tess and authorized fleet sessions through supported CLI controls.
- Tess must collaborate with Mos and the fleet while preserving a single clear orchestration authority.
- The system must migrate useful Hermes/OpenClaw capabilities intentionally, with evidence, instead of copying implementations wholesale.
### Non-Functional Requirements
1. **Security:** default-deny provider/tool capabilities, least privilege, no secrets in logs/prompts/commits, Discord user/channel authorization, and auditable approvals.
2. **Reliability:** durable inbox/checkpoints; idempotent message handling; reconnect with bounded backoff; no message loss or duplicate execution across gateway restart.
3. **Performance:** first acknowledgement within 2 seconds when connected; streamed agent output begins within 5 seconds excluding model/provider delay; status reads return within 2 seconds under nominal local conditions.
4. **Observability:** every ingress message and resulting provider/tool operation carries a correlation ID across Discord, gateway, Tess, provider, and audit events.
5. **Maintainability:** channel, runtime, transport, memory, and external-agent integrations remain adapter-based with contract tests.
6. **Privacy:** only scoped context enters external runtimes; persisted messages/memories follow retention and redaction policy.
7. **Portability:** Tess runs through Pi/Mosaic contracts and does not require Hermes to start or serve native Mosaic operations.
### Acceptance Criteria
1. `AC-TESS-01`: A dedicated Discord channel and `mosaic tess chat` connect to one durable Tess session and stream responses bidirectionally.
2. `AC-TESS-02`: `mosaic tess status|sessions|tree|attach|send|stop` operate against authorized provider capabilities with stable typed outputs and actionable errors.
3. `AC-TESS-03`: Tess runs GPT-5.6 Sol at high reasoning and its effective runtime/model/tool policy is visible through status without exposing credentials.
4. `AC-TESS-04`: Tess can inspect and message the Mosaic fleet, hand orchestration work to Mos, and demonstrate that Tess does not independently claim Mos-owned orchestration work.
5. `AC-TESS-05`: Hermes adapter demonstrates session listing, streaming/message delivery, hierarchy mapping, and at least one approved capability in each of Kanban, skills, memory, tools, and cron—or reports unsupported capabilities fail-closed.
6. `AC-TESS-06`: Restart/compaction test preserves session identity, pending inbox, last durable checkpoint, and a resumable handoff without duplicate side effects.
7. `AC-TESS-07`: Unauthorized Discord users/channels, cross-tenant access, unsafe tool calls, forged approvals, and sensitive-output cases are denied and audited.
8. `AC-TESS-08`: tmux/fleet and Matrix/native transport implementations pass the same provider contract suite; Matrix may remain non-default until readiness gates pass.
9. `AC-TESS-09`: Baseline quality gates, unit/integration/contract tests, Discord+CLI E2E, restart/recovery tests, independent code review, and security review are green.
10. `AC-TESS-10`: Migration matrix documents every audited Hermes/OpenClaw capability as native, adapted, deferred, or rejected, with cutover and rollback evidence.
11. `AC-TESS-11`: User, admin, developer, API/OpenAPI, operations/recovery, and plugin-authoring documentation is current and linked from the sitemap.
### Constraints, Dependencies, Risks, and Assumptions
- Dependency: Mosaic gateway remains the single API surface; Pi is the native runtime; Valkey/PostgreSQL provide canonical durable state where required.
- Dependency: Discord bot credentials and dedicated channel ID are deployment secrets provisioned outside source control.
- Risk: Tess could drift into a second orchestrator. Mitigation: explicit role policy, Mos handoff contract, authority checks, and E2E boundary tests.
- Risk: broad Hermes compatibility can freeze legacy semantics into Mosaic. Mitigation: Mosaic-owned normalized contracts and capability negotiation.
- Risk: Discord creates a privileged remote-control surface. Mitigation: pairing/allowlists, RBAC, approvals, rate limits, audit, and safe tool classes.
- Risk: transcript ingestion can violate privacy or overload memory. Mitigation: scoped opt-in import, redacted summaries, provenance, retention, and deduplication.
- Risk: current root filesystem has limited headroom. Mitigation: isolated worktrees, no duplicated dependency installation unless required, and cleanup only after active-lane verification.
- `ASSUMPTION:` The public name is **Tess**, because the user requested a name and the tessera/Mosaic relationship is distinctive; config must permit later display-name changes without renaming APIs or storage keys.
- `ASSUMPTION:` The dedicated Discord channel ID and final guild policy will be supplied/provisioned during deployment, so implementation uses explicit configuration and fail-fast startup validation.
- `ASSUMPTION:` tmux/fleet is the production transport for the first operational milestone; Matrix/native transport is implemented behind the same contract and promoted only after parity/reliability verification.
- `ASSUMPTION:` Project/task truth remains in canonical Mosaic/project stores; semantic memory systems are retrieval/mirror layers, not hidden authorities.
### Testing and Delivery Intent
Delivery uses five gated milestones: runtime contracts/security; Pi service/state; Discord/CLI; fleet/Hermes/plugin suite; migration/Matrix/recovery/qualification. Every source-code task requires tests, independent review, a PR to `main`, terminal-green CI, and issue/task closure. Production activation additionally requires a clean-host Pi launch, dedicated Discord channel smoke test, CLI attach test, restart/recovery drill, and rollback procedure.
---
## Official Channel Plugin Workstream (#756)
### Problem and Objective
The Discord plugin currently couples Discord event handling, gateway bridging, and reply routing in one implementation and activates only on mentions. Mosaic needs an official channel adapter that behaves the same no matter whether the bound logical agent currently runs through Claude, Codex, Pi, OpenCode, or a future harness. The Discord connection and conversation address must remain stable while the gateway changes the runtime provider behind that logical session.
The objective is to make Discord the first implementation of a transport-neutral official channel contract, with explicit authorization and deterministic channel/thread routing that future Matrix, Slack, and other adapters can share.
### Scope
#### In Scope
1. `CHN-001`: Transport-neutral channel adapter, route, message, attachment, authorization-principal, response-target, and health contracts in `@mosaicstack/types`, including trusted per-binding logical-agent configuration selection.
2. `CHN-002`: Stable channel conversation addresses based on logical agent plus channel/thread identity; harness, model, and runtime-provider IDs are forbidden from channel session keys.
3. `DSC-001`: An authorized untagged message in a configured agent-bound channel routes to the agent and receives its response in that channel.
4. `DSC-002`: A bot mention in a configured parent channel creates a Discord thread, or reuses the thread already attached to that same native message; the mentioned turn and subsequent thread turns route and respond in that thread.
5. `DSC-003`: A message already inside an authorized thread inherits authorization from its configured parent and never attempts a nested thread.
6. `DSC-004`: Guild, parent channel, user, pairing, and role authorization remains default-deny before thread creation or gateway dispatch.
7. `DSC-005`: Discord service authentication, HMAC envelope integrity, replay protection, attachments, approvals, response chunking, and correlation behavior remain intact.
8. `DSC-006`: The Discord adapter exposes lifecycle and health behavior through the shared channel contract without importing a harness SDK.
#### Out of Scope
1. The logical-agent lease, fencing epoch, execution grant, checkpoint, or cross-harness takeover implementation tracked by #754/#755.
2. Dynamic Discord authorization administration in the web UI.
3. Multi-guild tenant isolation, DMs, slash commands, voice, reactions, or production bot deployment.
4. Implementing Matrix or Slack adapters in this slice.
### Non-Functional Requirements
1. **Security:** no thread or dispatch side effect occurs until guild, parent channel, user, pairing, role, and bounded per-user/channel rate checks pass; attachment metadata is shape- and size-bounded; credentials never enter source, messages, session keys, or logs.
2. **Portability:** channel contracts and stable conversation IDs contain no Claude, Codex, Pi, OpenCode, model, process, or provider-specific field; each configuration-owned binding selects its trusted logical agent without changing the channel identity.
3. **Reliability:** repeated messages for one channel/thread resolve the same conversation handle; reconnecting the adapter does not require a harness-specific rebinding.
4. **Maintainability:** Discord-specific API translation stays in the Discord package; gateway and future adapters depend on transport-neutral contracts.
5. **Observability:** thread creation or routing failure is reported without message content or credential material.
### Acceptance Criteria
1. `AC-CHN-01`: Contract and behavior tests prove the plugin route contains only logical agent plus channel/thread identity and produces the same stable conversation handle regardless of underlying harness selection.
2. `AC-CHN-02`: A mentioned authorized parent-channel message creates a thread (or reuses its already-attached thread), dispatches to the thread conversation, and targets the response to that thread.
3. `AC-CHN-03`: An untagged authorized parent-channel message dispatches to the parent conversation and targets the response to the parent channel.
4. `AC-CHN-04`: Untagged follow-ups inside an authorized thread dispatch and respond in that same thread without creating a nested thread.
5. `AC-CHN-05`: Unauthorized guilds, channels, users, unpaired users, insufficient roles, and rate-limited senders produce no thread and no gateway dispatch.
6. `AC-CHN-06`: Shared channel contracts are exported from `@mosaicstack/types`, Discord implements the lifecycle/health seam, and no harness SDK is imported by the plugin.
7. `AC-CHN-07`: Focused routing/auth tests, package tests, typecheck, lint, formatting, coverage, independent code/security review, and terminal-green CI pass.
### Constraints, Risks, and Assumptions
- Dependency: Mosaic gateway remains the policy, durable-session, audit, and runtime-provider boundary.
- Constraint: This work must not modify orchestrator-to-Pi migration or #754/#755 lease/fencing files.
- Risk: accepting untagged messages could create noisy or unintended agent input. Mitigation: only explicitly configured channels and paired, role-authorized users are accepted, with bounded per-user/channel message and thread rates.
- Risk: Discord thread creation can fail because of channel permissions, archived state, or API rate limits. Mitigation: fail without dispatching a turn whose response destination cannot be honored, and emit sanitized diagnostics.
- `ASSUMPTION:` Configured channels are dedicated agent interaction surfaces, so authorized untagged human messages are intentional agent input.
- `ASSUMPTION:` Mention in a parent channel selects a public thread; messages already in a thread remain there because Discord has no nested threads.
- `ASSUMPTION:` One Discord bot may serve multiple configuration-owned logical-agent bindings.
- `ASSUMPTION:` Static allowlists and paired-user roles are the authorization administration surface for this slice.
### Testing and Delivery Intent
Use TDD for remote-ingress routing and permission boundaries. Required evidence includes parent-channel mention, untagged parent message, existing-thread follow-up, existing-thread mention, thread reuse, unauthorized side-effect denial, stable harness-neutral conversation identity, adapter health, and regression coverage for signed envelopes and approvals. Deliver through issue #756, a reviewed squash PR to `main`, terminal-green CI, and issue closure.
---
## Mos Runtime Portability Workstream (MOS-PORT)
### Problem and Objective
Mos is currently identified partly by a harness-native session and communication process. Replacement/rebinding exists, but no gateway-enforced logical identity or fencing prevents a stale harness from continuing to reply or execute effects after takeover.
The objective is to make Mos a server-derived logical Mosaic identity whose authority can move safely among runtime connectors. The gateway owns identity, lease, policy, and audit; harnesses remain replaceable adapters.
### M1 Requirements
1. `MOS-PORT-ID-001`: Define a normalized logical-agent identity independent of Claude Code, Pi, Codex, tmux, Matrix, and provider-native session IDs.
2. `MOS-PORT-LEASE-001`: Persist one exclusive connector lease per tenant/logical-agent/binding with CAS acquisition, monotonic fencing epoch, TTL, heartbeat, explicit release, and takeover.
3. `MOS-PORT-FENCE-001`: Bind every connector dispatch/execution grant to the current server-derived tenant, logical identity, binding, connector, scopes, expiry, and lease epoch.
4. `MOS-PORT-FENCE-002`: Reject and audit stale, expired, forged, cross-tenant, cross-binding, and unauthorized grants before connector, channel, provider, or tool side effects.
5. `MOS-PORT-OBS-001`: Emit credential-safe correlation/audit events for lease acquire, renew, takeover, reject, release, and expiry.
6. `MOS-PORT-ARCH-001`: Runtime/provider adapters consume normalized lease context without adding harness-native schemas to Mosaic core.
### M1 Acceptance Criteria
1. `AC-MOS-PORT-01`: Two contenders for one binding cannot simultaneously hold current authority under concurrency.
2. `AC-MOS-PORT-02`: Successful takeover increments the fencing epoch and every operation from the old epoch fails closed before side effects.
3. `AC-MOS-PORT-03`: Gateway/database restart preserves lease and epoch state; expired leases can be recovered only through the authorized takeover path.
4. `AC-MOS-PORT-04`: Cross-tenant, cross-agent, cross-binding, forged, and expired lease/grant cases are denied and audited.
5. `AC-MOS-PORT-05`: Unit, migration, repository close/reopen, concurrency, abuse, gateway integration, independent security review, CI, and documentation gates pass.
### Deferred to Later #754 Milestones
Canonical checkpoint/handoff payloads, exactly-once connector receipts, concrete Claude/Pi/Codex adapters, channel cutover, and full cross-harness failover/rollback E2E are explicitly out of M1 scope.
---
## Workspace placement guard hardening (#1174)
### Problem and objective
The Bash pre-tool guard must prevent Git checkouts and repository state from being placed under
`$HOME` without refusing ordinary Git commands merely because a source, option value, branch name,
or metadata mentions `$HOME`. A guard that over-blocks routine work is unsafe because operators
will route around it.
### Scope and requirements
1. `WPG-REQ-01`: `git clone` and `git worktree add` placement SHALL be judged from their placement
operands, not from every HOME-shaped word in the command.
2. `WPG-REQ-02`: Clone sources, references, templates, environment assignments, and non-placement
worktree metadata MAY resolve under HOME when all placement operands resolve elsewhere.
3. `WPG-REQ-03`: Both attached and separate-value `--separate-git-dir` forms SHALL remain placement
operands and SHALL be refused when they resolve under HOME.
4. `WPG-REQ-04`: Option classification SHALL account for Git's rule-generated boolean negations
without relying on an enumerable allowlist of flag spellings.
5. `WPG-REQ-05`: Quote removal, escapes, shell command boundaries, redirections, and end-of-options
handling SHALL preserve existing fail-closed checkout coverage.
6. `WPG-REQ-06`: Absolute placement aliases SHALL resolve shell-known HOME spellings, dot segments,
repeated separators, and existing symlink parents before the HOME boundary comparison.
7. Relative targets whose effective path depends on the shell cwd are out of scope and tracked by
#1197.
### Acceptance and verification
1. Git's own option parser accepts each tested flag, including generated `--no-*` forms, while the
guard allows a HOME-valued source with an explicit safe destination.
2. Equivalent clone and worktree fixtures cover rule-generated negations and remain discriminating
against the prior head where the defect existed.
3. Real HOME destinations and both `--separate-git-dir` forms remain blocked, including placements
after shell command boundaries.
4. The full hermetic guard suite, syntax/static checks, adversarial probes, independent review, and
terminal-green CI pass before merge.
5. Any option-classification residual is documented with its deliberate failure direction.
### Constraints, risks, and assumptions
- Security and usability are co-equal: neither a placement bypass nor routine over-block is an
acceptable repair.
- `ASSUMPTION:` The value-taking option surface exposed by the installed Git version is closed and
measurable through Git's own parser/help output; rationale: boolean flags are rule-generated,
while separate-value options have explicit grammar and must be classified as such.
- Risk: a future Git release may add a new value-taking placement option. Mitigation: document the
chosen residual direction and pin every currently supported placement option in behavior tests.
- Risk: a symlink can be replaced after pre-execution canonicalization. Mitigation: resolve every
existing parent physically and document the remaining inherent TOCTOU window; the worktree helper
remains the authoritative path-derivation mechanism, with atomic closure tracked by #1199.
---
## Release Integrity Workstream (RI, #1275)
### Problem and objective
At `next` 476db12b (review of 2026-08-17), publication from `next` is not bound to the full verification pipeline for the same commit: the publish pipeline's publish steps depend on `build` only, while ordinary push CI excludes `next`. Public Forge/MACP paths contain false-success placeholders: a stub executor that reports `completed` with exit zero, planning/remediation gates that execute literal `true`, a review gate that echoes an approving verdict, and a gate runner that treats empty commands and unimplemented CI-provider gates as passing. Shipping UI surfaces can render a failed fetch as an empty, healthy collection.
Objective: for alpha 0.0.50, the release cannot publish, report, or display work state that the repository has not actually verified. Decisions SDLC-D-033 through SDLC-D-038 (Jason, 2026-08-17) scope this floor; full decision text and required-behavior lists live in jarvis-brain `docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` and `data/decisions/mosaic-stack-sdlc-protocol.json`. This section restates only the normative requirements.
### Normative requirements
1. **RI-N1 Exact-commit publication verification (SDLC-D-034).** One canonical terminal verification command performs self-contained re-verification in the publish pipeline against the job's checked-out commit before any external publication effect. The command contains or invokes the complete mandatory verification set (semantic parity with the PR merge gate, including sanitization, upgrade-guard, typecheck, lint, format check, tests, and build); CI and publication do not maintain separate semantic checklists. Every publish step depends on the verification step in the executable pipeline DAG. Provider commit identity and `git rev-parse HEAD` must identify the same commit. Missing, skipped, cancelled, stale, or inconclusive checks fail closed. Documentation-only runs may skip publication but cannot bypass verification when a publication effect will occur. A negative control must prove that a broken check blocks every publish step.
2. **RI-N2 Fail-closed Forge/MACP with explicit simulation (SDLC-D-035).** Simulation requires explicit caller intent (e.g. `--simulate`) and produces a distinct typed `simulated` state that can never satisfy dependencies, acceptance criteria, gates, merge, or release. Normal execution exits nonzero with a typed capability failure when a required executor, reviewer, command, or CI provider is absent — no stub completion, no literal-`true` gates, no synthetic approvals, no empty-command passes. A manual gate with no automation enters a waiting state; it does not pass. Positive tests prove explicit simulation still works; negative controls prove simulation and every missing-provider case cannot advance lifecycle state.
3. **RI-N3 One transitional PRD authority (SDLC-D-036).** `@mosaicstack/prdy` structured storage under `docs/prdy/`, driven by `mosaic mission --plan`, is the authoritative PRD representation for the alpha. `mosaic prdy` either routes through the same application service or operates only as an explicit, named Markdown import/export adapter; `docs/PRD.md` is not a peer authority. `mission --plan` must persist the mission↔PRD linkage (mission id/version, PRD id/version, selected requirements). Markdown output is a generated view carrying source identity; editing it cannot mutate authority silently. Import is explicit, validated, and conflict-aware (proposed successor, never overwrite). Structural validity is separate from approval.
4. **RI-N4 One quality-rails evaluator (SDLC-D-037).** The TypeScript quality-rails package is the sole authoritative evaluator. A complete probe inventory maps every current TypeScript and shell check to one canonical check with disposition (preserve/strengthen/retire, each named). Effective shell enforcement probes are absorbed before their independent paths retire; expected-file presence alone is not parity. The evaluator returns typed results (`passed`/`failed`/`blocked`/`error`/`not-applicable`) with check version, subject, and reason; missing implementation, missing input, unknown check, process error, timeout, or malformed output can never become `passed` or an unqualified skip. Check definitions and policy are versioned and digested. Shell commands become thin adapters with no separate verdict logic. The canonical terminal verification command (RI-N1) invokes this evaluator rather than duplicating its logic. Contract, parity, and negative-control tests are required, plus independent review of probe equivalence.
5. **RI-N5 Consequence-aware stale UI (SDLC-D-038).** Mission Control distinguishes typed freshness states (`current`, `stale`, `partial`, `unknown`, `unavailable`) rather than inferring from empty arrays or null. A failed fetch never renders as an empty healthy collection. Last-known data may display for situational awareness only with source identity, version, and age visibly labeled; any derived completion/assurance/release verdict whose inputs are stale becomes `unknown`; all state-changing actions are disabled until fresh state loads and is revalidated. With no verified snapshot, surfaces show an explicit unavailable state. Cache corruption, cross-workspace data, schema mismatch, and version regression invalidate the snapshot. Tests cover the failure matrix (network, auth, malformed, partial, corruption, stale age, schema mismatch, recovery, stale-action rejection) with negative controls proving no case yields a current green verdict or enabled mutation.
### Acceptance criteria
- AC-RI-1: A push to `next` that fails any mandatory verification step publishes nothing (no npm package, no image), demonstrated by a checked-in negative control and by pipeline evidence on a real `next` publish run where the verification step is green and every publish step depends on it.
- AC-RI-2: With no executor/reviewer/CI provider wired, Forge and MACP normal runs exit nonzero with typed capability failures; with `--simulate`, runs complete but every result is typed `simulated` and cannot satisfy any gate, dependency, or completion state — proven by unit tests including negative controls.
- AC-RI-3: A PRD created or revised through either `mosaic mission --plan` or `mosaic prdy` resolves to one authority under `docs/prdy/` with stable identities and versions; the mission↔PRD linkage survives restart; a Markdown export is labeled as generated and cannot silently become a second writer; divergent legacy content blocks baseline claims until explicitly resolved — proven by contract tests.
- AC-RI-4: `quality-rails check` through any entry point (TS CLI, framework shell adapter) returns the same typed verdict for the same subject; the probe inventory names every legacy check's disposition; a deliberately broken probe fails closed — proven by contract/parity/negative-control tests and independent review of probe equivalence.
- AC-RI-5: No shipping surface renders a failed fetch as an empty healthy state; stale/partial/unavailable states are typed, labeled, and mutation-disabled — proven by the failure-matrix tests.
- AC-RI-6: All cards merged to `next` via squash PR with terminal-green CI; release evidence for 0.0.50 records commit, verification run, and published artifacts.
### 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.
## Graduation ruling (Q-G3, Jason 2026-09-01)
Graduation of a Part II workstream contract is a ratification act: Jason marks
it (grill or direct ruling). The graduated contract text archives inside the
then-current PRD revision bundle — this section gains a graduated-set record
per revision — keeping the frozen-bundle model intact.
@@ -1,171 +0,0 @@
---
id: GOV.5
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# GOV.5 — Open questions (the grill list)
Every question the corpus could not settle. This is the E5 ms-grill-me input;
ratification is blocked until this list is empty or every remaining row is
explicitly deferred with an owner. IDs are stable; answered questions get their
ruling recorded here and flow into the owning section.
**Frontier status (2026-09-01, grill rounds 38 complete): EMPTY.** Every row
is ruled, dissolved, or deferred-with-owner. The operator-side Q-T3 canon
coalescence closed in round 8 and was executed the same day (brain commit
`59d43270`). E6 ratification executed 2026-09-01; this list is frozen with the
bundle.
## Data model
- **Q-D1****RULED, Jason 2026-09-01: consolidate.** `launch.env` folds
into `profile.json` (one seat record). Precondition verified same day:
`mosaic-core/lib/loader.ts seatRole()` parses profile.json as a generic
record and reads only `role` — widened files tolerated by construction.
Migration staged as lane work (ledger B1).
- **Q-D2** — **RULED, Jason 2026-09-01: per-provider map + broker refs.**
`profile.json` carries a per-provider map whose values are credential-broker
references (`environment/service/component/secret-name`), never secrets —
one seat, N providers, zero secrets in the brain tree. Flows to
[[DATA.1-record-authority]] and [[AUTHN.1-auth-accounts]].
- **Q-D3****RULED, Jason 2026-09-01: the projection engine owns.** One
writer: the role-projection engine (`role apply` path). The launcher seeds
nothing itself — it invokes the projector; Pi and every harness get
regenerated files on each role/seat change; hand edits are drift, flagged by
`validate`. Consistent with L2-D19 + Q-T5. Unblocks the reconciliation
features (lane ledger B3 → D8).
- **Q-D4****RULED, Jason 2026-09-01: role ceiling + seat choice.** The
Role Revision defines the allowed model set (policy ceiling); the seat
records preferences within it; effective models = intersection, consistent
with the L2-D39 intersection chain. Flows to [[HARN.1-harness-config]] and
role-harness-config DESIGN Q2 (same ruling, both doors).
- **Q-D5****DEFERRED with owner (Jason 2026-09-01)**: the mutating
config-engine half gets its own ruling after v1 read-only `validate`/`plan`
ships; owner = the mosaic-config workstream. ([[CLI.1-parity]] §v1 subset)
## Sessions
- **Q-S1****DEFERRED with owner (Jason 2026-09-01)**: the
session-id ↔ incarnation-id contract is settled inside the session-lifecycle
draft before it lands (which now carries the Q-S4 two-path requirement);
owner = that draft's ratification. ([[SESS.1-session-continuity]])
- **Q-S2****RULED, Jason 2026-09-01: step-up re-auth required.** Role
rebinding is an authority-changing act: fresh principal re-authentication
≤10 minutes before the confirmation lands, matching the S2
identity-lifecycle linking precedent. Flows to [[SEAT.1-seat-profile]]
§role-binding and the UI.1 seat-page spec.
- **Q-S3****DEFERRED with owner (Jason 2026-09-01)**: the measurable
continuity-degradation bar is settled inside the session-lifecycle draft
before it lands; owner = that draft's ratification.
- **Q-S4****RULED, Jason 2026-09-01: two-path requirement ratified.** The
session-lifecycle draft may not land with one relaunch path. Role change →
clean-session path (new incarnation + fencing token, ephemeral context
discarded, OD-02/OD-03); harness/model/provider change → continuity path (same
Stack session id, checkpointed context restored, OD-57OD-61, no noticeable
degradation). Binding requirement on the draft, recorded in
[[SESS.1-session-continuity]] §state machine.
## Audit
- **Q-A1****RULED, Jason 2026-09-01: mechanical tooling.** The audit is
deterministic tooling over the grant/assignment record (witness-style, per
the S2 writer-coverage pattern); its output feeds the audit page read-only.
Agents may consume audit output but never produce the verdict ("prompt
adherence is not an enforcement mechanism"). Flows to
[[AUTHZ.1-capability-authority]] and [[UI.1-webui-surfaces]] §audit.
- **Q-A2****DISSOLVED by the Q-A1 ruling**: the auditor is code, audited by
ordinary review and CI witnesses, not a seat subject to misdirection.
- **Q-A3****DEFERRED with owner (Jason 2026-09-01)**: the computable
misdirection metric is designed inside the mechanical audit tooling ruled by
Q-A1; owner = the audit-tooling workstream (lane ledger C2).
## Surfaces
- **Q-N1****DEFERRED with owner (Jason 2026-09-01)**: technical
investigation of the in-browser OAuth flow (tmux-bridged terminal vs
server-side) runs before the auth page builds; owner = the auth-page
workstream (lane ledger D6). ([[AUTHN.1-auth-accounts]])
- **Q-C1****RULED, Jason 2026-09-01: CI witness in the stack repo.** The
parity matrix becomes a generated artifact with a drift-gate witness
(contract-9 pattern): CI regenerates the inventory from code and fails on
divergence from the committed matrix. Flows to [[CLI.1-parity]]; the
witness itself is E6-return follow-up work.
## Governance
- **Q-G1****RULED, Jason 2026-09-01: both ratified.** L2-D52
(least-privilege Assignment issuance, closes G1+G2) and the G6 WebUI
surface-scope fix applied per their return procedures after digest
re-verification; amendment files flipped to ratified; lane ledger A3/A4
closed.
- **Q-G2** — **RULED, Jason 2026-09-01: distinct prefixes at source.**
Stack keeps D1D15; the operator DECISION-REGISTER renames to **OD-01…OD-65**
with a redirect table at the source doc; file-local D-numbering in drafts is
prohibited going forward (each decision doc declares a unique registry
prefix, rule lands in [[GOV.1-prd-lifecycle]]). Applied at E6 return for
stack references; brain-side rename on next DECISION-REGISTER touch.
- **Q-G3** — **RULED, Jason 2026-09-01: Jason marks; archive in bundle.**
Graduation is a ratification act — Jason marks it (grill or direct ruling);
the graduated contract text archives inside the then-current PRD revision
bundle ([[GOV.4-workstream-contracts]] gains a graduated-set section),
keeping the frozen-bundle model intact.
## Triage-raised (E2 sweep, 2026-08-31)
- **Q-T1****RULED B, Jason 2026-09-01: "shipped but frozen."** Amend D3 to
acknowledge federation M1M3 exist (Step-CA, enrollment, grants, mTLS auth
guard, ScopeService, list/get/capabilities verbs; M3 landed 2026-06-24/25),
are excluded from the v1 bar, and are frozen; re-home tracking in
NORTH_STAR.yaml as a dormant workstream; frozen cert/auth code carries a
**security re-audit gate** before any resumption. Consequences at E6 return:
supersession/status banners on the three stale docs (root MISSION-MANIFEST,
federation/MISSION-MANIFEST, scratchpads/mvp-20260312), reconcile
guides/deployment.md with D15, NORTH_STAR.yaml dormant entry. The P5
scope ambiguity (governance-federation vs shipped mTLS-query federation)
stays open inside the future federation PRD, not rev1. Evidence: lane
`FEDERATION-DOSSIER-2026-08-31.md`.
- **Q-T2****RULED, Jason 2026-09-01: all three re-ratified** into the
rev1 decision map ([[GOV.3-decision-map]] §re-ratified orphans): "No Python"
in the monorepo; Matrix/MACP exactly-three-install-modes with Mode A
(split-domain) primary; OpenBrain excluded from consolidation scope. The
Matrix ruling's open DNS/domain prerequisite gets its own row (Q-T6).
- **Q-T3** — **RULED (partial), Jason 2026-09-01: coalesce under the
STRUCTURE-CANON name.** MOSAIC-CANON's more comprehensive content is
authoritative; `STRUCTURE-CANON.md` is the logical surviving document name;
the two coalesce into one. Conflict report delivered and all six
decision points ruled (grill round 8, 2026-09-01): per-seat credential
slots win; doc paths corrected to `fleet/auth/`/`fleet/memory/`;
ENTITY.md/README.md stay required with a 42-seat backfill task; merge
executed with the full reference sweep, MOSAIC-CANON reduced to a pointer
shim. Record: lane `CANON-COALESCENCE-2026-09-01.md`. Operator-side; not a
rev1 blocker.
- **Q-T4** — **RULED, Jason 2026-09-01, two parts.**
**(a) Two independent axes**: "Standalone/Enterprise" in the S2 corpus is a
multi-tenancy/isolation _mode_ (`platform_mode`, D3/D11); D15's "compose
standalone tier" is deployment _packaging_. Orthogonal. rev1 text always
says "standalone mode" vs "compose tier"; mode-conversion.md needs a
terminology note only, not a rewrite.
**(b) Own track, rev1 cites**: rev1 ratifies citing the nine contracts as
DRAFT successor material with status noted; each contract ratifies on its
own PR when its family lands. Extraction record: lane
`S2-EXTRACTION-2026-08-31.md`.
- **Q-T5****RULED, Jason 2026-09-01: scope to files.** Adopted wording:
"Generated settings _files_ are projections of the active Role Revision,
never authority (L2-D19). DB settings records written through audited
Gateway commands (`platform_mode`, `registration_mode`, `custody_config`,
`bootstrap.seed-company-name`, and their successors) are records of
authority like any other SOT row." No corpus conflict remains. Case law:
contract 1 §5.4, contract 8 §2.4, contract 9 §3.2.
- **Q-T6****DEFERRED with owner (Jason 2026-09-01)**: the Matrix/MACP
Mode A DNS/domain prerequisite (`archive/planning/matrix-macp/rfc-001:428`)
rules before any Matrix install work resumes; owner = whoever reopens
Matrix. Until then Mode A is primary-on-paper only.
## Deferred-by-scope (recorded, not blocking rev1)
- Federation design (D3 — roadmap placeholder; nothing in v1 may foreclose it).
_Q-T1 ruled B (2026-09-01): D3 to be amended — M1M3 acknowledged, frozen,
security re-audit gate before resumption; design itself stays deferred._
- OS/kernel-level seat sandboxing (explicit lane non-goal; role-lane
discipline, not process containment).
@@ -1,64 +0,0 @@
---
id: HARN.1
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# HARN.1 — Harness configuration
A harness is an installed agent runtime (claude, pi, codex, opencode, …).
Shared contracts speak capability language; harness commands, model IDs,
hooks, and settings live in runtime adapters (register OD-38).
## Harness configuration surface (WebUI page + CLI)
| Control | Notes |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| install harness | single button push; installer runs server-side through official tooling |
| enable / disable | disabled harnesses are not selectable on any seat page |
| available models | an **allowlist** a seat may select from — not a selection. Whether `enabledModels` is role policy or harness/seat preference is open: [[GOV.5-open-questions]] Q-H1 |
| reasoning level defaults | |
| provider | which provider(s) back this harness ([[PROV.1-providers]]) |
| linked auth accounts | which accounts may drive this harness ([[AUTHN.1-auth-accounts]]) |
Enable/disable and install are runtime state (Postgres-owned) projected into
whatever flat state the launcher needs ([[DATA.1-record-authority]]).
## Runtime adapter contract (pulled 2026-08-31 from adapter-contract draft)
Every harness adapter binds a required capability set or **fails closed**:
repository ops via wrapper capability, scoped file/command execution,
structured reasoning, shared-memory capture/search/recall, inter-seat
messaging/wake, checkpoint persistence + mechanical telemetry, a `mosaic coord`
client that cannot mutate Kanban state or deploy seats directly, and credential
resolution through the seat's own slot. Rules:
- An unavailable capability is a **named blocker**, never silent degradation.
**"Prompt adherence is not an enforcement mechanism"** — a harness that
cannot persist checkpoints, emit telemetry, or honor fencing does not run
workflows that need them.
- Each adapter publishes a capability→binding table (capability, binding
surface, config source, verification check) and proves its bindings at
session start; verification failure is a named blocker.
- Adapters bind capabilities but **never redefine role authority, delivery
policy, gate outcomes, or review independence** — a harness whose native
workflow conflicts with shared policy keeps the shared policy and records the
conflict as an adapter limitation.
## E2 inputs (triage 2026-08-31; see [[GOV.2-docs-inventory]])
**Canonical ground truth**: the `fleet/` book — `concepts/desired-vs-observed-state.md`
(roster-v2 sole writable authority), `concepts/generated-env-launch-chain.md` +
`reference/generated-env-boundary.md`, `reference/roster-v2-fields.md`,
`operations/reconcile-and-recover.md` (lock/generation semantics),
`NORTH_STAR.md`/`FLEET-DOCTRINE.md` (delivery-fleet north star, subordinate to
this PRD per rev0 §10).
**Pending pulls**: brain `docs/guides/proposed/runtime/adapter-contract.md`
(the register-OD-38 runtime-adapter capability contract this section cites).
## enabledModels ruling (Q-D4, Jason 2026-09-01)
The Role Revision defines the allowed model set — a policy ceiling. The seat
records model preferences within that set. Effective models = the
intersection, consistent with the L2-D39 authority-intersection chain. A seat
preference outside the role ceiling is refused, not silently clamped.
@@ -1,89 +0,0 @@
---
id: PRD.0
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# PRD.0 — Index: naming standard, domain registry, reading order
This file is the order authority for the PRD section documents. Lexical sort of
the directory is **not** authoritative; this index is.
## Naming standard (ratified 2026-08-31)
`<DOMAIN>.<n[.n[.n]]>-<kebab-slug>.md` — e.g. `AUTHN.1.1-oidc.md`
- **Domain code**: short uppercase code from the registry below. Codes are
append-only; a code is never reused or renamed.
- **Number**: hierarchical, dotted, **append-only at every level**. A new
subtopic under `AUTHN.1` takes the next free number (`AUTHN.1.3`). Nothing
ever renumbers; depth absorbs insertions. Added topics augment, never
reshuffle.
- **Slug**: kebab-case; names only what the number does not. The domain word is
never repeated in the slug (`AUTHN.1.1-oidc.md`, not
`AUTHN.1.1-authentication-oidc.md`).
- **Separators**: dots between number levels only; one hyphen between number
and slug; hyphens inside the slug. No underscores, no spaces.
- **ID in three places** that must agree: filename, frontmatter `id:`, H1.
Wikilinks use the basename, e.g. `[[AUTHN.1.1-oidc]]` (illustrative — no such section exists yet).
- **Flat directory**: hierarchy lives in the number, not nested folders.
## Domain registry (append-only)
| Code | Domain |
| ----- | ----------------------------------------------------------------------------------------------- |
| PRD | The PRD assembly itself: index, preamble, revision log |
| GOV | Governance: document lifecycle, decision registers, amendment process, ratification |
| VIS | Vision / north star: what the Stack is, premises, non-goals |
| AUTHZ | Authorization & enforcement: capabilities, role policy, mosaic-core, L2 contracts, gap register |
| AUTHN | Authentication: provider accounts, OAuth/API keys, renewal, deactivation, allowed harnesses |
| ROLE | Roles: manifests, role config surfaces, role/seat separation |
| SEAT | Seats: profiles, launch config, seat config surfaces, profile.json consolidation |
| HARN | Harnesses: install/enable, model availability, reasoning, linked auth |
| PROV | Providers: supported providers, local providers (Ollama, LM Studio), provider config |
| SESS | Sessions: Stack session identity, continuity, mid-stream harness/model/provider switching |
| UI | WebUI: pages, page-scope rules, interaction patterns, audit surfaces |
| CLI | mosaic CLI: command surface, CLI↔WebUI parity |
| DATA | Record classes & storage: J1 git/DB authority split, flat-file vs DB, reconciliation |
New domains append below this line with a dated note.
## Reading order
Order is by lifecycle of understanding, not by code:
1. [[PRD.0-index]] (this file)
2. [[GOV.1-prd-lifecycle]]
3. [[GOV.2-docs-inventory]]
4. [[GOV.3-decision-map]]
5. [[VIS.1-north-star]]
6. [[DATA.1-record-authority]]
7. [[AUTHZ.1-capability-authority]]
8. [[ROLE.1-role-governance]] → [[SEAT.1-seat-profile]] (separation is load-bearing; role before seat)
9. [[HARN.1-harness-config]] → [[PROV.1-providers]] → [[AUTHN.1-auth-accounts]]
10. [[SESS.1-session-continuity]]
11. [[UI.1-webui-surfaces]] → [[CLI.1-parity]] (surfaces last; they project everything above)
12. [[GOV.4-workstream-contracts]] (preserved contracts; bind after the model is understood)
13. [[GOV.5-open-questions]] (the grill list; ratification gate)
Pulled sources (inputs, never ratified): [rev0 PRD](../2026-08-26_PRD_rev0/PRD.md),
the operator DECISION-REGISTER (estate brain `docs/guides/proposed/DECISION-REGISTER.md`, snapshot 2026-08-28, sha256 `2cc81be1…aabec`; operator-only corpus, not shipped).
Sections are added to this list as they are authored; an unlisted file is a
defect.
## Structural rulings (2026-08-31, Jason)
- The finished PRD is the **project SOT**. `docs/PRD.md` in the stack repo
becomes a shim to the current dated revision. Missions reference the PRD,
never usurp it. See [[GOV.1-prd-lifecycle]].
- The entire PRD lives in the **stack repo** (mosaicstack/stack, trunk `next`).
Brain documents are operator-instance documents that cite it.
- This work is the **successor** to the 2026-08-26 "North Star" PRD on
`origin/next` (snapshot: [rev0 PRD](../2026-08-26_PRD_rev0/PRD.md)).
- Revisions **archive, never delete**. Each ratified revision is a frozen
bundle directory — `docs/PRDs/YYYY-MM-DD_PRD_revN/` holding the PRD, this
index, and every section doc as a set (layout: [[GOV.1-prd-lifecycle]]
§Revision bundles).
- Shim and revision immutability are **convention, not enforcement** (no hook
or CI guard yet).
-103
View File
@@ -1,103 +0,0 @@
---
kind: spec
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
succeeds: origin/next:docs/PRD.md (2026-08-26 North Star, commit 9aa4983c, sha256 60cc2f98697471850caa3440d79139d70f67eda585a2ee465fdcd517bc36afdf)
---
# PRD: Mosaic Stack — rev1
The project source of truth, successor to the 2026-08-26 North Star PRD
(rev0). Ratified 2026-09-01; this bundle is frozen — the next revision is
drafted in a lane and lands as a new bundle ([[GOV.1-prd-lifecycle]]). This file assembles the section documents in this bundle; the
sections own the detail. Order authority and naming: [[PRD.0-index]].
Lifecycle (shim, frozen revision bundles, archival): [[GOV.1-prd-lifecycle]].
The PRD is **mission-independent**: missions pin an accepted PRD version and
reference it (register OD-16/OD-19); they never usurp it. rev0 ([rev0 PRD](../2026-08-26_PRD_rev0/PRD.md)) is archived verbatim beside this bundle;
`docs/PRD.md` is the permanent shim pointing here.
## Metadata
- **Owner / decision authority:** Jason Woltje
- **Status:** ratified 2026-09-01 (Jason Woltje). Drafted as a class-2 draft-native in lane `fleet/lanes/control-plane-surfaces` (estate brain); grill record in [[GOV.5-open-questions]]
- **Base text:** rev0, pinned at `origin/next` commit `9aa4983c`
- **Pulled sources (inputs, never ratified; not shipped in this bundle):** [rev0 PRD](../2026-08-26_PRD_rev0/PRD.md) and the operator DECISION-REGISTER (estate brain `docs/guides/proposed/DECISION-REGISTER.md`, snapshot 2026-08-28, sha256 `2cc81be1…aabec`; operator-only corpus, not shipped)
## Revision log
| Rev | Date | State | Notes |
| ---- | ---------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| rev0 | 2026-08-26 | superseded 2026-09-01; archived verbatim as `docs/PRDs/2026-08-26_PRD_rev0/PRD.md` | North Star PRD (Part I product north star from D1D14 + D15; Part II workstream contracts) |
| rev1 | 2026-08-31 | **ratified 2026-09-01** (Jason; grill rounds 18 closed the GOV.5 frontier) | rev0 + control-plane surfaces (seats, roles, harnesses, providers, authentication, sessions, WebUI/CLI), consolidated decision map, authorization gap register, docs-estate consolidation |
## Mandate (2026-08-31 drafting directive)
1. Combine the official PRD (rev0) with the `control-plane-surfaces` and
`agent-runtime-ng` lane findings.
2. Ingest and reconcile the pertinent document corpus — stack `docs/` on
`origin/next`, `~/.mosaic/docs`, `~/.mosaic/docs/guides/proposed`
([[GOV.2-docs-inventory]] is the audit trail).
3. Specify all functions of the site and the north star in one place, with
full `mosaic` CLI ↔ WebUI parity.
4. Remove the no-central-location ambiguity; clear up drift and naming issues;
clarify ambiguous structural language.
5. Walk open questions via ms-grill-me before ratification
([[GOV.5-open-questions]]).
---
## Part I — Product north star
**[[VIS.1-north-star]]** — what Mosaic Stack is, who it is for, deployment
modes, hierarchy and tenancy, identity, onboarding, data custody, the
webUI-over-tooling architecture gate, the v1 slice, the fleet-north-star
subordination, non-goals, and tiered containerized deployment. rev0 Part I
preserved as base text with marked rev1 annotations.
## Part II — Platform model (control plane)
| Section | Owns |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| [[DATA.1-record-authority]] | record-class authority (J1), the configuration data model, seat-file consolidation, reconciliation obligation |
| [[AUTHZ.1-capability-authority]] | intersection authority model, `mosaic-core` enforcement, firewalls, privilege escapation, accepted risk, gap register G1G7 |
| [[ROLE.1-role-governance]] | role definitions/revisions, manifest invariants, role surface, seat/role separation rule |
| [[SEAT.1-seat-profile]] | instance contract, seat surface, the separated role-binding control (G5), OD-02/OD-03 semantics |
| [[HARN.1-harness-config]] | harness install/enable, model allowlists, adapter boundary (register OD-38) |
| [[PROV.1-providers]] | hosted and local providers, named instances, activation |
| [[AUTHN.1-auth-accounts]] | agent-side provider accounts, OAuth/API, custody rules, broker boundary |
| [[SESS.1-session-continuity]] | Stack session id, incarnation layering, mid-stream switching via register OD-57OD-61, the two-operations rule |
## Part III — Surfaces
| Section | Owns |
| ----------------------- | ------------------------------------------------------------------------------------------------ |
| [[UI.1-webui-surfaces]] | governing rules and the complete page/function inventory, including the authorization-audit page |
| [[CLI.1-parity]] | CLI primacy, the one-engine rule (OD-53), the parity-matrix obligation, command families |
## Part IV — Governance
| Section | Owns |
| ------------------------ | --------------------------------------------------------------------- |
| [[GOV.1-prd-lifecycle]] | SOT rule, shim, frozen revision bundles, archival |
| [[GOV.2-docs-inventory]] | corpus inventory, supersession verdicts, naming-defects register |
| [[GOV.3-decision-map]] | every binding decision registry, collision rule, reconciliation notes |
| [[GOV.5-open-questions]] | the grill list; ratification gate |
## Part V — Active workstream contracts (preserved unchanged)
**[[GOV.4-workstream-contracts]]** — rev0 Part II carried verbatim: #1194
drift detection, Compaction Refresh Trust Lifecycle, Pi Persistent Goal Loop,
FCM, cross-harness comms, KBN-101, TESS, channel plugins, MOS-PORT, workspace
placement guard, Release Integrity, T78 CLI migration. Open issues bind to
them; they graduate out individually as workstreams close.
---
## Ratification
Per [[GOV.1-prd-lifecycle]]: this bundle freezes into
`mosaicstack/stack docs/PRDs/` (branch off `origin/next`), rev0 archives as a
one-file bundle, `docs/PRD.md` becomes the generated pointer (register OD-18).
Gate: [[GOV.5-open-questions]] empty or explicitly deferred; then reviewed PR
per stack delivery gates.
@@ -1,34 +0,0 @@
---
id: PROV.1
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# PROV.1 — Provider configuration
A provider is a model-inference source: hosted (Claude, OpenAI, ZAI, N others)
or local (Ollama, LM Studio, other).
## Provider configuration surface (WebUI page + CLI)
| Control | Notes |
| --------------------- | -------------------------------------------------------------------------------------- |
| provider selection | dropdown of supported providers |
| name | user-chosen instance name (multiple named instances of one provider type are expected) |
| auth mode | OAuth or API key — the account itself lives in [[AUTHN.1-auth-accounts]] |
| local provider setup | endpoint/port for Ollama, LM Studio, other local providers |
| activate / deactivate | inactive providers are not selectable downstream |
Provider records are runtime state (Postgres-owned, projected). Credentials
never enter provider records; they live with the credential broker
([[AUTHN.1-auth-accounts]]).
## E2 inputs (triage 2026-08-31; see [[GOV.2-docs-inventory]])
**Canonical ground truth**: `DEVELOPER-GUIDE/architecture/decisions/mos-runtime-portability-m1.md`
(the only current ADR for the logical-agent/connector-lease identity model);
`ADMIN-GUIDE/operations/mos-connector-lease-operations.md` — connector
activation is a **deliberate deny-all hold**; nothing in this section may imply
it is live.
**Drafts noted**: `rfcs/optional-ai-egress-gateways.md` (non-operative;
separates `IProviderAdapter` from egress-gateway concerns).
@@ -1,85 +0,0 @@
---
id: ROLE.1
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# ROLE.1 — Role governance and configuration
## What a role is
A role is the reviewed, Git-owned capability ceiling for a class of seats:
Role Definition → immutable, digested Role Revisions → the active revision
projects `mosaic-core.manifest.json` and role-scoped settings. Authority table:
[[DATA.1-record-authority]]. Enforcement: [[AUTHZ.1-capability-authority]].
## The separation rule (Jason, 2026-08-31 — closes gap G6)
- The **seat** configuration surface NEVER directly modifies role config.
- The **role** configuration surface NEVER directly modifies seat config.
- A seat page writes at most a per-seat **overlay**, never the role file.
The original `role-harness-config/DESIGN.md` sentence constrained a surface it
never named — both readings were faithful, and reviewer context decided the
meaning. The staged amendment names the surface explicitly.
## Role configuration surface (WebUI page + CLI)
| Control | Notes |
| ------------------- | -------------------------------------------------------------------------------------------------------------- |
| manifest editing | capability grants against the C1C8 (later open) registry; schema-validated before commit |
| revision management | create revision, diff against active, activate, roll back — every revision immutable and digested |
| role links | which seats bind this role (read-only here; binding happens on the seat surface — see [[SEAT.1-seat-profile]]) |
| projection status | whether each bound seat's on-disk projection matches the active revision (`role check` class) |
All writes go through the one canonical role-management API (L2-D14) shared
with the CLI — the WebUI holds no separate role logic. Role management is
**principal-only** (L2-D13): no agent identity may ever invoke these
operations, and the API enforces that, not the page.
## Manifest invariants (must survive any surface)
- Committed, non-symlink, trusted-path — `mosaic-core`'s loader refuses
violations; no surface may "fix" that by writing a symlink.
- Nothing env-overridable, nothing cwd-relative.
- `tools[]` equals exactly the bound bindings of granted capabilities.
- Role cross-checked against path at load.
## Specialization model (pulled 2026-08-31 from SPECIALIZATION-MODEL draft)
Four layers: **Role** (decision ownership and prohibited actions — few,
stable) → **Seat** (durable identity performing the role) → **Specialization**
(recurring domain/tools/behavior — open-ended, composable, never changes
authority) → **Task** (current activity). Rules:
- A seat has **exactly one role at a time**; never activate a second role
inside a session. If authority changes, `mosaic config` reconfigures the seat
and the coordinator starts a **clean session** — seat identity, history, and
authorship survive; the old lease is revoked and a new incarnation starts.
(Independent confirmation of register OD-02/OD-03 and the
[[SESS.1-session-continuity]] two-operations rule.)
- Promotion to a new role only when decision ownership or prohibited actions
materially differ; otherwise a formal specialization profile. Promotion
triggers: different authority/external side effects, distinct
credential/identity/data boundaries, added compliance controls, stable
machine-readable I/O contract, required independence, deterministic gate
behavior, repeated cross-seat use.
- Ad-hoc task-scoped specialization is valid only inside existing authority,
with no new credential/safety/independence boundary; it dies with the task
unless intentionally promoted.
- Anti-patterns: per-topic role explosion; role-subtype hierarchies no workflow
consumes; model IDs or harness syntax inside specialization definitions;
using specialization to bypass role authority or gates.
## E2 inputs (triage 2026-08-31; see [[GOV.2-docs-inventory]])
**Canonical ground truth**: `fleet/reference/role-classes.md`,
`fleet/concepts/role-authority-and-leases.md`, `fleet/how-to/customize-roles.md`
(baseline + `roles.local` resolver), `fleet/migration/legacy-class-aliases.md`,
`ADMIN-GUIDE/security/discord-ingress.md` (viewer/operator/admin precedent).
**Pending pulls**: brain `docs/guides/proposed/SPECIALIZATION-MODEL.md`
(Role/Seat/Specialization/Task layering — the conceptual basis of this
section's separation rule); `plans/2026-08-29-agent-enrollment-command-design.md`
(enrollment authority composed across three contracts — fragility to fix or document).
**Naming hazard**: "Tess"/"Ultron" are roster-class display aliases in fleet
how-tos and named product identities elsewhere (defect N6) — qualify every use.
@@ -1,87 +0,0 @@
---
id: SEAT.1
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# SEAT.1 — Seat identity, profile, and configuration
## Instance contract (register OD-48)
| File | Carries |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `profile.json` | structured identity — and, post-consolidation, the full seat record ([[DATA.1-record-authority]] §consolidation) |
| `overlay.json` | generated composition |
| seat-local `AGENTS.md` | narrative specialization |
| `SOUL.md` | persona |
## Seat configuration surface (WebUI page + CLI)
| Control | Notes |
| ---------------------- | ------------------------------------------------------------------------------------- |
| harness | from **enabled** harnesses only ([[HARN.1-harness-config]]) |
| model | constrained by the harness's available-models allowlist |
| reasoning level | |
| work dir | |
| authentication account | from configured, active accounts allowed for that harness ([[AUTHN.1-auth-accounts]]) |
| overlay | per-seat overlay only — never the role file (ROLE separation rule) |
| role binding | **separated section — see below** |
## The role-binding control (gap G5)
Role Binding is the single highest-authority action in the system,
principal-only under L2-D13. `model` is a preference. They must not share one
undifferentiated form — a privilege grant must not inherit the ceremony of a
dropdown. Requirements:
- Visually and structurally separate section on the page.
- Distinct confirmation step; re-authentication of the principal is under
consideration ([[GOV.5-open-questions]] Q-S2).
- Register OD-02/OD-03 bind the semantics: a seat has exactly one role; an
**active session never switches roles**. A role change reconfigures the
existing seat, preserves identity and history, **discards ephemeral context,
and starts a clean session**. The surface must say so before confirming.
- Role-transition history is recorded: old role, new role, reason, authorizer,
checkpoint, activation time (register OD-04).
Role changes are therefore a _different operation_ from harness/model/provider
changes ([[SESS.1-session-continuity]]) and must not share a code path.
## Seat identity and credential rules (pulled 2026-08-31 from seat-identity draft)
- **One seat = one identity = one token slot.** A second copy of a token
anywhere is drift and is removed without reading it.
- Agents never mint their own tokens; provisioning, rotation, and scope changes
are operator authority. Credential refusal is _correct behavior_ — the fix is
the seat's identity, never another seat's or a shared credential.
- Fail-closed everywhere: an empty/unreadable slot is a designed state reported
at launch; the credential helper refuses, records, notifies — never falls
back to a shared or owner credential.
- Git identity resolution order: explicit environment identity → configured
identity → git's own answer. Identity is named on every invocation and never
persisted inside a shared clone/worktree config (silent attribution rewrite).
Commit author must identify the seat that did the work.
- Tokens are compared by digest, never by value; scopes are verified from the
authority's own report, never transcription.
## E2 inputs (triage 2026-08-31; see [[GOV.2-docs-inventory]])
**Canonical ground truth**: `fleet/reference/agent-mutations.md`,
`fleet/reference/lifecycle-transitions.md` (`enabled`/`desired_state` authority),
`fleet/how-to/create-update-delete-agent.md`, `guides/fleet-local-canary.md`.
**Pending pulls**: brain `docs/guides/proposed/operations/seat-identity.md`
(credential-resolution mechanics under the OD-48 instance contract).
## Role-binding step-up ruling (Q-S2, Jason 2026-09-01)
Confirming a role-binding change requires fresh principal re-authentication no
older than 10 minutes — the same step-up bar the S2 identity-lifecycle
contract sets for account linking. An active session alone is insufficient;
this closes the stolen-session → privilege-misdirection path through the seat
surface.
## Seat record consolidation ruling (Q-D1, Jason 2026-09-01)
`launch.env` consolidates into `profile.json`: one seat record. Verified:
`mosaic-core/lib/loader.ts seatRole()` reads only the `role` key from a
generically-parsed record, so widened files are tolerated by construction.
@@ -1,121 +0,0 @@
---
id: SESS.1
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# SESS.1 — Session identity and mid-stream switching
## Requirement (Jason, 2026-08-31)
An agent session stays active on the system, tied to a **Stack session id**.
Changing harness, model, or provider mid-stream preserves the session id and
fully switches context from one provider/harness to another, with no user
intervention and no noticeable performance degradation.
## Two operations, two code paths — never merged
| | Harness / model / provider switch | Role switch |
| ----------- | --------------------------------- | ------------------------------------------------------- |
| Session id | preserved | seat identity preserved; session is **clean** |
| Context | fully transferred | **ephemeral context discarded** (register OD-03) |
| Governed by | this section | [[SEAT.1-seat-profile]] §role-binding |
| Why | continuity requirement | an active session never switches roles (register OD-02) |
## The ratified mechanism already exists: register OD-57OD-61
The 2026-08-28 register confirms the machinery this requirement needs:
- **OD-57 checkpoints** — atomic, schema-valid, revisioned seat checkpoints tied
to incarnation and lease; freshness enforced mechanically.
- **OD-59 relaunch** — the coordinator requests and validates a checkpoint, stops
the session, applies configuration, starts a **clean incarnation**, restores
the assignment, verifies readiness.
- **OD-60 fencing** — leases, epochs, incarnation IDs, fencing tokens prevent a
stale session from mutating state after the switch.
- **OD-61 restart recovery** — the relaunched seat restores role, mission, task,
PRD pin, constraints, evidence, blockers, leases, dependencies, and next
action **without prior conversation**.
A mid-stream harness switch is therefore an OD-59 relaunch keyed to a persistent
Stack session id: checkpoint → stop → reconfigure → new incarnation →
restore → resume. What OD-59 does not yet promise is the _experience_ bar — no
user intervention, no noticeable degradation — which is this PRD's addition.
## Identity layering
`mosaic-core` mints a per-launch **incarnation id** and keys its journal on it,
deliberately not on any session id. A harness switch is a new process → new
incarnation → new journal, **while the Stack session id persists**. So:
```
Stack session id (durable; user-facing continuity)
└─ incarnation id (per launch; enforcement journal, fencing per OD-60)
```
The precise contract between the two ids — minting, custody, what the
coordinator records at each relaunch — must be specified before build:
[[GOV.5-open-questions]] Q-S1.
## Open hard problem
Context-transfer fidelity between harnesses with different context formats,
tool-call encodings, and system-prompt injection points. The checkpoint (OD-57)
is the transfer vehicle; whether a checkpoint alone meets "no noticeable
degradation" across harness families is unproven: [[GOV.5-open-questions]] Q-S3.
## Session lifecycle state machine (pulled 2026-08-31 from the session-lifecycle draft — with one required extension)
The operator draft (`workflows/session-lifecycle.md`, the densest
decision-register consumer: OD-03/OD-04/OD-08, OD-56OD-65) supplies the checkpoint/
lease/fencing machinery this section's continuity requirement runs on:
- **States**: Active → Relaunch-requested (triggers per OD-59: context
utilization, session age, milestone, drift, degraded health, role
reconfiguration, authorized request) → Checkpointing (atomic, revisioned,
bound to identity + incarnation + epoch + lease, OD-57) → Relaunching
(validated checkpoint, old lease revoked → **new incarnation, new fencing
token**, OD-59) → Restoring (readiness proof: role, task, PRD pin, blockers,
next action, OD-61) → Active/Degraded. Role change routes through
Reconfiguring first (old-role record, transition history, revoked lease,
OD-03/OD-04).
- **Fencing**: a stale session cannot mutate after its replacement holds the
new token (OD-60); mutation authority is lease-gated and not renewed while the
checkpoint is stale. Coordinator outage fails closed for new
assignments/relaunches/renewals; existing leases run to expiry; read-only
work continues (OD-63).
- **Checkpoint contents** (required fields): role, config version, mission,
outcome node, task, PRD pin, constraints, completed work with evidence refs,
blockers and failed attempts, active leases/external ops, next action with
required inputs. The checkpoint is an operational projection — mission truth
stays in the ledger (OD-58). Telemetry is append-only and never the resumable
checkpoint (OD-56).
**Structural gap found at extraction (must be fixed before this machine
ratifies):** the draft models exactly **one** relaunch mechanism — every
trigger, without exception, mints a new incarnation and fencing token. There is
no continuity-preserving path at all, and harness/model/provider switching does
not appear among the triggers. This PRD's two-operations rule (above) requires
**two code paths**: the state machine must gain a switch path that preserves
the Stack session id and full context per OD-57OD-61 while still rotating the
fencing token safely. Adopting the draft's table verbatim would silently
collapse the two operations back into one — the exact defect register OD-02/OD-03
vs the continuity requirement exists to prevent.
## E2 inputs (triage 2026-08-31; see [[GOV.2-docs-inventory]])
**Canonical ground truth**: `DEVELOPER-GUIDE/architecture/compaction-revocation.md`
(the only current continuity/revocation lifecycle — observer/generation-fencing,
test-consumed), `channel-protocol.md`.
**Pending pulls**: brain `docs/guides/proposed/workflows/session-lifecycle.md`
(checkpoint/relaunch/recovery/role-reconfig — complements this section's
switching focus; its role-reconfig path must respect the OD-02/OD-03 clean-session rule).
## Two-path requirement ratified (Q-S4, Jason 2026-09-01)
The state-machine gap flagged above is now a binding requirement: the
session-lifecycle draft may not land with a single relaunch path. Role change
→ clean-session path (new incarnation + fencing token, context discarded,
OD-02/OD-03). Harness/model/provider change → continuity path (same Stack session
id, OD-57 checkpoint restored under OD-61, no noticeable degradation). The two
paths must not share a code path.
@@ -1,108 +0,0 @@
---
id: UI.1
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# UI.1 — WebUI control-plane surfaces (all functions of the site)
The complete function inventory of the WebUI control plane. Every page obeys
the governing rules; every control ultimately calls the same engine as the CLI.
## Governing rules
1. **Full CLI parity** — every aspect of the `mosaic` CLI surfaces in the WebUI
([[CLI.1-parity]] carries the matrix obligation).
2. **One canonical API** (L2-D14; register OD-53) — CLI, TUI, WebUI, API, and
automation share one CLI-backed schema, resolver, planner, authorization,
transaction, validation, and audit engine. The WebUI holds no separate
logic.
3. **The webUI sits OVER official tooling** (D8/D12 hard rule) — no page ever
reaches the database or filesystem around the tooling; a missing tool means
the gap is "blocked on tooling" and the tool is built first.
4. **Strict surface separation** — seat pages never modify role config; role
pages never modify seat config ([[ROLE.1-role-governance]]).
5. **No direct settings-file authorship** — settings are generated projections
(L2-D19; [[DATA.1-record-authority]]).
6. **Agents can never reach these surfaces** (L2-D13; the API refuses agent
identity — the enforcement is not the page's absence).
7. **WebUI drafts** (register OD-54) — draft configuration is revisioned
server-side desired-state; drafts have no effect until planned and applied.
## Interaction conventions
Logically separated pages; dropdowns, activate/deactivate buttons, drag-drop
actions performed on-page.
## Page inventory
| Page | Section doc | Functions |
| ---------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| Seat configuration | [[SEAT.1-seat-profile]] | harness, model, reasoning, workdir, auth account, overlay; separated role-binding section |
| Role configuration | [[ROLE.1-role-governance]] | manifest editing, revision create/diff/activate/rollback, role links, projection status |
| Harness configuration | [[HARN.1-harness-config]] | install (button), enable/disable, available-models allowlist, reasoning defaults, provider link, linked accounts |
| Provider configuration | [[PROV.1-providers]] | provider dropdown, named instances, OAuth/API mode, local providers, activate/deactivate |
| Authentication | [[AUTHN.1-auth-accounts]] | in-browser OAuth establishment, account list, force renew, deactivate, allowed harnesses |
| Authorization audit | below | effective grants, escapation potential, drift |
## Page: Authorization audit (closes gap G3)
Surfaces, per seat, to the user:
- **Effective capability grant** — the live intersection
(role ∩ assignment ∩ lease ∩ workflow ∩ target policy ∩ backend).
- **Misdirection potential** — which seats hold capabilities that would let
another seat's work be routed around its own role lane.
- **Escalation potential** — any path that would add capability. Should be
provably empty; the audit's job is proving it _stays_ empty.
- **Drift** — seats whose on-disk projection diverges from their active role
revision (`role check` class).
- **Failure/blocked surfacing** (register OD-64) — the canonical alert stream's
WebUI adapter.
Implementation choice (dedicated auditor agent vs mechanical tooling) and the
auditor-identity problem are on the grill: [[GOV.5-open-questions]] Q-A1/Q-A2.
## Cross-cutting requirement
Every change made through these pages — or the CLI — automatically reconciles
authentication, `settings.json`, and required symlinks
([[DATA.1-record-authority]] §reconciliation; removal-fast / addition-attested
per L2-D17). The user never touches a file.
**Measured 2026-08-31** ([[CLI.1-parity]] Artifacts 23): the shipped WebUI
already contains two D12 violations — the admin role/ban toggles and the stored
harness/provider/model selection mutate state with no backing CLI command.
Remediation, not precedent. The server-side hierarchy/grants CRUD surface
(`hierarchy.controller.ts`) is the natural backing for the authorization audit
page below, but needs a CLI face and an audit read-path first.
## E2 inputs (triage 2026-08-31; see [[GOV.2-docs-inventory]])
**Canonical ground truth**: `USER-GUIDE/product/web-dashboard.md` (route-by-route
current state, incl. explicit gaps — no New Project/Task UI),
`webui/PHASE-P-STRUCTURE.md` (Next→Vite SPA migration).
**Pending pulls**: DRAFT S2 `onboarding-wizard.md` (D4/D11/D8),
`tool-gateway-mapping.md` (the D8/D12 gate made concrete), `api-artifacts.md`.
## S2 contract feed (extraction 2026-08-31)
Full extraction record: lane `S2-EXTRACTION-2026-08-31.md` (per-contract cores, dependency edges, ruling cross-checks). Pulls binding on this section:
- **Contract 5 verbatim-affirms the parity rule**: "The webUI is a Gateway
client only"; "No webUI-only command exists; a Gateway command without CLI
exposure is a conformance gap." "Blocked on tooling" closure is mandatory;
UI workarounds (direct DB/filesystem, legacy endpoints, domain logic in the
web app) are non-conformant. This is the ratifiable D8/D12 text this
section's violation findings measure against.
- **Legacy non-substitutes** barred from backing any P1 surface, frozen for
new consumers: `/api/projects`, `/api/tasks` CRUD, `POST /api/workspaces`,
`/api/teams` reads, `POST /api/bootstrap/setup`, MCP `brain_*` mutations.
- **Onboarding wizard (contract 3)** is the reference pattern for every config
page this section specifies: pure client-side composition of Gateway
commands, exactly one disclosed server-side composed transaction (bootstrap
finalize), wizard state always derived from canonical state — never a
persisted answer file that can drift.
- **Company visibility** (`private` default vs `directory`) is a UI-facing
disclosure control with a bounded existence-only carve-out.
- P1 build rank order (T10): hierarchy → hierarchy RBAC → typed kanban →
agent enrollment → authorized roll-up → onboarding orchestration.
@@ -1,214 +0,0 @@
---
id: VIS.1
status: ratified
ratified: 2026-09-01 (Jason Woltje; PRD rev1 ratification PR)
---
# VIS.1 — Product north star
Successor text to rev0 Part I ([rev0 PRD](../2026-08-26_PRD_rev0/PRD.md) lines
33215, preserved there verbatim). Base text unchanged except marked **rev1**
annotations; the decision registry moves to [[GOV.3-decision-map]].
### 1. What Mosaic Stack is (D1)
Mosaic Stack is an **open-source, AI-first platform for people who want a
self-hosted environment for agentic management and a life operating system.**
It serves personal, business, and employee needs from one deployment, and the
work is offered freely.
"AI-first" means agents are first-class operators of the system, not a bolted-on
chat box: the platform exists to let humans direct fleets of agents over their
projects, tasks, communications, and infrastructure, with the same tools and
the same guarantees whether a human or an agent is acting.
### 2. Who it is for (D1, D9)
The operator of a deployment is its user. Mosaic Stack is **not a hosted
business**: running the system as a service for external customers is outside
the north star. Multi-tenancy exists WITHIN a deployment so that one operator
can separate their world — for example, several LLCs plus a personal domain —
while every deployment is self-hosted by its own operator.
"Company" in the hierarchy is organizational separation for one operator's
world, not a customer account.
### 3. Deployment modes (D3)
Two modes, chosen at install time:
| | Standalone / personal | Enterprise |
| ------------------- | -------------------------------------- | ----------------------------------------------------- |
| Brains | one mosaic-brain (system + user files) | system brain for config + one brain per user |
| User-data isolation | single user | no user-data leakage between users; sharing is opt-in |
| Secrets | OpenBao/Vault or flat files | OpenBao/Vault REQUIRED |
| Conversion | Standalone → Enterprise, **one-way** | terminal state |
Brains are configurable as external git repositories (recommended, not
required); git tracking is always on locally.
**Federation** (connecting deployments: system-level config, assigned users,
rights and data-access control, trusts with boundaries, exfiltration
monitoring) is intentionally not fully designed. It is deferred, appears on the
roadmap as a placeholder phase per D11, and nothing in v1 may foreclose it.
_D3 as amended 2026-09-01 (Q-T1 ruling B):_ federation milestones M1M3
(Step-CA, enrollment, grants, mTLS auth guard, ScopeService, list/get/
capabilities verbs) are **shipped but frozen** — present in code behind the
`tier === 'federated'` gate, dormant since 2026-06-25, absent from the canonical
compose topology (D15), excluded from the v1 bar, tracked as a dormant
workstream in `docs/fleet/NORTH_STAR.yaml`, and gated on a security re-audit
before any resumption. See [[GOV.5-open-questions]] Q-T1.
### 4. Structure and tenancy (D2, D9, D13)
The hierarchy:
```
company/organization (N per deployment)
└─ estate (each in exactly one company)
└─ project (each in exactly one estate)
└─ workspace (project-specific; carries the Kanban)
```
Rules:
- Users can create N companies, N estates, N projects.
- Tasks bubble UP the hierarchy so whole-system status is visible at every
level. Bubble-up is **read-only aggregation**, never a cross-workspace write.
- Granular RBAC: admins restrict access per company, estate, and project;
grants are evaluated down the chain. Assets are transferable subject to the
structure.
- **`workspace_id` remains the hard mechanical isolation unit** exactly as
ratified in
[docs/requirements/native-kanban-sot.md](../../requirements/native-kanban-sot.md)
(#751): PostgreSQL sole writable SOT, cross-workspace relationships rejected,
fail-closed mutations. The hierarchy is parent structure ABOVE workspaces,
used for RBAC evaluation and read-only roll-ups. The kanban SOT carries this
as Amendment A1, added by reviewed PR — an amendment, not a rewrite (D13).
### 5. Identity (D10)
Built-in auth (better-auth) is the **account system of record**. Authentik and
other external IdPs federate in via OIDC as login methods; they never become
the system of record. Perimeter shims (forward-auth in front of a web host) are
deployment workarounds, not the design.
### 6. Onboarding (D4)
Onboarding is a **wizard that differs by mode, is re-runnable (no lock-in), and
is extensible** — new wizards attach as tabs.
Standalone flow captures: system and company name; component choices (Mosaic
Comms/Matrix vs external; Mosaic SSO/Authentik vs external; Mosaic
DB/PostgreSQL vs external; vector DB); the initial user
(email/password/name/SSO); comms setup (Matrix/Discord/Slack); agent enrollment
(harness choice and install, OAuth or API-key login, multi-account, model
choice with recommendation, agent name and persona, account assignment,
optional comms auto-enroll); a user onboarding profile (disabilities including
ADHD/autism/PDA/vision, professional background, education, desired agent
communication style, optional voice-matching interview, family/pets/friends/
hobbies/likes-dislikes); email and drive connectors (Gmail/IMAP, Google
Drive/OneDrive/Dropbox) with granular agentic-access consent; SSO/OIDC
configuration; an initial estate, an initial project, and seeded example data.
Enterprise uses the same skeleton with personal data optional; the focus moves
to business structure, org chart, RBAC, M365 and external systems, immediate
OIDC, SSO prominent.
Profile answers feed `USER.md` and/or the user's data store subject to the
custody rule in §7.
### 7. Data custody (D6, D14)
- **Sensitive profile categories** (disabilities, family, communication style,
and similar) live in the **user's own brain ONLY**. PostgreSQL holds
structural data, consent records, and pointers — never the content. "User
data does not leak" is enforced by architecture, not policy (D14).
- Standalone (one user, one brain) **may** keep the same split — D14 makes it
optional in Standalone, not required. Keeping it is the recommended default
because it preserves forward-compatibility with the one-way Enterprise
conversion (D3).
- Estate brains hold operational records. Only product-relevant material
migrates into this repository's docs; operational records stay in their
brains and are linked (D6).
### 8. Architecture gate — the webUI sits OVER official tooling (D8, D12)
**HARD RULE:** every webUI operation goes through the Gateway API backed by the
same official framework tooling the CLI uses. The CLI remains the primary
execution method; the webUI uses the tools to operate and configure the
system. The webUI never bypasses tooling to reach the database or filesystem
directly.
Consequence for planning: when a desired webUI operation has no backing tool,
the gap is scored **"blocked on tooling"** and the tool is built first. The
product baseline therefore always includes all three D8 inputs: the tool
inventory (what exists and what is missing), the webUI→tool mapping, and the
measured current state of the `next` branch.
### 9. v1 slice (D11)
v1 is deliberately small:
1. **Standalone onboarding wizard** — system/company name, component choices,
initial user, initial estate + project, seeded examples, re-runnable.
2. **Hierarchy core** — company → estate → project → workspace → kanban, with
read-only task bubble-up.
3. **Basic RBAC** on the hierarchy.
4. **Minimal agent enrollment** — one harness, API key, name/persona.
Deferred beyond v1: connectors, comms integrations, voice-matching, M365,
Enterprise conversion, federation. Every deferred item appears in
[docs/ROADMAP.md](../../ROADMAP.md) per the D11 rule: nothing exists only in heads.
### 10. Relationship to the fleet north star
[docs/fleet/NORTH_STAR.md](../../fleet/NORTH_STAR.md) (generated from
`docs/fleet/NORTH_STAR.yaml`) is the **delivery-fleet** north star: how the
agent fleet that builds and operates the system should run (NS-1..NS-10,
workstreams AL). This PRD is the **product** north star. They are not
competitors: the fleet north star is subordinate product-wise — its workstream
J ("Web control plane") is one consumer of this PRD's D8/D12 gate — and this
PRD does not redefine fleet invariants. The subordination rule is ratified in
the frozen audit-input baseline (T2 operator freeze, 2026-08-25: "the PRD must
cite and subordinate it, never fork it"). A change that would put the two in
conflict must amend one of them explicitly, never fork a third document
(drafting addition — see §12.1).
### 11. Explicit non-goals
- Hosted/SaaS operation for external customers (D9).
- A webUI that writes to the database or filesystem around the tooling (D12).
- A second writable task store beside PostgreSQL (native-kanban-sot invariants).
- Fully-designed federation in v1 (D3 — roadmap placeholder only; the shipped M1M3 code is frozen, not a v1 feature).
### D15 — Tiered containerized deployment (2026-08-30, containerization lane)
The stack ships a tiered deployment target, additive to the architecture
gate (D8): (1) Standalone tier — docker compose is the canonical
single-host deployment: postgres, valkey, openbao, gateway, appservice
and the served webUI in one composition, with migrations, health checks,
and a documented install/upgrade path; the registry (CI-published
images) is the only deployment source. (2) Enterprise tier — Kubernetes
manifests for the same service set, phase-gated on the standalone tier
holding its acceptance bar. The v1 acceptance bar for the standalone
tier: compose-up healthy; webUI hosts agent chat; an in-stack agent can
open a PR to this repo; CI validates it; the running deployment adopts
the merged change (pull + restart). Federation (D3 clause) remains
deferred and unforeclosed. Implementation plan:
docs/plans/2026-08-30_containerization.md.
---
## rev1 annotations (2026-08-31)
- §8's architecture gate (webUI over official tooling, CLI primary) is
elaborated for the control plane by [[UI.1-webui-surfaces]] and
[[CLI.1-parity]]; register decision OD-53 confirms all interfaces share one
CLI-backed engine.
- §4's RBAC and §5's identity are joined by the **agent-side** authority model
in [[AUTHZ.1-capability-authority]]: role capability ceilings enforced at the
harness by `mosaic-core`, composed by pure intersection.
- The fleet north star subordination (§10) gains a control-plane consequence:
the WebUI workstream consumes this PRD's surface specifications
([[UI.1-webui-surfaces]]) rather than defining its own.
+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
+1 -1
View File
@@ -59,7 +59,7 @@ Active workstream is **W1 — Federation v1**. Workers should:
## Fleet configuration management (#758) — M0M5 implementation DAG
> **PRD:** [Fleet declarative configuration management](./PRDs/2026-08-31_PRD_rev1/GOV.4-workstream-contracts.md#fleet-declarative-configuration-management-workstream-fcm-758) · **M0 acceptance:** [docs IA checklist](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) · **baseline dispositions:** [legacy example/profile inventory](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md)
> **PRD:** [Fleet declarative configuration management](./PRD.md#fleet-declarative-configuration-management-workstream-fcm-758) · **M0 acceptance:** [docs IA checklist](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) · **baseline dispositions:** [legacy example/profile inventory](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md)
>
> Every row below is one independently reviewable card and **one PR**. `depends_on` is a
> hard DAG edge; no card may silently absorb another card's scope. All source cards require
+1 -3
View File
@@ -1,10 +1,8 @@
---
kind: tracking
status: superseded
status: active
---
> **Superseded (2026-09-01, PRD rev1 ratification).** This document's framing of Federation v1 as an active, in-progress mission (M3) is historical. Federation M1M3 are shipped but **frozen** (dormant since 2026-06-25, excluded from the v1 bar, security re-audit gate before any resumption); the canonical v1 deployment topology is the compose standalone tier (PRD rev1, D15). Authority: `docs/PRD.md``docs/PRDs/2026-08-31_PRD_rev1/` (decision D3 as amended, GOV.5 Q-T1). Tracking: `docs/fleet/NORTH_STAR.yaml` (dormant federation workstream). Content below is preserved verbatim as a record — do not edit it.
# Mission Manifest — Federation v1
> Persistent document tracking full mission scope, status, and session history.
+14 -15
View File
@@ -39,21 +39,20 @@ The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's
## Workstreams
| id | title |
| --- | ----------------------------------------------------------------------------------------------------------------- |
| A | Substrate — Mosaic Backlog on native Postgres storage service |
| B | Supervisor — movement guarantee, two-agent floor, dispatch/claim |
| C | Planner — goal decomposition into independently-shippable cards |
| D | Merge-gate — single approver, pr-merge.sh after CI wait |
| E | Meta-loop — session-review + enhancer improvement PRs |
| F | Safety-rails — TTL claims, advisory spend, PAUSE kill-switch |
| G | Kill-switch — operator PAUSE honored before dispatch and merge |
| H | Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization |
| I | Operator surface — launcher, fleet visibility, reliable steering (tier 0) |
| J | Web control plane — browser surface over the gateway (tier 1) |
| K | Clients — desktop and mobile over the same backend (tier 2) |
| L | Auth profiles — per-provider accounts, per-session selection (tier 2) |
| M | Federation — DORMANT; M1M3 shipped and frozen (PRD rev1 D3 as amended; security re-audit gate before resumption) |
| id | title |
| --- | ----------------------------------------------------------------------------------------------------------- |
| A | Substrate — Mosaic Backlog on native Postgres storage service |
| B | Supervisor — movement guarantee, two-agent floor, dispatch/claim |
| C | Planner — goal decomposition into independently-shippable cards |
| D | Merge-gate — single approver, pr-merge.sh after CI wait |
| E | Meta-loop — session-review + enhancer improvement PRs |
| F | Safety-rails — TTL claims, advisory spend, PAUSE kill-switch |
| G | Kill-switch — operator PAUSE honored before dispatch and merge |
| H | Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization |
| I | Operator surface — launcher, fleet visibility, reliable steering (tier 0) |
| J | Web control plane — browser surface over the gateway (tier 1) |
| K | Clients — desktop and mobile over the same backend (tier 2) |
| L | Auth profiles — per-provider accounts, per-session selection (tier 2) |
## Goals (backlog projection)
+1 -8
View File
@@ -145,15 +145,8 @@ workstreams:
title: Clients — desktop and mobile over the same backend (tier 2)
- id: L
title: Auth profiles — per-provider accounts, per-session selection (tier 2)
# M is DORMANT by ruling (PRD rev1, D3 as amended 2026-09-01, GOV.5 Q-T1
# ruling B). Federation M1M3 exist in code behind `tier === 'federated'`
# (M3 landed 2026-06-24/25), are excluded from the v1 bar and frozen. It
# projects no goals on purpose: none may be added before a security
# re-audit of the frozen cert/auth code and a federation PRD revision.
- id: M
title: Federation — DORMANT; M1M3 shipped and frozen (PRD rev1 D3 as amended; security re-audit gate before resumption)
# NOTE: workstreams C, D, E, F and M are declared but currently project no goals.
# NOTE: workstreams C, D, E and F are declared but currently project no goals.
# That is planning debt, not an editing error: their goals have not been written
# yet. The A5 validator below reports it rather than letting it stay invisible.
+1 -1
View File
@@ -1,6 +1,6 @@
# Fleet Configuration Management
This book documents the local roster-v2 desired-state control plane delivered under issue #758. The normative requirements are the [FCM section of the repository PRD](../PRDs/2026-08-31_PRD_rev1/GOV.4-workstream-contracts.md#fleet-declarative-configuration-management-workstream-fcm-758), not the older fleet-suite or observability planning pages.
This book documents the local roster-v2 desired-state control plane delivered under issue #758. The normative requirements are the [FCM section of the repository PRD](../PRD.md#fleet-declarative-configuration-management-workstream-fcm-758), not the older fleet-suite or observability planning pages.
## Authority boundary
+4 -19
View File
@@ -5,24 +5,12 @@ status: active
# Deployment Guide
> **Status: non-operative for PostgreSQL, federated (federation is frozen — PRD rev1 D3 as
> amended; not a v1 route), and bare-metal production.** The checked-in
> **Status: non-operative for PostgreSQL, federated, and bare-metal production.** The checked-in
> Compose PostgreSQL service mounts legacy initialization SQL and the KBN-101 bootstrap, runner,
> secret-renderer, and process-exec interfaces do not exist yet. This page does not authorize a
> production deployment, database initialization, manual DDL, secret provisioning, or service
> activation.
## Relationship to the PRD (D15)
Per PRD rev1 Decision D15 (`docs/PRD.md`), the compose standalone tier — `docker compose up` — is
the canonical v1 deployment topology; this guide describes the interim path to that bar, not a
competing one. The KBN-101 holds documented below (bootstrap, runner, secret-renderer, process-exec)
are operational gates on the road to the standalone-tier bar, not an alternative or federated
topology. They remain fully binding: nothing in this guide authorizes PostgreSQL, federated, or
bare-metal production activation until the named KBN-101-00/03/05 artifacts land, pass review, and
satisfy the order specified below. Federation M1M3 references elsewhere in this guide are
historical/frozen (PRD rev1 D3 as amended) and do not describe a live or v1-bound route.
## Current safe local route
Use PGlite only for current in-process data-layer work; it requires no PostgreSQL. A Gateway/Web
@@ -34,14 +22,12 @@ docker compose up -d valkey
```
This command intentionally does not start PostgreSQL. Do not run a broad Compose start, use its
PostgreSQL initialization mount, infer that current Compose is a production/federated (federation
is frozen — PRD rev1 D3 as amended; not a v1 route) route, or
PostgreSQL initialization mount, infer that current Compose is a production/federated route, or
start Gateway/Web until KBN-101-02 supplies fail-closed local-tier/DSN isolation.
## Held future procedure
PostgreSQL local, federated (federation is frozen — PRD rev1 D3 as amended; not a v1 route),
Compose, and bare-metal production activation are held until these
PostgreSQL local, federated, Compose, and bare-metal production activation are held until these
artifacts land and pass their independent gates:
1. **KBN-101-00** external privileged bootstrap artifact;
@@ -83,5 +69,4 @@ For local PGlite development, diagnose application behavior without introducing
connection.
Non-database local services may be inspected with their ordinary local health/log tools. Those
checks do not certify PostgreSQL, federated (federation is frozen — PRD rev1 D3 as amended; not a
v1 route) deployment, or production readiness.
checks do not certify PostgreSQL, federated deployment, or production readiness.
-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
-1
View File
@@ -14,7 +14,6 @@
| [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) | rc.16 direct-Drizzle current storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README commented/user-guide executable forms fail before masking and source-consistency rejects runner-delegation copy; held future bootstrap → TLS/roles → run → verify → readiness; plus prior production boundary, pgvector owner, attestation, inventory, manifests, DDL classifier, TLS/bootstrap, activation, and certification contract; foundation prerequisite of KBN-100 and real-role gate before KBN-105 |
| [`KBN-101-ENVELOPE-A.md`](./KBN-101-ENVELOPE-A.md) | KBN-101 Envelope A (v6) — RATIFIED, part of the frozen SSOT: rc.20 declarative sink-RBAC + per-role connection-selection + RLS `WITH CHECK`/`USING` write-source + `FORCE ROW LEVEL SECURITY` + sink-resident `task_status_write_override`; adds owner card KBN-101-10 + responsibility-widenings; authority Jason B1 + Mos OPTION A/Q1/Q2 |
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
| [`P0-MAP-CURRENCY-2026-08-29.md`](./P0-MAP-CURRENCY-2026-08-29.md) | REQ-MIG-001 lane-opening verification: SHARED-CONTRACT §5 field map re-verified byte-identical at `next` @ `abb0c936`; workspaces/audit-pattern refinements; measured `mission_tasks.status` writer inventory and the pre-expand stop-write work item |
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
@@ -1,117 +0,0 @@
---
kind: verification
status: active
---
# P0 Field-Map Currency Verification — 2026-08-29
**Purpose:** REQ-MIG-001 (native-kanban-sot.md §5) accepts only when "P0 publishes
the current `origin/main` field-by-field expand/backfill/compatibility/switch/contract
map before any schema lane starts." That map exists: [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md)
§5, inspected at `packages/db/src/schema.ts` @ `e72388b2cbfe400842fe940fa6cabf984ed43711`
(2026-07-13). The M4-3 schema lane (expand migration 0021+) now opens against the
integration trunk `next`. This document re-verifies the map's currency at the
lane-opening head and records the measured pre-expand writer inventory. It amends
nothing normative in SHARED-CONTRACT.md; where the two disagree, SHARED-CONTRACT.md
wins.
## 1. Currency verification (measured)
- Map pin: `e72388b2cbfe400842fe940fa6cabf984ed43711` (2026-07-13, `main`).
- Lane-opening head: `abb0c936011c7f6b8c0bcc90a20a865d5e8a40e9` (`origin/next`,
2026-08-29).
- Measurement: `git diff e72388b2 abb0c936 -- packages/db/src/schema.ts` reports
**300 insertions, 0 deletions** — no existing declaration changed.
- The additions: the new declarations `logicalAgentConnectorLeases`,
`connectorLeaseAuditLog`, and the hierarchy layer (`companies`, `estates`,
`platformProjects`, `workspaces`, `hierarchyGrants`, `hierarchyAuditEvents`,
`hierarchyOutbox`, plus their enums and constant arrays); a nullable `issuer`
column on the unmapped BetterAuth `accounts` table (shipped as
`drizzle/0017_accounts_issuer.sql`); and expanded `drizzle-orm` imports
(`sql`, `AnyPgColumn`, `unique`, `check`, `bigint`). None touch a mapped
source.
- Stronger literal fact: REQ-MIG-001's acceptance names `origin/main`. Measured
pin → `origin/main` (`7102ccb9`, 2026-08-13): **63 insertions, 0 deletions**
for `schema.ts`, and `origin/main` is an ancestor of `abb0c936`. The map is
therefore current at `origin/main` itself, and at the trunk head beyond it.
**Consequence:** every source column mapped in SHARED-CONTRACT.md §5.4 —
`teams`/`team_members`, `projects`, `missions`, `tasks`, `mission_tasks`,
`agents`, fleet `backlog` — is byte-identical to the declaration the map
inspected. The field map is current as written. No row changes.
## 2. Refinements available since the pin (context, not map changes)
1. **The `workspaces` table exists.** The map predates contract 1's hierarchy
layer; its "bootstrap workspace" backfill step now has a shipped target:
`workspaces` (uuid PK, chained under platform projects per
`docs/requirements/hierarchy-schema.md`; hierarchy core in
`drizzle/0018_clean_cobalt_man.sql`, audit/outbox in
`0019_volatile_killraven.sql`, visibility in
`0020_special_betty_brant.sql`). New `workspace_id` columns FK there.
2. **The audit/outbox envelope pattern is shipped.** `hierarchyAuditEvents` +
`hierarchyOutbox` implement same-transaction semantic event + outbox. The
task lane's `task_events`/`task_outbox` mirror the pattern but are
workspace-scoped with the composite `(workspace_id, id)` key required by
§5.3 and REQ-SOT-004. The hierarchy tables are a pattern reference, never a
shared store for task events.
3. **Trunk designation.** The integration trunk is `next` (`.mosaic/repo.json`).
§1 measures currency at both the literal `origin/main` REQ-MIG-001 names and
the trunk head pinned above, so no reinterpretation of the acceptance text
is needed.
4. **Migration ownership.** SHARED-CONTRACT.md §6 assigns schema/migration
ownership to the mission seat `coder2`. Seat identity is operational fleet
state, not resolvable from this repository, and is outside this document's
scope. The invariant §6 protects binds regardless of seat and is restated
here as binding on the M4-3 schema lane: exactly one lane generates
migrations at a time; expand is additive; no drop/rename/narrow; constraints
validate before NOT NULL.
## 3. Pre-expand writer inventory (measured 2026-08-29 at `abb0c936`)
SHARED-CONTRACT.md §5.1 phase 1 requires an N-1 patch that stops
`mission_tasks.status` as a write source, plus a writer inventory, before any
expand DDL.
- **Sole authoring write path:** `packages/brain/src/mission-tasks.ts`
`create`/`update` (Drizzle insert/update on `mission_tasks`), invoked by
`apps/gateway/src/missions/missions.controller.ts`. `update` accepts
`Partial<NewMissionTask>`, so `status` is writable through both DTOs today.
The same module also exposes `remove`/`removeByMission` DELETE paths —
immaterial to `status` writes, listed for inventory completeness.
- **Storage-layer surfaces that touch the column without authoring it**
(added 2026-08-29 after independent review of the phase-1 patch):
`packages/storage/src/migrate-tier.ts` copies whole `mission_tasks` rows
between storage tiers and must preserve the stored `status` verbatim — row
transport, exempt from the write prohibition (stripping there would corrupt
data inside the N-1 window). The generic table-keyed storage adapters
(`adapters/postgres.ts`, `adapters/pglite.ts`) register `mission_tasks` in
their table maps but have no caller that targets it: measured at this head,
every runtime adapter caller passes a fixed collection constant
(preferences/insights). Neither surface authors a new `status` value.
- **Read-only consumers of `mission_tasks`:** federation verb services
(`get-query.service.ts`, `list-query.service.ts`) select only. The MCP
`brain_*` tools do not touch `mission_tasks` at all; `brain_create_task` /
`brain_update_task` write the separately mapped `tasks` table, a legitimate
N-1 writer through the compatibility window.
- The ratified contract 5 decision
(`docs/requirements/tool-gateway-mapping.md` §3.2, ruled 2026-08-27) freezes
the legacy endpoints — including MCP `brain_*` task mutations — for new
consumers, while existing consumers keep working until each surface's owning
contract retires it. It does not stop existing writes.
**Standing work item:** the phase-1 stop-write patch (reject or ignore `status`
on `mission_tasks` create/update) MUST land before the expand DDL of migration
lane M4-3a. It is N-1-safe per the §5.4 row for `mission_tasks.status` (linked
status is ignored; the column stays declared and readable through the whole
N-1 window; retirement only after no readers).
## 4. Lane opening
With this verification merged, REQ-MIG-001's P0-map precondition is satisfied
for the M4-3 schema lane at pinned head `abb0c936`. The ordered phases (§5.1),
mission candidate-key DDL order (§5.2), audit/proposal DDL order (§5.3), field
map (§5.4), and required migration tests (§5.5) bind as written. External
import machinery (jarvis-brain/Vikunja shadow import, REQ-MIG-001) and client
cutover (REQ-MIG-002) remain out of scope for M4-3; the legacy surface stays
frozen for new consumers meanwhile (`tool-gateway-mapping.md` §3.2 decision).
@@ -1,315 +0,0 @@
---
kind: spec
status: active
audience: developer
---
# Agent Enrollment Command Family — v1 Design (M4-4-0)
Status: design note (implementation-facing; amends no contract).
Authority chain: tool-gateway-mapping.md §3.1 rank-4 row + §4 envelope
(ruled 2026-08-27), onboarding-wizard.md §3.5 (D11 minimal enrollment),
custody-schema.md §5.2 at revision 13 (agent-grantee FK bound to the
live `agents` table — a binding introduced at rev 4 and standing
verbatim), PRD §9 D11. Where this note and a ratified contract disagree,
the contract wins.
## 1. What the contracts bind (and what they leave open)
There is no standalone enrollment contract. The rank-4 family is defined
by composition:
1. **Contract 5 §3.1 rank 4:** "Enroll one agent: harness, credential
reference/API-key intake (values never echoed), name/persona,
assignment scope (contract 3 §3.5)."
2. **Contract 5 §4 — all five sub-clauses:** §4.1 typed request/result
DTOs validated at the Gateway boundary (expected-version only where
an owning contract defines one); §4.2 closed per-family error enum
(validation, authentication, authorization, not-found, conflict,
precondition, internal) with HTTP mappings; §4.3 audit linkage — the
envelope contributes correlation: every request accepts/generates a
correlation id, carried into the audit events **and returned in the
result**, with no second audit stream; §4.4 fail-closed — an
operation that cannot evaluate its authorization or reach its owning
tool refuses, never degrading to a fallback read or direct data
access; §4.5 CLI parity — the family MUST be invocable through the
official CLI against the same Gateway commands with the same
request/result/error contracts (a Gateway command without CLI
exposure is a tracked conformance gap).
**Idempotency keys are NOT contract 5 §4.3:** the idempotency-key
envelope is contract 3 §4.3, ratified as a drafting addition to
contract 5 §4's command envelope via contract 3 §7 item 4. Its fence
and replay rules bind as written there; §3.1 rule 5 below designs to
them.
3. **Contract 3 §3.5:** the wizard's enrollment step is minimal (one
harness, API-key login, agent name and persona — D11), uses ONLY this
family, and is skippable. Wizard witness §6.10: a run that skips the
step produces zero enrollment-family mutations.
4. **Custody-schema §5.2 (rev 13; binding introduced at rev 4):**
contract 7's agent-grantee FK references the live `agents` table
(`agents.id`, uuid); an enrollment surface with its own table would
force a contract-7 amendment.
**Assignment scope (open point, pinned here):** the rank-4 row cites
contract 3 §3.5, which defines no assignment semantics; the PRD's full
enrollment vision (Part I, Standalone flow) includes "account
assignment", but the D11 v1 slice is exactly "one harness, API key,
name/persona". v1 therefore scopes assignment to the two bindings the
minimal slice already implies — the enrolling user becomes the agent's
owner (`agents.owner_id`), and the credential reference names which of
that user's stored provider credentials the agent uses. Richer
assignment (multi-account, comms auto-enroll, workspace placement) is
deferred with the rest of the PRD's full flow (D11); when a contract
defines it, this family extends by ordinary amendment of the design.
The deferral rests on contract 3 §3.5's explicit delegation of
enrollment specifics to this family — not on reading the D11 list as
exhaustive (it is not: the §3.1 `model`/`provider` fields are required
by the live table's NOT NULL columns, though D11 does not name them).
## 2. Current state (measured 2026-08-29 at `origin/next` = `94d626df`)
- `agents` table (packages/db `schema.ts`): id uuid PK, name, provider,
model, status enum, project_id (legacy `projects`, ON DELETE SET
NULL), owner_id → users, system_prompt, allowed_tools, skills,
is_system, config jsonb, timestamps. No harness column (provider and
model describe the LLM backend, not the harness), no audit coupling.
- Sole write path: `packages/brain/src/agents.ts` repository (the only
module issuing `insert(agents)`), with three write consumers: the
legacy `/api/agents` CRUD controller
(`apps/gateway/src/agent/agent-configs.controller.ts`), the `/agent
new` chat command (`apps/gateway/src/commands/command-executor.service.ts`
`brain.agents.create`), and workspace bootstrap
(`apps/gateway/src/workspace/project-bootstrap.service.ts`). All
three keep serving existing consumers; none is touched by M4-4.
- Sealed credential store exists: `ProviderCredentialsService`
(apps/gateway/src/agent/) — one row per (userId, provider), values
sealed at rest, decrypt server-side only, summaries never carry
values.
- Harness registry exists (`apps/gateway/src/harness/`), the validation
source for the harness field.
- Implementation pattern: the merged hierarchy module (M4-1) —
transaction-scoped command context, in-tx authorization, discriminated
result unions, same-transaction semantic audit event + transactional
outbox, no-oracle not_found folding.
**F1 — contract-5 mapping note (disposition, not an amendment):**
`/api/agents` appears nowhere in contract 5 — neither as a P0 row nor in
the §3.2 legacy non-substitutes list (the ruled §3.2 freeze names
specific endpoints, and `/api/agents` is not among them). The operative
constraints are §3.3's amendment-only rule for new mapping rows and §5's
closure rule: this design adds no new consumer to `/api/agents` and
builds the rank-4 family as the P1 path for enrollment. Adding the
missing P0 row is a contract amendment for a future S2 pass; nothing in
M4-4 depends on it.
## 3. Command family surface (v1)
One command, one query. Module: `apps/gateway/src/enrollment/`
(`enrollment.module.ts`), mirroring the hierarchy module's shape.
### 3.1 `agent.enroll` (mutation)
Request DTO (shared types package, class-validator at the boundary):
| Field | Type | Rule |
| ---------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `harness` | string | syntactically invalid (empty/malformed) → `validation_failed`; well-formed but not in the harness registry → `precondition_failed` |
| `correlationId` | string (uuid) | optional; generated when absent (contract 5 §4.3); carried into audit events and returned in the result |
| `replayMode` | 'actor-bound' | optional, default `actor-bound`. `shared` is seed-only (contract 3 §4.3 binds it to the §3.4 canonical seed key set and "no other operation can carry a shared declaration"; §7 item 4 closes it); a `shared` declaration here is refused `validation_failed`, executes nothing, and records no fence row |
| `name` | string | non-empty, trimmed, ≤ 200 chars |
| `persona` | string \| null | optional; stored as the agent's system prompt |
| `model` | string | non-empty (provider-qualified model id) |
| `provider` | string | non-empty; names the credential's provider |
| `credential` | discriminated union | `{ mode: 'reference' }` — a credential for (actor, provider) MUST already exist; `{ mode: 'intake', type: 'api_key', value: string }` — value is sealed into the credential store in the same flow |
| `idempotencyKey` | string (uuid) | required (contract 3 §4.3, ratified into contract 5 §4 via contract 3 §7 item 4) |
Rules:
1. **Never echoed.** The credential value appears in no result DTO, no
audit event, no outbox payload, and no log line. The result carries
only `{ provider, credentialMode }`.
2. **Intake = the existing sealed store, inside the transaction.**
`intake` writes through the sealed-store path
(`ProviderCredentialsService.store` semantics: seal-at-rest, upsert
per (userId, provider)) **in the same transaction** as the agent
insert — a failure after the credential write rolls everything back,
leaving no orphan credential. Enrollment persists no second copy and
no plaintext.
3. **Reference must resolve.** `reference` with no stored credential for
(actor, provider) refuses with `precondition_failed` (nothing is
created).
4. **Ownership.** `owner_id` = the authenticated actor. v1 authorization
is AuthGuard-authenticated user; no hierarchy grant is required
because v1 enrollment binds no hierarchy node (§1 assignment-scope
pin). `is_system` is never settable through this command.
5. **Idempotency fence (contract 3 §4.3, in full).** The command layer
records, in a uniqueness-constrained fence table in the same
transaction as the mutation and its audit event: the key, the
operation identifier (`agent.enroll`), the acting principal, the
authorization scope, a digest of the canonicalized request payload
(the digest input EXCLUDES the credential value — it covers
provider + credentialMode, never plaintext), the declared replay
mode (always `actor-bound` for this family — the `shared` refusal
in the table above means no shared fence row can exist here; the
column is kept for envelope-shape fidelity and mode-mismatch
collision checks), and a reference to the committed outcome (the
agent id). The recorded **authorization scope** for this family is
pinned to the acting principal's platform-user scope (v1
authorization is grant-free per rule 4, so the scope is the
authenticated-user identity domain — recorded so the §4.3
scope-equality check has a defined value). Fence uniqueness is the
pair (operation identifier, key). **Replay:** a submission whose
(operation, key) is recorded is first authorized exactly as a fresh
submission; then replay-mode, scope, and digest equality are
checked (a mismatch on any — including scope — is a collision);
then **target-result authorization** — the submitter must hold, at
replay time, read authority on the referenced agent row under
§3.2's rule (owner or admin) — plus recorded-actor equality
(`actor-bound`). A passing replay executes nothing, returns the
recorded outcome, and appends a replay access event (non-mutation
audit class: accessing principal, current correlation id,
fence-row reference). Any equality or authorization failure refuses
with the single bounded `conflict` shape — constant, identifying no
record — preserving the no-existence-oracle rule. **Concurrency
(contract 3 §4.3's rule, ratified via §7 item 4):** two submissions
with the same (operation, key) serialize on the fence's unique
constraint — exactly one executes; the loser waits for the winner's
transaction, and is then handled as a replay if it committed
(through the full replay path above) or executes afresh if it
aborted. A unique-violation race never surfaces as an unhandled
internal fault.
6. **Audit + outbox, same transaction.** Insert into `agents` +
sealed credential write (intake mode) + fence row + semantic audit
event (`agent.enrolled`: actor, agent id, harness, provider, name,
credentialMode — no credential material) + outbox row commit
atomically, hierarchy-pattern style. Audit rows reference the agent
by **snapshot id, not FK** — mirroring the hierarchy audit tables'
deliberate FK-free linkage so audit history survives agent deletion
through the legacy CRUD DELETE path.
Result union: `enrolled { agent, correlationId }` | refusal from the
§3.3 enum (refusals also carry the correlation id, per contract 5
§4.3's end-to-end traceability). `agent` in the result is the persisted
row minus nothing sensitive (the table stores no credential material).
### 3.2 `agent.enrollment.get` (query)
By agent id; actor must be the owner (or admin). Unauthorized and
missing fold to the same `not_found` wire shape (contract 2
no-existence-oracle rule, applied family-wide for uniformity).
The query carries the same non-state envelope as the mutation
(contract 5 §4.3; contract 3's envelope reconciliation confirms closed
query responses carry it): typed request DTO with an optional
`correlationId` (generated when absent) and a typed result —
`found { agent, correlationId }` | `not_found` (the folded shape,
also carrying the correlation id). Queries take no idempotency key
(the fence binds mutations).
### 3.3 Error enum (closed, §4.2)
`validation_failed` 400 · `authentication_failed` 401 ·
`authorization_refused` 403 (owner-only paths; folded to `not_found`
where §3.2 applies) · `not_found` 404 · `conflict` 409 (the single
bounded idempotency refusal shape of §3.1 rule 5) · `precondition_failed`
422 (unresolvable credential reference; well-formed harness not in the
registry — syntactic invalidity is `validation_failed` per the §3.1
table) · `internal_fault` 500 (also the §4.4 fail-closed class when the
owning tool is unreachable; unauthorized-fallback behavior is
prohibited).
## 4. Schema delta (migration 0021, additive-only)
Extend `agents` — no new agent table, preserving custody-schema §5.2's
FK binding without amendment:
- `harness` text NULL — registered harness name; NULL for pre-existing
rows (legacy rows predate the concept).
- `enrolled_at` timestamptz NULL — set by `agent.enroll`; NULL marks a
legacy (non-enrolled) row. No backfill: enrollment is a fact this
command creates, not one to invent for existing rows.
New tables, mirroring the hierarchy audit/outbox pair (pattern reuse,
separate store): `agent_audit_events` (append-only: id, event_type,
actor id, agent id — snapshot value, no FK, per §3.1 rule 6 —
correlation id, causation id, payload jsonb, created_at; per-agent
ordering index), `agent_outbox` (hierarchy-outbox shape), and
`agent_idempotency_fence` (contract 3 §4.3 shape: operation identifier,
key, acting principal, authorization scope, canonicalized-payload
digest, replay mode, committed-outcome reference (agent id), created_at;
UNIQUE (operation identifier, key)). Persona reuses the existing
`system_prompt` column; no version column (no ratified expected-version
rule names `agents` — §4.1 binds only where the owning contract defines
one).
Witnesses (real PostgreSQL, lane standard): append-only enforcement,
same-tx atomicity (agent row + credential write + fence row + audit +
outbox all-or-nothing under injected failure at multiple points,
including after the credential write), fence uniqueness on
(operation, key).
Sequencing: additive DDL via the same migration path as 00180020
(hierarchy). The docs/native-kanban-sot/SHARED-CONTRACT.md §5.3 DDL
gate binds the kanban lane's audit/proposal DDL, not this lane; if a
pending operator ruling on migration sequencing changes mechanics
lane-wide, re-check before generating 0021.
## 5. Witnesses the implementation slice must ship
1. Never-echo: enroll via `intake`, assert the value string is absent
from the HTTP result, the audit row, the outbox payload, and captured
logs.
2. Sealed-store single-copy: after intake, the credential exists only in
`provider_credentials` (sealed), and `agents` has no credential
column at all.
3. Reference-resolution refusal (`precondition_failed`, no row created).
4. Harness refusals, both codes: syntactically invalid →
`validation_failed`; well-formed registry miss →
`precondition_failed` (against the live registry).
5. Idempotency (contract 3 §4.3 set): actor-bound replay returns the
recorded outcome and executes nothing (no new agent/audit/outbox
mutation rows; a replay access event is appended); payload-digest
mismatch, replay-mode mismatch, scope mismatch, and different-actor
actor-bound replay each refuse with the single bounded `conflict`
shape; a replay is re-authorized fresh (a submitter whose
authorization was revoked since the original is refused, not
replayed); a `shared` declaration on `agent.enroll` is refused
`validation_failed` with nothing executed and no fence row
recorded (seed-only rule); two concurrent same-(operation, key)
submissions produce exactly one mutation, the loser resolving
through the replay path (no unhandled unique-violation fault).
6. Same-tx atomicity fault injection (agent / credential write / fence
/ audit / outbox), including a failure injected after the intake
credential write commits its statement — everything rolls back, no
orphan credential.
7. Wizard-facing zero-mutation witness (contract 3 §6.10 shape): no
call → zero rows in `agents`/`agent_audit_events`/`agent_outbox`/
`agent_idempotency_fence` attributable to the family.
8. `is_system` injection attempt is rejected by DTO validation.
9. Correlation-id witness (contract 5 §6.3): a correlation id submitted
on `agent.enroll` appears in its audit event(s) and in the result;
the same holds for `agent.enrollment.get`'s result; the §6.3 static
companions (no `any`-typed boundary pass-through; single audit
emitter) apply. §6.3's no-existence-oracle probe: an unauthorized
`agent.enrollment.get` of an existing agent and a get of a
nonexistent id return indistinguishable results.
10. CLI-parity witness (contract 5 §6.4): a CLI smoke invocation of
`agent.enroll` and `agent.enrollment.get` against the Gateway
succeeds with the same typed results the web client receives. The
implementation slice therefore SHIPS CLI exposure for both
operations (contract 5 §4.5 — a Gateway command without CLI
exposure is a tracked conformance gap; this design refuses to open
one).
11. Fail-closed witness (contract 5 §6.5): with the owning tool or
grant state unreachable (fault injection), the operation returns
the internal-fault or authorization-refusal class and performs no
fallback read/write.
## 6. Out of scope
Wizard orchestration (M4-6); any UI (D8/D12); un-enroll/update lifecycle
(no contract requires it in v1 — the legacy write surfaces named in §2
keep serving existing consumers); OAuth login, multi-account, comms
auto-enroll, model recommendation (PRD full flow, deferred by D11);
contract amendments (F1 recorded above for a future S2 pass). CLI
exposure is explicitly IN scope (witness 10 — contract 5 §4.5 binds it).
-99
View File
@@ -1,99 +0,0 @@
# Plan — Stack Containerization (tiered deployment)
Status: DRAFT for review. Charter: fleet/lanes/stack-containerization
(brain) NORTH-STAR.md; PRD amendment in the same PR adds D15.
Supersedes nothing; sequences the absorbed M4 remainder per its lane.
## Measured baseline (origin/next @ 143ba0f5, 2026-08-30)
- `docker-compose.yml`: dev infrastructure only — postgres (pgvector),
valkey, otel-collector, jaeger. No application services.
- `docker-compose.federated.yml`: standalone overlay for the FEDERATED
storage tier (own postgres/valkey; port-conflicts the base stack by
design). Not an app deployment.
- `docker/gateway.Dockerfile`, `docker/appservice.Dockerfile`:
multi-stage production builds (node:22-alpine) EXIST; the gateway image
includes the web SPA bundle (#1444).
- CI (`publish.yml`) builds and publishes these images (next-channel
prereleases + main stable), and runs `verify:release` fail-closed.
- Gap: no stack-level composition wires gateway+appservice+data plane
into one deployable unit; no blessed install/upgrade path; no
in-container agent-runtime story for the dogfood loop.
## Target (PRD D15 amendment)
Tiered deployment, additive to the existing architecture:
1. **Standalone tier (v1 bar)**: `docker compose up` on one host brings
postgres, valkey, openbao, gateway, appservice (and the webUI the
gateway serves) to healthy; migrations apply; the webUI hosts agent
chat; an in-stack agent can read this repo and open a PR; CI
validates; the deployment adopts merged images (pull + restart).
2. **Enterprise tier (post-v1)**: Kubernetes manifests (or Helm) for the
same service set, phase-gated on the standalone bar holding.
## Phases
### Phase A — blessed standalone compose
- A1 Compose service definitions for gateway + appservice joining the
existing infra compose (profiles: `dev` keeps today's behavior;
`stack` adds the app tier), with health checks and dependency order.
- A2 Migrations on boot (or an explicit migrate step) with idempotency
and version pinning; init-db.sql folded into pg-init.
- A3 Openbao in the compose set (secret plumbing for the app tier).
- A4 `.env.example` + `mosaic.config.json` defaults documented for the
standalone mode; mode recorded per the mode-conversion contract.
- A5 Smoke: `docker compose --profile stack up` green on a scratch host;
webUI served; agent chat reachable; failures catalogued and fixed.
- Acceptance: the five-point NORTH-STAR bar measured live.
### Phase B — component completion
- Interface assumption (velma verdict A1, P5-RM-005/006): in-stack
dogfood agents inherit SEAT-GRADE identity — credential-slot
isolation, wrapper-first enforcement, no privileged coordination
identity, evidence by references that resolve outside the container
lifetime.
- Decompose JIT from A5's catalogue. Known candidates: agent runtime
bits (brain/tool access paths in-container), repo credentials for the
dogfood agent, watch/comms surfaces inside the deployment.
### Phase C — CI/CD parity
- Publish pipeline is the only image source (already true); add the
deployment-side pull/upgrade path (compose pull + migrate + restart =
next iteration); document the promotion flow next -> registry ->
deployment.
### Phase D — coordinator integration (GATED)
- Gate (velma verdict C2): blocked until the checkpoint-and-lease child
of the guides-proposed control-plane refactor — core + WU-P1-CHECKPOINT
(schema, freshness, incarnation, clean-replacement resume; D57-D60
lineage) — carries an independent target-bound PASS. Wiring restarts
against the core alone re-creates the stale-incarnation failure class
D57-D60 closed. Transitive: inherits the T108 gates (P0 exit + Jason
P1 authorization).
- Scope (velma verdict C1): lifecycle actions (start/stop/restart/
health/recovery) executed by the SHIPPED coord client over the one
typed coordination contract (request id, actor identity, epoch,
revision, lease, correlation; typed stale rejection; worker role
boundary). No second coordination interface gets designed here —
containerization consumes the coordination contract, never defines it.
### Phase E — enterprise tier
- k8s manifests/Helm for the same set; phase-gated on Phase A holding.
### Absorbed M4 remainder
- M4-3 pivot: KBN-101 foundation first (per ruling R6), then expand DDL.
- M4-5: lands inside Phase B/C where natural.
- M4-6 (composes M4-1+M4-4): last, as designed.
## Non-goals (v1)
- No Kubernetes in v1; no multi-host federation; no replacement of the
fleet's brain-based seats (the stack is an additional operator
surface); no on-host image builds for deployment (registry only).
-4
View File
@@ -13,10 +13,6 @@ status: active
- [Documentation structure README implementation](2026-08-10-docs-structure-readme.md) — completed implementation plan for the documentation contract and atlas.
- [Documentation catalog and truth audit](2026-08-10-docs-catalog-audit.md) — audit method, evidence statuses, deliverables, and acceptance criteria.
## Feature design plans
- [Agent enrollment command design](2026-08-29-agent-enrollment-command-design.md) — v1 rank-4 enrollment command family: contract composition, command surface, schema delta, witnesses (M4-4-0).
After a plan is delivered, update the canonical guide, contract, decision, or index. Do not cite a plan as proof that intended behavior shipped.
## Related
+1 -1
View File
@@ -8,7 +8,7 @@ status: active
> Single-writer: the RI-050 orchestrator (jarvis, dragon-lin) only. Workers read but never modify.
>
> **Mission:** alpha 0.0.50 release-integrity floor (decisions SDLC-D-033..038).
> **PRD:** [PRD rev1 GOV.4 § Release Integrity Workstream](../PRDs/2026-08-31_PRD_rev1/GOV.4-workstream-contracts.md#release-integrity-workstream-ri-1275)
> **PRD:** [docs/PRD.md § Release Integrity Workstream](../PRD.md#release-integrity-workstream-ri-1275)
> **Issue:** #1275 (remains open until RI-V-001 closes)
> **Base branch:** `next` (all cards branch from `origin/next`, squash-merge via PR)
>
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.
-279
View File
@@ -1,279 +0,0 @@
# Deployment Mode and Conversion Contract (D3)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 6 of 9).
Authority: PRD D3 (Part I §3) — two modes chosen at install time,
Standalone and Enterprise, with the mode table (brains, user-data
isolation, secrets, conversion); Standalone → Enterprise conversion is
**one-way** and Enterprise is a **terminal state**. PRD D14 (Part I §7)
— the per-user brain split is optional in Standalone and keeping it is
the recommended default because it preserves forward-compatibility with
the one-way conversion. PRD D11 (Part I §9) — v1 ships the Standalone
flow only; Enterprise conversion is explicitly deferred. PRD D3
federation clause — federation is intentionally not fully designed,
deferred, and nothing in v1 may foreclose it.
Revision 2 (luna review F1F7): the identity precondition restated in
identity-contract terms with a conversion-local acknowledgment record
this contract owns (F1); a durable, keyed preparation state with a
Standalone-safe representation rule, an in-transaction re-check fence,
and an exact flip boundary (F2); the §5.4 unknown-value rule stated
directly without the contradictory non-exhaustiveness clause (F3); the
D14 boundary bound here with a stable column-allowlist witness instead
of delegated to an unratified layout (F4); the conversion witness
matrix extended to every §4.2/§4.4 condition (F5); the mode-record
writer coverage imported concretely from contract 1 §6.3 with a named
schema, closed writer set, crafted-write probe, and mode-resolution
assertion (F6); the mode read command flagged as a §12.1 drafting
addition rather than a D8 mandate (F7). Ownership language aligned
with contract 3 revision 2: mode is recorded at bootstrap and read by
the wizard as input.
This contract binds the mode as a canonical platform property (§2), the
per-mode obligations and which contract owns each (§3), the conversion
transition (§4), the v1 non-foreclosure obligations (§5), and their
witnesses (§6). Domain semantics stay with their owning contracts:
wizard branching (contract 3 §2), identity/SSO
(`identity-lifecycle.md`), custody and per-user brain mechanics
(contract 7, `custody-schema.md`), tool mapping
(`tool-gateway-mapping.md`).
## 1. Definitions
1. **Mode**: the platform-wide deployment mode, exactly one of
`standalone` or `enterprise`. The vocabulary is closed in v1;
extension (e.g. a federation mode) is by amendment to this contract,
never ad hoc.
2. **Conversion**: the one-way transition `standalone → enterprise`.
No other mode transition exists.
3. **Conversion preconditions**: the verifiable conditions of §4.2 that
must all hold before the mode record may change.
4. **Preparation unit**: one re-runnable piece of pre-conversion work —
the migration of one secret to the Vault backend, or the partition
of one user's brain content (§4.3).
## 2. Mode is a canonical recorded property
1. Mode is recorded canonically in the platform database at bootstrap
as the operator's install-time choice (D3: modes are "chosen at
install time"). The record is a single-row keyed record
(`platform_mode`: mode value, recorded-at timestamp, bootstrap epoch
reference); this contract owns it, the bootstrap writer performs the
one v1 write (§6.2), and the wizard reads it as input (contract 3
§2.3). Mode is never derived from feature state (presence of Vault,
count of brains, count of users), and no component may infer a
different mode than the record states.
2. The record is readable by any authenticated user through a Gateway
command with CLI exposure. This read command is a **drafting
addition** ratified with this contract (PRD §12.1), not a D8
mandate: D8 binds only that any surface exposing the value goes
through official tooling. When a webUI surface consumes the read, a
mapping row is added to `tool-gateway-mapping.md` by amendment —
the same route §4.4 already binds for the conversion command.
Components branch on the read value only.
3. The record is immutable except by the §4 conversion transition.
Editing it by direct database access, config file, environment
variable, or wizard re-run is non-conformant (contract 3 §2.3:
changing mode later is conversion, not a wizard re-run).
## 3. Per-mode obligations (owner map)
The PRD mode table binds four rows; this contract assigns each an
owning contract so no obligation is unowned and none is bound twice:
| Obligation | Standalone | Enterprise | Owner |
| ------------------- | -------------------------------------- | -------------------------------------------------- | --------------------------------------------- |
| Brains | one mosaic-brain (system + user files) | system brain for config + one brain per user | contract 7 (custody/brain mechanics) |
| User-data isolation | single user | no user-data leakage between users; sharing opt-in | contract 7 (enforced by architecture, D14) |
| Secrets | OpenBao/Vault or flat files | OpenBao/Vault REQUIRED | this contract (§4.2 gate; steady-state check) |
| Conversion | may convert to Enterprise, one-way | terminal state | this contract (§4) |
The Standalone brains row states the default layout, not the only
valid one: the D14 per-user split is a MAY in Standalone with keeping
it the recommended default (PRD §7, contract 7 §6), and Vault-backed
secrets are equally valid Standalone configuration. Both prepared
states are therefore themselves valid Standalone states — the fact
§4.3 relies on.
In Enterprise steady state, a flat-file secrets backend is
non-conformant; the platform refuses to start Enterprise-mode
components against a flat-file secrets configuration (fail-closed, not
warn-and-run).
## 4. Conversion transition
1. **Direction and terminality.** The only transition is
`standalone → enterprise`. `enterprise → standalone` does not exist:
there is no command, no admin override, and no support path. An
attempt is refused with the precondition/state error class of the
command envelope (`tool-gateway-mapping.md` §4.2).
2. **Preconditions (all verified before the record changes):**
- Secrets: OpenBao/Vault is configured and reachable, and every
required secret is served from the Vault backend — none from a
flat-file backend. Secret migration completes before conversion;
this contract does not define the migration tooling, only the
gate.
- Brains: the per-user brain split required by the Enterprise row of
§3 is established for **every** existing user (or the deployment
already kept the split, the D14 recommended default). Brain
partitioning mechanics are contract 7; this contract binds only
that the split is complete before the mode flips.
- Identity: at least one platform administrator account exists that
is active in identity-contract terms — authenticated capability,
not banned, not deactivated (identity §2, §5). And the conversion
request carries a **configuration acknowledgment**: the current
canonical values of registration mode and per-provider JIT
enablement (identity §2.2, §4.1), echoed back in the request. A
mismatch between the echoed values and the canonical values at
verification refuses the conversion. This acknowledgment record
is conversion-local, owned by this contract, and stored with the
§4.4 audit event as the precondition evidence; it adds no
identity-contract obligation and no mode-specific identity
default — identity's own defaults remain valid states.
3. **Preparation state and the flip boundary.** Preparatory work is
tracked durably: each preparation unit (§1.4) records its
completion in a preparation table keyed by (bootstrap epoch, unit
identity — the secret's path, the user's id), written in the same
transaction as the unit's own effect where the unit's backend
allows it, and reconciled from the backend's actual state where it
does not (a secret already served by Vault, a brain already split,
is complete regardless of the table). Units are at-most-once per
key and re-runnable across attempts. **Standalone-safe
representation:** every preparation unit moves the deployment into
a state that is itself valid Standalone configuration (§3 note), so
an interrupted preparation leaves a fully operational Standalone
deployment reading its state through the ordinary contracts — no
rollback, fencing, or special Standalone read path is needed, and
no component behavior may key on "preparation in progress".
**The flip:** one transaction that (a) locks the mode record, (b)
re-verifies every §4.2 precondition after acquiring the lock, and
(c) writes the mode record and the §4.4 audit event. Any re-check
failure aborts with no write. External state that changes after the
re-check but before commit is bounded by the transaction window;
an external backend (Vault) failing after conversion is an
Enterprise runtime fault handled by §3's fail-closed steady-state
rule, not a conversion defect. An interrupted or failed conversion
leaves the record `standalone` and the platform fully operational;
there is no intermediate mode and no half-converted state
observable through the record.
4. **Authority and audit.** Conversion is a platform-administrator
command carrying an explicit irreversibility acknowledgment in its
request (distinct from the §4.2 configuration acknowledgment). It
is an official Gateway/CLI command (D8): when built, it is added to
the tool↔Gateway mapping by amendment (`tool-gateway-mapping.md`
§3.3). The transition emits an audit event (actor, prior mode, new
mode, precondition evidence reference including the configuration
acknowledgment) in the same transaction as the record change; the
event survives indefinitely. A refused attempt emits a refusal
event naming the failed precondition class and actor, with no
mode-change event.
## 5. v1 obligations (non-foreclosure)
v1 ships Standalone only (D11); the conversion command is deferred
work. v1 still MUST:
1. Record the mode per §2 at bootstrap, with `enterprise` a reserved,
refused value for bootstrap — v1 bootstrap accepts `standalone`
only. The wizard reads the record (contract 3 §2.3); nothing in v1
writes it after bootstrap.
2. Keep the §2.3 immutability rule: no v1 surface mutates the mode
record.
3. Not foreclose conversion: the v1 platform database holds no
sensitive user content — sensitive categories live in the owning
user's brain, and postgres holds structure, consent records, and
pointers only (the D14 boundary, PRD §7). Custody mechanics are
contract 7's; this contract binds the boundary itself here so v1
cannot ship a layout that makes the §4.2 brain precondition
unsatisfiable, and §6.3 gives it a stable witness that does not
depend on contract 7's internals. Conversion implementation
additionally requires contract 7 ratified.
4. Not foreclose federation: v1 components accept exactly the two §1.1
values wherever a mode value is parsed and refuse any other value
**before side effects** — a refused configuration, not undefined
behavior and not a crash mid-operation. Forward compatibility lives
in storage and architecture, not in parser speculation: the mode
record's storage is not structurally locked to two values (no
database-level two-value enum), and any future value (e.g. a
federation mode) is defined by a versioned amendment to this
contract before any component accepts it. The PRD defers
federation's shape entirely; this contract does not presume it
arrives as a third mode value.
## 6. Verification requirements
Binding on the implementing PRs:
1. **Mode-record witness (v1):** after bootstrap the mode is readable
via the Gateway command and CLI and equals the bootstrap-recorded
choice; bootstrap with mode `enterprise` is refused; bootstrap with
any unknown mode value is refused before side effects (§5.4).
2. **Writer-coverage witness (v1):** the mode record's writer set is
closed by the same three-prong static assertion contract 1 §6.3(b)
defines — symbol, class-table literal, and raw-execution prongs
with its allowlist composition rules — scoped to the
`platform_mode` table, with a writer allowlist containing exactly
the bootstrap writer in v1 (and exactly plus the conversion command
at the conversion milestone). Companions: a crafted direct write
attempted in a test fails and leaves the record unchanged; a
mode-resolution assertion that no shipped component derives mode
from feature state (mode reads occur only through the §2.2 read
surface — static assertion over Gateway, CLI, bootstrap, and
repository sources).
3. **D14-boundary witness (v1):** a column-allowlist assertion in the
style of contract 1 §6.2 that the platform database schema contains
no sensitive-content column — the §5.3 boundary — stable regardless
of contract 7's internals (contract 7 §7 carries the full custody
witnesses).
4. **No-downgrade witness (conversion milestone):** with mode
`enterprise`, a conversion request to `standalone` (and any crafted
mode-write) is refused with the precondition/state error class and
no record change.
5. **Precondition witnesses (conversion milestone),** each refused
with no record change and no partial mode effect, parameterized
over both OpenBao and Vault where secrets are involved:
(a) secrets backend unreachable; (b) one required secret still
flat-file backed (migration incomplete); (c) one unpartitioned user
brain in a **multi-user** deployment where every other user is
partitioned; (d) no active platform administrator (the only admin
banned or deactivated); (e) configuration acknowledgment missing or
mismatching the canonical registration/JIT values; (f) actor not a
platform administrator (authorization refusal); (g) irreversibility
acknowledgment absent. And the steady-state rule: an
Enterprise-mode component started against a flat-file secrets
configuration refuses to start (§3).
6. **Interruption and fence witnesses (conversion milestone):** fault
injection aborting conversion after each preparation unit and
between preparation and flip leaves the record `standalone` and the
platform operational in Standalone semantics (§4.3
Standalone-safety), and a re-attempt completes without duplicating
prepared state (at-most-once keys); a precondition invalidated
after preparation but before the flip (a secret reverted to
flat-file) is caught by the in-transaction re-check and refused.
7. **Audit witnesses (conversion milestone):** a completed conversion
has exactly one mode-change audit event, same-transaction with the
record change (transaction linkage asserted), carrying actor, prior
mode, new mode, and the precondition evidence reference including
the configuration acknowledgment; a failed attempt has a refusal
event naming the failed precondition class and no mode-change
event; the mode-change event remains queryable after subsequent
unrelated audit activity (retention probe).
8. **Mapping witness (conversion milestone):** the conversion command
and the mode read command each have their
`tool-gateway-mapping.md` row (added by amendment per §2.2/§4.4)
before the commands ship.
## Ruling request
Ratify sections 16 as written, with one decision embedded:
- Decision (§5): v1 implements the **mode record and its immutability
only** — bootstrap records `standalone`, the `enterprise` value is
reserved and refused, and the conversion command itself is deferred
to the Enterprise milestone, consistent with D11's deferred list.
v1 carries three obligations beyond the record: the closed writer
assertion, the D14 column boundary, and the unknown-value refusal
(§6.1–§6.3) — these are the non-foreclosure floor, not hidden
conversion work. Alternative if rejected: build the conversion
command inside v1 — rejected because D11 scopes v1 to the Standalone
slice and conversion depends on contract 7 custody mechanics that
are themselves not in the v1 slice.
-102
View File
@@ -456,105 +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.
## 10. Amendment A3 — capability-holder existence disclosure
**Status:** amendment to Amendment A2, added by reviewed PR together with
contract 2 Amendment 1 (`rbac-grant-model.md` §8, this PR), under that
amendment's ruling request (decision owner Jason). It binds if and only if
contract 2 Amendment 1 ratifies; until then §9.1.2's sole-disclosure rule
stands unmodified — which is consistent, because until ratification the
company-CRUD capability class is empty and the carve-out below has no
holders. Everything in §§19 remains binding verbatim, with exactly the one
express modification below. The detailed contract text lives in
`rbac-grant-model.md` §8.1; this amendment changes only what A2 itself
permits, so that contract does not stretch A2 by interpretation.
### 10.1 What A3 modifies in A2
1. **Capability-holder disclosure (narrows §9.1.2's sole-disclosure rule
by one carve-out).** §9.1.2 makes the directory the sole permitted
existence disclosure and keeps private companies undisclosed to
non-granted subjects everywhere. A3 admits exactly one further
disclosure channel: a subject holding the company-CRUD capability
(contract 2 §8), when exercising the hierarchy schema §5.5 visibility
command, learns the target company's existence and its old/new
visibility values through the command's redacted actor receipt —
success for an existing target (private or directory alike) versus
`not_found` for a nonexistent id — bounded exactly as contract 2 §8.1
states: no name, slug, structure, content, grant, or membership
information, and no read command of any kind. To every other
non-granted subject, private companies remain undisclosed everywhere,
including the directory; the directory remains the sole
existence-disclosure _listing_.
### 10.2 What A3 explicitly does not change
1. The directory itself is unchanged: read-only, directory-class companies
only, existence/name/slug only (§9.1.2's enumeration is narrowed for
capability holders' receipts, widened for nothing).
2. No join-request surface, no curation listing, no read command of any
family is authorized (§9.2.2 unchanged; a curation listing is a further
amendment per contract 2 §8.1).
3. The canonical audit event for visibility mutations is untouched — it
keeps hierarchy schema §5.2's full immutable target snapshot; the
capability confers no audit read (contract 2 §8.5).
4. Every other constraint of A1 and A2 is untouched.
+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:
-478
View File
@@ -1,478 +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".
Amendment 1 (company-CRUD capability): defines the capability class that
contract 1 Amendment 1 (Ruling 4b, 2026-08-28) and hierarchy schema §5.5
anticipate. §8 defines the capability as a platform-scoped, admin-assigned,
audited delegation of exactly the hierarchy schema §5.5 company visibility
command — no read command, no other company operation; the mutation's
inherent existence disclosure is ratified as a bounded carve-out to
hierarchy schema §6.7/§2.8 and to kanban SOT Amendment A2's
sole-disclosure rule — SOT Amendment A3 (native-kanban-sot.md §10, this
PR) expressly extends A2 by exactly this carve-out (§8.1). The holder
sees only a redacted actor receipt; the canonical audit event keeps
hierarchy schema §5.2's full immutable snapshot. The hierarchy role
vocabulary (§2), every evaluation rule (§3), and grant management (§4) are
untouched: the capability is not a `hierarchy_grants.role` value and
evaluates outside the chain; capability-row deletion joins §6.1's
revocation enumeration (§8.4). Until this amendment ratifies, the capability
class is empty and
the visibility command remains admin-only (hierarchy schema §5.5 states
this fallback; the shipped gate at
`apps/gateway/src/hierarchy/hierarchy.repository.ts` implements it).
Scope: the roles that can appear in `hierarchy_grants.role`, what a grant at
each hierarchy level confers, how grants evaluate down the chain, how
revocation propagates, and who may manage grants. Out of scope: the hierarchy
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).
## 8. Company-CRUD capability (Amendment 1)
Hierarchy schema §5.5 authorizes the company visibility mutation for
exactly two actor classes: platform admins and "subjects holding the
company-CRUD capability that a follow-up amendment to contract 2 will
define". This section is that definition. The name is historical — coined
in contract 1 Amendment 1 before the capability's content was fixed — and
confers nothing by connotation: the ratified content is exactly §8.1.
Company _creation_ is already ruled open to active users and always
private (contract 3 §5.2, Ruling 4); rename, delete, and transfer of
companies remain hierarchy `owner` operations (§2.3, §5); none of those is
part of this capability, and widening it to any other operation is a
further amendment, not an implementation decision.
1. **Content: exactly one command, no read command, disclosure stated.**
Holding the capability authorizes executing the hierarchy schema §5.5
visibility command (`companies.visibility`, both directions:
`private → directory` and `directory → private`) on any company in the
deployment, and no other command of any family. It confers **no read
command**: no company enumeration, no curation listing, no structure
read. The practical flow this implies is deliberate: to publish a
private company, the holder is given the target identifier by the
requesting company `owner` out of band; to unpublish, the target is
already directory-listed. A curation listing for capability holders,
if ever wanted, is a further amendment with its own disclosure
analysis under hierarchy schema §6.7.
**Existence disclosure carve-out, stated rather than pretended away:**
exercising a mutation inherently discloses its target's existence.
The command's result distinguishes an existing company (success, for
private and directory targets alike) from a nonexistent id
(`not_found`), so a holder presenting candidate ids learns existence —
exactly as a platform admin already does through the same command.
This amendment ratifies that disclosure as part of the §5.5 curation
authority, bounded as follows. The holder-visible surface is the
command's **actor receipt** — the mutation result payload, carrying
exactly the target id, old visibility, and new visibility, and
**nothing else**: no name, slug, structure, content, grant, or
membership information. The actor receipt is a redacted projection
distinct from the **canonical audit event**, which is unchanged by
this amendment: it keeps hierarchy schema §5.2's deletion-safe
immutable target snapshot (id, slug, and parent chain at event time)
in full. The two never converge on the holder: the capability confers
no audit read (§8.5), so the canonical event — and with it the slug
and parent chain — is reachable only by subjects independently
authorized to read audit data, never through this capability. A
successful publish additionally makes the target directory-listed to
every authenticated user; that is the command's ratified purpose
(hierarchy schema §5.5), not a leak. Hierarchy schema §6.7's
existence-oracle rule and §2.8's directory-only disclosure are amended
by exactly this carve-out for capability holders, kanban SOT Amendment
A3 (native-kanban-sot.md §10, this PR) expressly extends A2's
sole-disclosure enumeration by the same carve-out, and all three are
otherwise untouched. Witnessed in §8.6.3.
2. **Holding: platform-scoped assignment, user subjects only.** The
capability is not a hierarchy grant: it attaches to no node, has no
role, and never enters §3 chain evaluation. It is held via a
`platform_capabilities` table whose column set is exactly (nothing
else, per the contract 1 §2.7 exhaustiveness discipline):
- `id` — uuid, primary key;
- `user_id` — text, NOT NULL, FK `users` **ON DELETE RESTRICT**;
- `capability` — text, NOT NULL, constraint-checked against exactly
`company_crud`;
- `granted_by` — text, NOT NULL, FK `users` **ON DELETE RESTRICT**;
- `created_at` — timestamptz, NOT NULL;
- UNIQUE (`user_id`, `capability`).
The user FKs are **text**, not uuid, because `users.id` is a BetterAuth
text key (`packages/db/src/schema.ts`; custody schema records the same)
— PostgreSQL cannot reference a text primary key with a uuid column.
This matches the shipped `hierarchy_grants` shape exactly: uuid
surrogate `id`, text FKs to `users`.
Both user FKs are RESTRICT for the same reason contract 1 §3.3 pins
RESTRICT on principal FKs: the identity contract (§7.3) gates user
deletion, and a cascade here could silently destroy a capability
without its §8.3 revocation audit event. Revocation is row deletion
through the §8.3 command — there is no other removal path, no expiry
column, and no tombstone. A deactivated holder confers nothing while
deactivated: identity contract §7.1 denies all authorization to
deactivated accounts, and the §8.4 predicate evaluates on the
authenticated live user. No team subjects (§1.4's suspension reasoning
applies with more force here — a workspace-bound team holding
deployment-wide curation authority has no ratified meaning).
3. **Assignment is instance administration on the normal admin surface.**
Only platform admins (`users.role = 'admin'`) may assign or revoke the
capability, through an ordinary admin command (the same command class
`AdminGuard` governs, §1.1) — not through direct table writes.
Assignment delegates a slice of instance administration and is itself
an instance-administration act under §1.1. A capability holder as such
may NOT assign or revoke it (no self-propagation). Every assignment
and revocation is a semantic audit event carrying actor, verb, subject
user, and capability; serialized capability strings are namespaced per
§4.5 (`platform-capability:company-crud` — a bare `company_crud` in
any serialized artifact is non-conformant).
4. **Evaluation and revocation follow this contract's existing rules.**
The hierarchy schema §5.5 command's authorization predicate is:
`users.role = 'admin'` OR a live `platform_capabilities` row
(`user_id`, `company_crud`). Both disjuncts are evaluated live and
fail closed per §3.5 — **independently**: with capability state
unreadable (fault), the capability disjunct denies, but a platform
admin whose `users.role` is readable remains authorized through the
admin disjunct; with role state unreadable, the admin disjunct denies
likewise. A decision that can read neither denies. Capability-row
deletion is hereby added to §6.1's enumerated revocation paths:
it propagates identically, under §6.2's bound, on every transport —
no new HTTP/MCP command authorized by the deleted row after the
revoking transaction commits, and any cached authorization is
invalidated in the revoking transaction (§3.5).
5. **What it does not confer**, stated so implementing PRs cannot drift:
no hierarchy grant or effective role at any node; no workspace
authorization or membership; no content, structure, or roll-up read;
no grant management (§4.1 unchanged); no MCP scope; no other instance
administration (user management, system settings, provider
configuration remain platform-admin-only); no company create, rename,
delete, or transfer. Hierarchy schema §5.5's rule that a company
`owner` as such may NOT change visibility is unchanged — `owner` and
this capability are disjoint authorities that combine only by a
subject holding both.
6. **Verification requirements** (extends §7, binding on implementing
PRs):
1. Schema witnesses (real PostgreSQL, `ci-postgres` service in the
`test` CI step): the `capability` CHECK constraint rejects any
value outside `company_crud`; NOT NULL enforced on every declared
NOT NULL column; UNIQUE (`user_id`, `capability`) rejects a
duplicate; both user FKs reject a dangling reference AND deleting a
referenced user is refused (RESTRICT witnessed in both directions);
the table's column set is exactly the §8.2 declared set (contract 1
§6.2 discipline).
2. Capability-only command matrix — the witness that proves "exactly
one command", not merely "at least one": a non-admin holder with no
other grants succeeds on the visibility command in **both**
directions with contract 1 §5.2's audit event (old and new values
as semantic content), and the **same** actor is refused, case by
enumerated case: every hierarchy mutation family (company/child
create under another's node, rename, delete, transfer); grant
create/change/revoke; the workspace read and write command
families; roll-up reads; structure reads — including the
not-found-indistinguishable refusal on a structure read of the very
company they just mutated (hierarchy schema §6.7); every
instance-administration surface other than the visibility command
(user management, system settings, provider configuration, and
capability assign/revoke itself); and MCP scope derivation yields
nothing — the §7.4 deny-by-default matrix gains this row. Company
creation compares against an eligible-user baseline: the holder's
create behaves exactly as any active user's — always `private`,
and a creation request carrying a visibility argument is refused
for holder and baseline alike (contract 3 §5.2).
3. Disclosure bound (§8.1 carve-out witnessed, receipt and canonical
event separately): the mutation result for a private-valid target,
a directory-valid target, and a nonexistent id is exactly {success,
success, `not_found`}; the actor receipt for a success carries
exactly {target id, old visibility, new visibility} and no result
or error payload carries name, slug, structure, content, grant, or
membership data; the canonical audit event for the same mutation —
asserted directly against the hierarchy outbox, not through any
holder-facing surface — carries hierarchy schema §5.2's full
immutable snapshot (id, slug, parent chain); and the holder's
attempt to read audit data is refused (no audit read conferred,
§8.5), proving the receipt/event separation reaches the holder as
a redaction, not a weakened event.
4. Assignment path, both polarities: a platform admin assigns and
revokes through the normal admin command (positive witnesses —
assign then observe the §8.6.2 allow, revoke then observe deny); a
non-admin — including a current capability holder — is refused
assign and revoke; every assign/revoke produces its audit event
with the namespaced string (§8.3); a direct-write path that skips
the command surface is non-conformant (the §8.3 command is the only
writer of `platform_capabilities`).
5. Revocation joins the §7.6 matrix: assignment is decision-time-live
(capability assigned → the holder's next visibility command allows,
no re-login); after row deletion, the ex-holder's next visibility
command is refused **on every exposed transport**, measured with
the revocation and the decision on distinct physical connections; a
cached-authorization implementation proves transactional
invalidation (§3.5). Fail-closed fault witnesses, both disjuncts
(§8.4): with `platform_capabilities` unreadable, a non-admin holder
is denied while a platform admin remains authorized; with role
state unreadable, the admin disjunct denies.
6. Owner-as-such refusal re-witnessed: hierarchy schema §6.9's
owner-cannot-publish witness re-asserted with the
`platform_capabilities` table present and empty for that owner.
## Ruling request
Ratify sections 17 as written, with one decision embedded and one
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.
## Ruling request (Amendment 1)
Ratify §8, the Amendment 1 header note, and kanban SOT Amendment A3
(native-kanban-sot.md §10 — the express A2 carve-out extension, which
binds only with this ratification) as written, with one decision
embedded:
- Decision: the company-CRUD capability is a platform-scoped,
admin-assigned, audited delegation of exactly the hierarchy schema §5.5
visibility command — no read command, no other company operation, with
the mutation's inherent existence disclosure ratified as a bounded
carve-out (§8.1). Say "agreed" or name the additional operations (or
the curation listing) you want it to carry.
-197
View File
@@ -1,197 +0,0 @@
# Tool↔Gateway Mapping Contract (D8)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 5 of 9).
Authority: PRD D8/D12 (Part I §8) — the webUI sits OVER official tooling:
every webUI operation goes through the Gateway API backed by the same
official framework tooling the CLI uses, and a webUI operation with no
backing tool is scored **blocked on tooling** and the tool is built
first. Measured input: the webui-audit A5 tooling baseline
(operation-by-operation inventory of the current Gateway surface and the
P1 gaps, cross-reviewed; `fleet/lanes/webui-audit/findings/
A5-tooling-baseline.md` in the estate brain). The T10 ruling adopted the
targeted-update plan including building the D8 tools in A5's rank order.
Revision 2 (GLM review F1F5): the §2 table completed against an
independent re-measurement of the live `apps/web` surface (mission
reads, coordination status, capability-gated `turn:send` added); rank-6
composition corrected to ranks 1 and 4; SOT citations corrected to §3
invariant 11 / REQ-TASK-001 / §5+A1; the §3.2 retirement clause
softened to match what the owning contracts actually schedule; §6.1
scoped to outbound calls with an extractability lint, and §6.3 given
static companions for §4.1 and §4.3.
This contract binds three things: the operation→tool mapping itself
(§2–§3), the command envelope every mapped operation satisfies
(§4), and the process rule that keeps the mapping closed (§5). Domain
semantics stay with their owning contracts — hierarchy (contract 1,
`hierarchy-schema.md`), grants (contract 2, `rbac-grant-model.md`),
wizard (contract 3, `onboarding-wizard.md`), identity
(`identity-lifecycle.md`), kanban lifecycle (`native-kanban-sot.md`
§5 and Amendment A1), roll-up (contract 8), API artifact format
(contract 9).
## 1. Definitions
1. **Official tool**: a command implemented in the framework packages and
exposed through the Gateway API; the CLI remains the primary execution
method for the same command (D8). The webUI is a Gateway client only.
2. **Mapped operation**: a webUI operation with a named official path in
§2 or §3. Anything else the webUI wants to do is unmapped and follows
§5.
3. **Legacy non-substitute**: an existing endpoint that resembles a P1
need but is contractually barred from backing it (§3.2).
## 2. P0 mapping (current operations, ratified as-is)
This table is the complete measured P0 surface: every Gateway call the
web app's production sources make at this revision's head appears as a
row (independently re-measured at review; the three calls the first
measurement missed — mission reads, coordination status, and the
capability-gated `turn:send` emit — are rows below). The surface stays
bound to these paths:
| WebUI operation | Official path |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Register / log in / log out / OIDC callback | better-auth mount `/api/auth/*`; `GET /api/sso/providers` |
| List/show projects (legacy read) | `GET /api/projects`, `GET /api/projects/:id` |
| List tasks / task detail (legacy read) | `GET /api/tasks`, `GET /api/tasks/:id` — with the filtered legacy project/mission reads the same surfaces use |
| Mission list (legacy read) | `GET /api/missions` |
| Coordination status (legacy read) | `GET /api/coord/status` |
| Conversation CRUD/search/messages | `/api/conversations*` |
| Chat turn / stop / thinking / command execute+approve / streaming | `/chat` socket events `message`, `abort`, `set:thinking`, `command:execute`, `command:approve`; `turn:send` (capability-gated — emitted only when the server advertises the pi turn-runtime capability, which the current Gateway does not) |
| Harness/model selection | `GET /api/harnesses*`, `GET/PUT /api/chat/preferences/selection` |
| Preferences; provider inspect/test | `/api/memory/preferences`, `GET /api/providers`, `POST /api/providers/test` |
| Admin users / roles / ban / health | `/api/admin/users*`, `/api/admin/health` |
P0 rows inherit §4 obligations as their backing controllers are next
touched; they are not required to be retrofitted in one sweep.
## 3. P1 mapping (bound to the build-first tools)
1. Every P1 operation maps to exactly one build-first command family, in
the T10-ruled rank order:
| Rank | Command family (owning contract) | P1 webUI operations it backs |
| ---- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Hierarchy command family (contract 1 §5; grants attach per contract 2) | Company/estate/platform-project/workspace CRUD, parentage and reparenting, hierarchy reads; the wizard's initial-hierarchy step (contract 3 §3.4) |
| 2 | Hierarchy RBAC command/evaluator (contract 2) | Grant create/change/revoke at company/estate/platform-project; inherited evaluation down to workspace; authorization-safe hierarchy queries |
| 3 | Typed kanban command/query surface (SOT §5, Amendment A1) | Workspace task lifecycle (create/edit/cancel/archive/move), board rank, typed queries |
| 4 | Agent enrollment command | Enroll one agent: harness, credential reference/API-key intake (values never echoed), name/persona, assignment scope (contract 3 §3.5) |
| 5 | Authorized roll-up query (contract 8) | Read-only aggregated task counts/statuses at every hierarchy level over readable workspaces only |
| 6 | Onboarding orchestration (contract 3) | The re-runnable wizard flow, composing ranks 1 and 4 (its only grant write rides inside the rank-1 company-create command, contract 2 §4.3) |
2. **Legacy non-substitutes.** The following MUST NOT back any P1
operation, matching the audit findings: legacy `/api/projects` and
`/api/tasks` CRUD (planning-data records, not hierarchy nodes and not
the typed kanban boundary); `POST /api/workspaces` (filesystem
bootstrap, not audited hierarchy parentage); `/api/teams` reads (no
grants, no inheritance); `POST /api/bootstrap/setup` (one-shot
epoch transition, identity §3 — not the re-runnable wizard); the MCP
`brain_*` task mutations (legacy Brain writes, not the typed kanban
commands). These stay serving their existing P0/host consumers until
the owning contract (or a successor amendment) schedules each
retirement — no such migration is scheduled at this revision; the
freeze stands on its own.
3. New P1 mapping rows (operations this table does not list) are added by
amending this contract, not ad hoc (§5).
## 4. Command envelope (request / result / error / audit)
Binding on every mapped operation the build-first families expose:
1. **Typed request and result.** Each command and query has an explicit
request DTO and result DTO in the shared types package, validated at
the Gateway boundary; unvalidated pass-through and `any`-typed
payloads are non-conformant. Mutations on records with an
expected-version rule in their owning contract carry the expected
version in the request and fail on mismatch with the conflict error
class (SOT §3 invariant 11 and REQ-TASK-001's concurrent-update
conflict acceptance; hierarchy per contract 1).
2. **Error taxonomy.** Every error result carries a stable
machine-readable code from a closed per-family enum plus an HTTP
status mapping, distinguishing at minimum: validation failure,
authentication failure, authorization refusal, not-found, conflict
(version/uniqueness), precondition/state refusal (e.g. bootstrap
epoch, suspended team subjects), and internal fault. Where contract
2's no-existence-oracle rule applies, authorization refusal and
not-found are indistinguishable on the wire for unauthorized readers
— same code, same status, same shape.
3. **Audit linkage.** A mutating mapped operation emits exactly the
audit events its owning contract defines (contract 1 §5.2, contract 2
§4.4, identity §§24, SOT audit rules); the envelope contributes the
correlation: every request accepts/generates a correlation id,
carried into the audit events and returned in the result, so a UI
action is traceable end to end. The mapping layer itself adds no
second audit stream.
4. **Fail-closed.** A mapped operation that cannot evaluate its
authorization or reach its owning tool refuses (contract 2 §3.5); the
envelope never degrades to an unauthorized fallback read or a direct
data access.
5. **CLI parity.** Each build-first family is invocable through the
official CLI against the same Gateway commands with the same
request/result/error contracts. No webUI-only command exists; a
Gateway command without CLI exposure is a conformance gap tracked at
the family's implementing issue.
## 5. Closure rule (blocked on tooling)
1. A webUI change that needs an operation with no mapping row is
**blocked on tooling**: the backing tool is built and mapped first
(D8). Scoring a gap "blocked on tooling" is mandatory, not
discretionary; working around it in the UI (direct DB or filesystem
access, calling a legacy non-substitute, embedding domain logic in
the web app) is non-conformant.
2. The mapping is enforced closed by §6.1's inventory witness: the web
app's network surface must be a subset of the mapped paths.
## 6. Verification requirements
Binding on the implementing PRs:
1. **Network-surface inventory witness:** a CI assertion extracting the
web app's outbound Gateway calls — route literals at request call
sites and outbound socket emits in `apps/web` sources (inbound
handler registrations are not calls and are out of scope) — and
failing on any call outside the §2/§3 mapped paths. The inventory is
closed like contract 1 §6.3's allowlist: a new call fails until a
mapping row exists in the same PR. Dynamic route construction that
evades extraction is resolved toward the witness, enforced by an
extractability lint: every request call site takes a literal or
template-literal path, and a call site that does not fails the
assertion itself (the web-side analogue of contract 1's
raw-execution prong), never an exemption for the caller.
2. **Non-substitute witness:** the P1 surfaces (hierarchy, RBAC, kanban,
enrollment, roll-up, wizard UI) make zero calls to the §3.2 legacy
endpoints — asserted by the same inventory, scoped per surface.
3. **Envelope witnesses per family:** for each build-first family — a
request with an invalid DTO is refused with the validation code; a
version-mismatch mutation returns the conflict code; an unauthorized
read of an existing node and a read of a nonexistent node return
indistinguishable results where the no-existence-oracle rule applies;
a correlation id submitted on a mutation appears in its audit
event(s) and result. Two static companions: a type-level assertion
that the family's boundary accepts no `any`-typed or unvalidated
pass-through payload (§4.1), and a single-emitter assertion that the
mapped operation's audit events originate only from the owning
contract's audit emitter (§4.3's no-second-audit-stream, made
checkable).
4. **CLI-parity witness:** for each family, a CLI smoke invocation of at
least one command and one query against the Gateway succeeds with the
same typed result the web client receives.
5. **Fail-closed witness:** with the owning tool or grant state
unreachable (fault injection), the mapped operation returns the
internal-fault or authorization-refusal class and performs no
fallback read/write (extends contract 2 §7.6 to the mapping layer).
## Ruling request
Ratify sections 16 as written, with one decision embedded:
- Decision (§3.2): the legacy endpoints named there are **frozen for new
consumers** as of ratification — existing P0/host consumers keep
working, new UI or tool code may not call them, and each is retired by
the migration its owning contract schedules. Alternative if rejected:
allow P1 surfaces to reuse legacy endpoints as interim backends —
rejected by the audit's finding that they cannot satisfy the
hierarchy/kanban/RBAC contracts, so the interim would ship
non-conformant semantics.
+1 -3
View File
@@ -1,10 +1,8 @@
---
kind: record
status: superseded
status: active
---
> **Superseded (2026-09-01, PRD rev1 ratification).** This document's record of a completed Federation M2 milestone is historical. Federation M1M3 are shipped but **frozen** (dormant since 2026-06-25, excluded from the v1 bar, security re-audit gate before any resumption); the canonical v1 deployment topology is the compose standalone tier (PRD rev1, D15). Authority: `docs/PRD.md``docs/PRDs/2026-08-31_PRD_rev1/` (decision D3 as amended, GOV.5 Q-T1). Tracking: `docs/fleet/NORTH_STAR.yaml` (dormant federation workstream). Content below is preserved verbatim as a record — do not edit it.
# Mission Scratchpad — MVP
> Append-only log. NEVER delete entries. NEVER overwrite sections.

Some files were not shown because too many files have changed in this diff Show More