diff --git a/.gitignore b/.gitignore index dd50108f..e86bc179 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage .env.local *.tsbuildinfo .pnpm-store +__pycache__/ docs/reports/ # Step-CA dev password — real file is gitignored; commit only the .example diff --git a/.husky/pre-push b/.husky/pre-push index 4a0f0e8a..aae2e7e1 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1 @@ -pnpm typecheck && pnpm lint && pnpm format:check +pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check diff --git a/.npmrc b/.npmrc index e72177a2..17a95ce4 100644 --- a/.npmrc +++ b/.npmrc @@ -1,5 +1,5 @@ @mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ -# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci), -# so the pipeline `pnpm install --prefer-offline` consumes the baked store -# instead of repopulating a fresh one. -store-dir=/root/.local/share/pnpm/store +# HOME resolves to /root in the ci-base image, preserving its warmed-store path. +# Non-root checkouts use their own HOME. Override without editing this file via +# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting). +store-dir=${HOME}/.local/share/pnpm/store diff --git a/.prettierignore b/.prettierignore index dddb198d..33f36e19 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,4 +4,15 @@ pnpm-lock.yaml **/node_modules **/drizzle **/.next +# Python build/test artifacts — same category as node_modules/dist/.next above. +# Prettier must never scan generated trees; without these a local venv poisons +# `pnpm format:check` with thousands of third-party files. +**/venv +**/__pycache__ +**/.mypy_cache +**/.pytest_cache +**/htmlcov .claude/ +docs/tess/TASKS.md +docs/scratchpads/ +packages/mosaic/src/fleet/testdata/documentation-publication-v1/inline-migration-v1.json diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index ce4d4da6..c02f3f1c 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -7,7 +7,14 @@ variables: - &enable_pnpm 'corepack enable' when: - - event: [push, pull_request, manual] + # PR + manual CI run on any branch — the pull_request pipeline is the merge gate. + # push CI is restricted to protected branches (main) so a feature-branch push no + # longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves + # CI load on the storage-constrained runner with zero loss of gating (branch + # protection requires no push/ci status context; main still gets full push CI). + - event: [pull_request, manual] + - event: push + branch: main # Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker # repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN). @@ -34,6 +41,32 @@ steps: # (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9. - bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test - bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh + # Test-membership guard (#1017): also first link of test:framework-shell. + # Invoked from BOTH surfaces it audits (F2, PR #1018) — the guard is link + # [0] of the pnpm chain, so severing that chain would silence it together + # with everything it guards; this direct line keeps one instrument running. + - bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh + + # Blocking gate (#791): a framework upgrade must never write or delete an + # operator-owned path. The HARD GATE proves an unanticipated operator sentinel + # survives a keep-mode reseed byte-identical (with rsync present AND absent — + # keep mode is a single cp-based path that must not depend on rsync), and that a + # corrupt/empty/missing manifest aborts fail-closed leaving operator files + # untouched (B2/B3). The rollback gate proves a mid-sync failure is rolled back + # from the pre-update snapshot (B1). The durable-snapshot gate (#791 PR2) proves + # the retained, operator-scoped pre-update backup is taken before any mutation + # (0700/0600, secret never logged, retention-pruned) and that the post-sync + # verify net restores any operator file a manifest bug lets the sync touch. The + # migration matrix pins the v2→v3 contract-file semantics. Pure bash, no + # node_modules — runs early alongside sanitization. + upgrade-guard: + image: *node_image + commands: + - apk add --no-cache bash rsync + - bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh + - bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh + - bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh + - bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh typecheck: image: *node_image @@ -43,6 +76,7 @@ steps: depends_on: - install - sanitization + - upgrade-guard # lint, format, and test are independent — run in parallel after typecheck lint: @@ -69,6 +103,12 @@ steps: DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic commands: - *enable_pnpm + # openssl (#912) is the wake HMAC signer: the digest H1/H2, beacon B12, + # and install I8 legs hard-require it in CI. It is baked into ci-base via + # Dockerfile.ci, but ci-base only rebuilds on push-to-main/tag — this + # `apk add` guarantees openssl is present on PR pipelines too (and is a + # fast no-op once the rebuilt image already ships it). + - apk add --no-cache openssl # postgresql-client (pg_isready) is baked into ci-base. # Wait up to 60s for CI postgres to be ready; fail fast if it never comes up. - | diff --git a/CLAUDE.md b/CLAUDE.md index cd9c2c0e..8876a145 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,13 +26,14 @@ pnpm test # Vitest (all packages) pnpm build # Build all packages # Database -pnpm --filter @mosaicstack/db db:push # Push schema to PG (dev) -pnpm --filter @mosaicstack/db db:generate # Generate migrations -pnpm --filter @mosaicstack/db db:migrate # Run migrations +pnpm --filter @mosaicstack/db db:generate # Offline migration artifact generation only +# PostgreSQL execution is held until KBN-101-00/-03/-05 land. Do not invoke a runner, +# init SQL, or Compose PostgreSQL service from this checkout. -# Dev -docker compose up -d # Start PG, Valkey, OTEL, Jaeger -pnpm --filter @mosaicstack/gateway exec tsx src/main.ts # Start gateway +# Dev: local PGlite data-layer work needs no PostgreSQL. Optional local queue service only: +docker compose up -d valkey +# Do not start Gateway/Web or root pnpm dev as a local PGlite route: the current unguarded dotenv +# loader can inherit a daemon PostgreSQL DSN. KBN-101-02 must make that state fail closed first. ``` ## Conventions diff --git a/Dockerfile.ci b/Dockerfile.ci index 4bb9d7a4..aed28067 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -22,10 +22,13 @@ FROM node:24-alpine # Native toolchain required to compile node-gyp deps on musl, plus the -# postgresql-client used by the test step's pg_isready readiness probe. `bash` -# is baked here too — the sanitization step in ci.yml otherwise does a per-run -# `apk add bash`. -RUN apk add --no-cache python3 make g++ postgresql-client bash +# postgresql-client used by the test step's pg_isready readiness probe. `bash`, +# `git`, and `jq` are baked here too — framework shell tests and the shipped +# Codex review wrappers require them without per-run installation in ci.yml. +# `openssl` (#912) is the non-circular HMAC signer for the wake trust layer: +# the digest H1/H2, beacon B12, and install I8 legs hard-require it in CI so the +# §4 G6 evidence comes from an actually-run HMAC leg, not a skipped one. +RUN apk add --no-cache python3 make g++ postgresql-client bash git jq openssl # Pin pnpm to the repo's packageManager version via corepack. RUN corepack enable && corepack prepare pnpm@10.6.2 --activate diff --git a/README.md b/README.md index 1582d839..f4d3c290 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,10 @@ mosaic config path # Print config file path ```bash mosaic doctor # Health audit — detect drift and missing files mosaic sync # Sync skills from canonical source -mosaic update # Check for and install CLI updates +mosaic skill list # Audit Claude skill registrations and conflicts +mosaic skill register # Register one canonical skill with Claude Code +mosaic skill unregister # Remove one Mosaic-owned Claude link +mosaic update # Update CLI/framework and auto-register canonical skills mosaic wizard # Full guided setup wizard mosaic bootstrap # Bootstrap a repo with Mosaic standards mosaic coord init # Initialize a new orchestration mission @@ -167,7 +170,12 @@ mosaic storage status mosaic storage tier mosaic storage export mosaic storage import -mosaic storage migrate +# Schema migration is unavailable in this release. The current storage wrapper shells +# directly to `pnpm --filter @mosaicstack/db db:migrate`; it is legacy N-1, +# uncertified, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation. +# Future schema migration is non-operative: external bootstrap → TLS/roles → runner +# --run → runner --verify → readiness. Tier copy uses only the separately held secure +# migrate-tier route. ``` ### Telemetry @@ -202,33 +210,50 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo git clone git@git.mosaicstack.dev:mosaicstack/stack.git cd stack -# Start infrastructure (Postgres, Valkey, Jaeger) -docker compose up -d - -# Install dependencies +# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset. +# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without +# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed. pnpm install -# Run migrations -pnpm --filter @mosaicstack/db run db:migrate +# Verify dependencies and generated state before running source-quality gates. +# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43. +# The web build certifies its exact standalone symlink manifest; added, removed, +# retargeted, or manifest-only-tampered generated links also exit 43. This detects +# accidental, independent, stale, and foreign-residue mutation—the class exposed by +# a five-month-stale .next that produced 19 phantom TS2307 errors. +# It does NOT defend against a same-UID actor that can rewrite both manifest and +# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side +# trust anchor outside worktree authority. +pnpm preflight -# Start all services in dev mode -pnpm dev +# Optional local queue service only. This does not start PostgreSQL. +docker compose up -d valkey + +# The current Gateway/Web local process is held; see docs/guides/dev-guide.md. +# Do not start it until KBN-101-02 makes inherited dotenv/DSN state fail closed. ``` -### Infrastructure +### Held future procedure -Docker Compose provides: +The checked-in Compose PostgreSQL service mounts legacy initialization SQL and is **not** a +current PostgreSQL, standalone, or federated developer route. Do not start it with Compose, +invoke initialization SQL, or treat the planned migrator as currently executable. -| Service | Port | Purpose | -| --------------------- | --------- | ---------------------- | -| PostgreSQL (pgvector) | 5433 | Primary database | -| Valkey | 6380 | Task queue + caching | -| Jaeger | 16686 | Distributed tracing UI | -| OTEL Collector | 4317/4318 | Telemetry ingestion | +**Held future activation procedure — non-operative and no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05 +land:** external bootstrap → TLS/roles → `mosaic-db-migrator --run` → +`mosaic-db-migrator --verify` → Gateway/Compose readiness. The future deployment artifacts—not +this README—will provide the reviewed commands and secret-consumer interface. + +For local data-layer work, PGlite needs no PostgreSQL service. The optional Compose command above +starts only Valkey; OTEL Collector and Jaeger may likewise be started individually if needed, +without starting PostgreSQL. A Gateway/Web local process is not currently a safe PGlite route: +its unguarded dotenv loader may inherit a daemon PostgreSQL DSN. Do not use root `pnpm dev` or a +Gateway start command until KBN-101-02 makes that state fail closed. ### Quality Gates ```bash +pnpm preflight # Checkout/dependency/generated-state validation pnpm typecheck # TypeScript type checking (all packages) pnpm lint # ESLint (all packages) pnpm test # Vitest (all packages) @@ -241,7 +266,7 @@ pnpm format # Prettier auto-fix Woodpecker CI runs on every push: - `pnpm install --frozen-lockfile` -- Database migration against a fresh Postgres +- **Legacy N-1 CI status only — active, uncertified, and non-authorizing as an operator route:** the checked-in job currently invokes `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` against an isolated disposable PostgreSQL CI database. It performs direct DDL in that CI database, is not approved ordinary behavior or an operator route, and remains a known exception pending KBN-101-06 removal/replacement by the certified runner-backed CI path. - `pnpm test` (Turbo-orchestrated across all packages) npm packages are published to the Gitea package registry on main merges. @@ -353,6 +378,8 @@ bash tools/install.sh --yes # Non-interactive, accept all defaults bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard ``` +The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage. + ## Contributing ```bash diff --git a/apps/gateway/package.json b/apps/gateway/package.json index 1a2cb973..8e5819f9 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -31,6 +31,7 @@ "@mariozechner/pi-ai": "^0.65.0", "@mariozechner/pi-coding-agent": "^0.65.0", "@modelcontextprotocol/sdk": "^1.27.1", + "@mosaicstack/agent": "workspace:^", "@mosaicstack/auth": "workspace:^", "@mosaicstack/brain": "workspace:^", "@mosaicstack/config": "workspace:^", diff --git a/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts new file mode 100644 index 00000000..22abe32c --- /dev/null +++ b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts @@ -0,0 +1,194 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { InMemoryDurableSessionStore } from '@mosaicstack/agent'; +import { + createDiscordIngressEnvelope, + DiscordPlugin, + type DiscordIngressPayload, +} from '@mosaicstack/discord-plugin'; +import { InteractionController } from '../../agent/interaction.controller.js'; +import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js'; +import { DurableSessionService } from '../../agent/durable-session.service.js'; +import { ChatGateway } from '../../chat/chat.gateway.js'; +import { CommandAuthorizationService } from '../../commands/command-authorization.service.js'; + +const SERVICE_TOKEN = 'test-discord-service-token'; +const envKeys = [ + 'DISCORD_SERVICE_TOKEN', + 'DISCORD_SERVICE_TENANT_ID', + 'DISCORD_INTERACTION_BINDINGS', + 'DISCORD_ALLOWED_GUILD_IDS', + 'DISCORD_ALLOWED_CHANNEL_IDS', + 'DISCORD_ALLOWED_USER_IDS', + 'MOSAIC_AGENT_NAME', +] as const; +const priorEnv = new Map(); + +function payload(content: string, messageId: string, correlationId: string): DiscordIngressPayload { + return { + content, + messageId, + correlationId, + guildId: 'guild-1', + channelId: 'channel-1', + userId: 'discord-admin-1', + conversationId: 'Nova:discord:channel-1', + }; +} + +function authorization(): CommandAuthorizationService { + const entries = new Map(); + return new CommandAuthorizationService( + { + select: () => ({ + from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }), + }), + } as never, + { + get: async (key: string) => entries.get(key) ?? null, + set: async (key: string, value: string) => entries.set(key, value), + del: async (key: string) => Number(entries.delete(key)), + }, + ); +} + +describe('interaction Discord/CLI durable-session integration', () => { + afterEach(() => { + for (const key of envKeys) { + const value = priorEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + priorEnv.clear(); + }); + + it('enrolls through the CLI surface then resolves the same durable session from Discord', async () => { + for (const key of envKeys) priorEnv.set(key, process.env[key]); + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN; + process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-1'; + process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-1'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-1'; + process.env['DISCORD_ALLOWED_USER_IDS'] = 'discord-admin-1'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-1', + channelId: 'channel-1', + pairedUsers: { + 'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' }, + }, + }, + ]); + + const durable = new DurableSessionService( + new InMemoryDurableSessionStore() as never, + {} as never, + ); + const enrollmentRuntime = { + listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]), + }; + const controller = new InteractionController(enrollmentRuntime as never, durable); + await controller.enroll( + 'Nova', + 'Nova:discord:channel-1', + { providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + { id: 'mosaic-admin-1', tenantId: 'tenant-1' }, + 'cli-enrollment-correlation', + ); + + const authz = authorization(); + const terminated = vi.fn().mockResolvedValue(undefined); + const runtime = new RuntimeProviderService( + { + require: () => ({ + capabilities: async () => ({ supported: ['session.terminate'] }), + terminate: terminated, + }), + } as never, + { record: async () => undefined } as never, + { + consume: (approvalId, action) => + authz.consumeRuntimeTerminationApproval(approvalId, action), + }, + ); + const gateway = new ChatGateway( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + authz, + runtime, + durable, + ); + const client = { data: { discordService: true }, emit: vi.fn() }; + const plugin = new DiscordPlugin({ + token: 'unused', + gatewayUrl: 'http://unused', + serviceToken: SERVICE_TOKEN, + allowedGuildIds: ['guild-1'], + allowedChannelIds: ['channel-1'], + allowedUserIds: ['discord-admin-1'], + interactionBindings: [ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-1', + channelId: 'channel-1', + pairedUsers: { + 'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' }, + }, + }, + ], + }); + const pluginInternals = plugin as unknown as { + client: { user: { id: string } }; + socket: { connected: boolean; emit: ReturnType }; + handleDiscordMessage(message: unknown): void; + }; + const pluginSocket = { connected: true, emit: vi.fn() }; + pluginInternals.client = { user: { id: 'bot-1' } }; + pluginInternals.socket = pluginSocket; + pluginInternals.handleDiscordMessage({ + id: 'approve-1', + guildId: 'guild-1', + channelId: 'channel-1', + author: { id: 'discord-admin-1', bot: false }, + mentions: { has: () => true }, + content: '<@bot-1> /approve', + channel: { parentId: null }, + attachments: new Map(), + }); + + expect(pluginSocket.emit).toHaveBeenCalledWith('discord:approve', expect.any(Object)); + const approvalEnvelope = pluginSocket.emit.mock.calls[0]?.[1]; + await gateway.handleDiscordApproval(client as never, approvalEnvelope); + const approval = client.emit.mock.calls.find( + ([event]) => event === 'discord:approval', + )?.[1] as { + approvalId: string; + success: boolean; + }; + expect(approval.success).toBe(true); + + await gateway.handleDiscordStop( + client as never, + createDiscordIngressEnvelope( + payload(`/stop ${approval.approvalId}`, 'stop-1', 'discord-stop-correlation'), + SERVICE_TOKEN, + ), + ); + + expect(terminated).toHaveBeenCalledWith( + 'runtime-1', + approval.approvalId, + expect.objectContaining({ actorId: 'mosaic-admin-1' }), + ); + expect(client.emit).toHaveBeenCalledWith('discord:stop', { + correlationId: 'discord-stop-correlation', + success: true, + }); + }); +}); diff --git a/apps/gateway/src/admin/admin-health.controller.ts b/apps/gateway/src/admin/admin-health.controller.ts index 99fd93f3..ad03d41f 100644 --- a/apps/gateway/src/admin/admin-health.controller.ts +++ b/apps/gateway/src/admin/admin-health.controller.ts @@ -25,7 +25,7 @@ export class AdminHealthController { async check(): Promise { const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]); - const sessions = this.agentService.listSessions(); + const sessions = this.agentService.listAllSessionsForSystem(); const providers = this.providerService.listProviders(); const allOk = database.status === 'ok' && cache.status === 'ok'; diff --git a/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts b/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts new file mode 100644 index 00000000..dc6b9bfd --- /dev/null +++ b/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts @@ -0,0 +1,191 @@ +import { ForbiddenException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; +import { AgentService, type AgentSession } from '../agent.service.js'; +import type { ActorTenantScope } from '../../auth/session-scope.js'; + +const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222'; +const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-user', tenantId: 'owner-tenant' }; +const FOREIGN_SCOPE: ActorTenantScope = { userId: 'foreign-user', tenantId: 'foreign-tenant' }; + +type AgentServiceInternals = { + sessions: Map; + creating: Map>; +}; + +function makeService(operatorMemory: unknown = null): AgentService { + return new AgentService( + { + getDefaultModel: vi.fn(() => null), + getRegistry: vi.fn(() => ({})), + findModel: vi.fn(), + listAvailableModels: vi.fn(() => []), + } as never, + {} as never, + {} as never, + { available: false } as never, + {} as never, + { getToolDefinitions: vi.fn(() => []) } as never, + { loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never, + null, + null, + { collect: vi.fn().mockResolvedValue(undefined) } as never, + operatorMemory as never, + ); +} + +function internals(service: AgentService): AgentServiceInternals { + return service as unknown as AgentServiceInternals; +} + +function makeSession(scope: ActorTenantScope = OWNER_SCOPE): AgentSession { + return { + id: CONVERSATION_ID, + provider: 'test-provider', + modelId: 'test-model', + piSession: { + thinkingLevel: 'off', + getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']), + setThinkingLevel: vi.fn(), + abort: vi.fn().mockResolvedValue(undefined), + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + getSessionStats: vi.fn(), + getContextUsage: vi.fn(), + } as unknown as AgentSession['piSession'], + listeners: new Set(), + unsubscribe: vi.fn(), + createdAt: Date.now(), + promptCount: 0, + channels: new Set(), + skillPromptAdditions: [], + sandboxDir: '/tmp/tess-session-ownership-test', + allowedTools: null, + userId: scope.userId, + tenantId: scope.tenantId, + metrics: { + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + modelSwitches: 0, + messageCount: 0, + lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(), + }, + }; +} + +describe('AgentService owner/tenant scope enforcement', () => { + it('allows owner-scoped operations and rejects foreign scopes for seeded sessions', async () => { + const service = makeService(); + const session = makeSession(); + internals(service).sessions.set(CONVERSATION_ID, session); + + expect(service.getSession(CONVERSATION_ID, OWNER_SCOPE)).toBe(session); + expect(service.getSession(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined(); + expect(service.getSessionInfo(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined(); + expect(service.listSessions(OWNER_SCOPE)).toHaveLength(1); + expect(service.listSessions(FOREIGN_SCOPE)).toEqual([]); + + service.addChannel(CONVERSATION_ID, 'websocket:owner', OWNER_SCOPE); + expect(session.channels.has('websocket:owner')).toBe(true); + expect(() => service.addChannel(CONVERSATION_ID, 'websocket:foreign', FOREIGN_SCOPE)).toThrow( + ForbiddenException, + ); + expect(() => service.removeChannel(CONVERSATION_ID, 'websocket:owner', FOREIGN_SCOPE)).toThrow( + ForbiddenException, + ); + + expect(() => + service.updateSessionModel(CONVERSATION_ID, 'foreign-model', FOREIGN_SCOPE), + ).toThrow(ForbiddenException); + service.updateSessionModel(CONVERSATION_ID, 'owner-model', OWNER_SCOPE); + expect(session.modelId).toBe('owner-model'); + + expect(() => + service.applyAgentConfig(CONVERSATION_ID, 'agent-foreign', 'Foreign Agent', FOREIGN_SCOPE), + ).toThrow(ForbiddenException); + service.applyAgentConfig(CONVERSATION_ID, 'agent-owner', 'Owner Agent', OWNER_SCOPE); + expect(session.agentConfigId).toBe('agent-owner'); + + expect(() => service.onEvent(CONVERSATION_ID, vi.fn(), FOREIGN_SCOPE)).toThrow( + ForbiddenException, + ); + const cleanup = service.onEvent(CONVERSATION_ID, vi.fn(), OWNER_SCOPE); + cleanup(); + + await expect( + service.prompt(CONVERSATION_ID, 'foreign prompt', FOREIGN_SCOPE), + ).rejects.toBeInstanceOf(ForbiddenException); + await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE); + expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt'); + await service.prompt(CONVERSATION_ID, '', OWNER_SCOPE, [ + { + id: 'attachment-001', + name: 'diagram.png', + url: 'https://cdn.example.test/diagram.png', + mimeType: 'image/png', + }, + ]); + expect(session.piSession.prompt).toHaveBeenLastCalledWith( + '\n\n[Untrusted channel attachments]\n' + + '{"id":"attachment-001","name":"diagram.png","mimeType":"image/png","url":"https://cdn.example.test/diagram.png"}', + ); + + await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf( + ForbiddenException, + ); + expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(true); + + await service.destroySession(CONVERSATION_ID, OWNER_SCOPE); + expect(session.piSession.dispose).toHaveBeenCalled(); + expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false); + }); + + it('derives the operator-memory scope on the createSession production path', async () => { + const plugin = { capture: vi.fn(), search: vi.fn() }; + const service = makeService(plugin); + const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox').mockReturnValue([]); + + // Session construction reaches the real scope derivation before the intentionally incomplete + // Pi test double rejects later in createAgentSession. + await service.createSession(CONVERSATION_ID, OWNER_SCOPE).catch(() => undefined); + + expect(buildTools).toHaveBeenCalledWith(expect.any(String), OWNER_SCOPE.userId, { + tenantId: OWNER_SCOPE.tenantId, + ownerId: OWNER_SCOPE.userId, + sessionId: CONVERSATION_ID, + }); + }); + + it('denies a foreign actor before it can obtain another session operator-memory scope', async () => { + const plugin = { capture: vi.fn(), search: vi.fn() }; + const service = makeService(plugin); + internals(service).sessions.set(CONVERSATION_ID, makeSession()); + const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox'); + + await expect(service.createSession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf( + ForbiddenException, + ); + + expect(buildTools).not.toHaveBeenCalled(); + expect(plugin.capture).not.toHaveBeenCalled(); + expect(plugin.search).not.toHaveBeenCalled(); + }); + + it('checks owner/tenant scope before returning an in-flight session creation', async () => { + const service = makeService(); + const session = makeSession(); + internals(service).creating.set(CONVERSATION_ID, Promise.resolve(session)); + + await expect( + service.createSession(CONVERSATION_ID, { + userId: FOREIGN_SCOPE.userId, + tenantId: FOREIGN_SCOPE.tenantId, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + + await expect( + service.createSession(CONVERSATION_ID, { + userId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + }), + ).resolves.toBe(session); + }); +}); diff --git a/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts b/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts new file mode 100644 index 00000000..11c57f6e --- /dev/null +++ b/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it } from 'vitest'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapability, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; +import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; +import type { ActorTenantScope } from '../../auth/session-scope.js'; +import { + RuntimeProviderAuditService, + RuntimeProviderService, + type RuntimeAuditEvent, + type RuntimeAuditSink, + type RuntimeApprovalVerifier, +} from '../runtime-provider-registry.service.js'; + +process.env['MOSAIC_AGENT_NAME'] ??= 'test-runtime-agent'; + +const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' }; +const CONTEXT = { + actorScope: OWNER_SCOPE, + channelId: 'cli', + correlationId: 'correlation-1', +}; + +class RecordingRuntimeProvider implements AgentRuntimeProvider { + readonly id = 'fleet'; + readonly receivedScopes: RuntimeScope[] = []; + readonly sentMessages: RuntimeMessage[] = []; + terminateCalls = 0; + throwAfterSend = false; + throwAuthorization = false; + + constructor(private readonly supported: RuntimeCapability[]) {} + + async capabilities(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return { supported: this.supported }; + } + + async health(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' }; + } + + async listSessions(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return []; + } + + async getSessionTree(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return []; + } + + async *streamSession( + _sessionId: string, + _cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable { + this.receivedScopes.push(scope); + return; + } + + async sendMessage( + _sessionId: string, + message: RuntimeMessage, + scope: RuntimeScope, + ): Promise { + this.receivedScopes.push(scope); + this.sentMessages.push(message); + if (this.throwAuthorization) { + throw Object.assign(new Error('provider authorization denied'), { code: 'forbidden' }); + } + if (this.throwAfterSend) { + throw new Error('provider acknowledgement failed'); + } + } + + async attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise { + this.receivedScopes.push(scope); + return { + attachmentId: 'attachment-1', + sessionId, + mode, + expiresAt: '2026-07-12T00:00:00.000Z', + }; + } + + async detach(_attachmentId: string, scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + } + + async terminate(_sessionId: string, _approvalRef: string, scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + this.terminateCalls += 1; + } +} + +class RecordingAuditSink implements RuntimeAuditSink { + readonly events: RuntimeAuditEvent[] = []; + + async record(event: RuntimeAuditEvent): Promise { + this.events.push(event); + } +} + +class DenyingApprovalVerifier implements RuntimeApprovalVerifier { + async consume(): Promise { + return false; + } +} + +class AcceptingApprovalVerifier implements RuntimeApprovalVerifier { + consumedAction: Parameters[1] | undefined; + + async consume( + _approvalRef: string, + action: Parameters[1], + ): Promise { + this.consumedAction = action; + return true; + } +} + +function makeService( + provider: RecordingRuntimeProvider, + audit: RuntimeAuditSink = new RecordingAuditSink(), + approval: RuntimeApprovalVerifier = new DenyingApprovalVerifier(), +): RuntimeProviderService { + const registry = new AgentRuntimeProviderRegistry(); + registry.register(provider); + return new RuntimeProviderService(registry, audit, approval); +} + +describe('RuntimeProviderService security boundary', (): void => { + it('derives and freezes only the authenticated actor scope while preserving correlation metadata', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + const audit = new RecordingAuditSink(); + const service = makeService(provider, audit); + + await service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ); + + const providerScope = provider.receivedScopes[0]; + expect(providerScope).toEqual({ + actorId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + channelId: CONTEXT.channelId, + correlationId: CONTEXT.correlationId, + }); + expect(Object.isFrozen(providerScope)).toBe(true); + expect(audit.events).toContainEqual( + expect.objectContaining({ + providerId: 'fleet', + operation: 'session.send', + outcome: 'succeeded', + actorId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + channelId: CONTEXT.channelId, + correlationId: CONTEXT.correlationId, + resourceId: 'session-1', + durationMs: expect.any(Number), + }), + ); + expect(JSON.stringify(audit.events)).not.toContain('hello'); + expect(JSON.stringify(audit.events)).not.toContain('key-1'); + }); + + it('does not block a provider operation when an unsafe resource ID is redacted in durable audit', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + let persisted: unknown; + const durableAudit = new RuntimeProviderAuditService({ + logs: { + ingest: async (entry: unknown): Promise => { + persisted = entry; + return entry; + }, + }, + } as never); + const service = makeService(provider, durableAudit); + + await service.sendMessage( + 'fleet', + 'session/credential-canary=secret-value', + { content: 'safe message', idempotencyKey: 'key-1' }, + CONTEXT, + ); + + expect(provider.sentMessages).toHaveLength(1); + expect(JSON.stringify(persisted)).not.toContain('secret-value'); + }); + + it('fails closed before a provider side effect when a capability is missing', async (): Promise => { + const provider = new RecordingRuntimeProvider([]); + const service = makeService(provider); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).rejects.toThrow(/capability denied/); + expect(provider.sentMessages).toEqual([]); + }); + + it('requires a consumed exact-action approval before termination', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.terminate']); + const approval = new DenyingApprovalVerifier(); + const audit = new RecordingAuditSink(); + const service = makeService(provider, audit, approval); + + await expect( + service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT), + ).rejects.toThrow(/approval denied/); + expect(provider.terminateCalls).toBe(0); + expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' }); + }); + + it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.terminate']); + const approval = new AcceptingApprovalVerifier(); + const service = makeService(provider, new RecordingAuditSink(), approval); + + await service.terminate('fleet', 'session-1', 'approval-1', CONTEXT); + + expect(approval.consumedAction).toEqual({ + providerId: 'fleet', + sessionId: 'session-1', + actorId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + channelId: CONTEXT.channelId, + correlationId: CONTEXT.correlationId, + agentName: process.env['MOSAIC_AGENT_NAME'], + }); + expect(provider.terminateCalls).toBe(1); + }); + + it('fails closed before invoking a provider when audit persistence rejects the request', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + const unavailableAudit: RuntimeAuditSink = { + async record(): Promise { + throw new Error('audit unavailable'); + }, + }; + const service = makeService(provider, unavailableAudit); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).rejects.toThrow(/audit unavailable/); + expect(provider.sentMessages).toEqual([]); + }); + + it('records a provider error after invocation as failed rather than denied', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + provider.throwAfterSend = true; + const audit = new RecordingAuditSink(); + const service = makeService(provider, audit); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).rejects.toThrow(/provider acknowledgement failed/); + expect(provider.sentMessages).toHaveLength(1); + expect(audit.events.map((event: RuntimeAuditEvent): string => event.outcome)).toEqual([ + 'requested', + 'failed', + ]); + expect(audit.events.at(-1)).toMatchObject({ + errorCode: 'provider_error', + durationMs: expect.any(Number), + }); + }); + + it('records a provider authorization rejection as denied rather than provider failure', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + provider.throwAuthorization = true; + const audit = new RecordingAuditSink(); + const service = makeService(provider, audit); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).rejects.toThrow(/provider authorization denied/); + expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' }); + }); + + it('persists only metadata-only runtime audit fields', async (): Promise => { + let persisted: unknown; + const ingest = async (entry: unknown): Promise => { + persisted = entry; + return entry; + }; + const service = new RuntimeProviderAuditService({ logs: { ingest } } as never); + + await service.record({ + providerId: 'fleet', + operation: 'session.send', + outcome: 'succeeded', + actorId: 'owner-1', + tenantId: 'tenant-1', + channelId: 'cli', + correlationId: 'correlation-1', + resourceId: 'session-1', + durationMs: 12, + }); + + expect(persisted).toMatchObject({ + content: 'runtime.provider.audit', + metadata: expect.objectContaining({ correlationId: 'correlation-1', durationMs: 12 }), + }); + expect(JSON.stringify(persisted)).not.toContain('approval'); + }); + + it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + let auditCalls = 0; + const audit: RuntimeAuditSink = { + async record(): Promise { + auditCalls += 1; + if (auditCalls === 2) { + throw new Error('completion audit unavailable'); + } + }, + }; + const service = makeService(provider, audit); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).resolves.toBeUndefined(); + expect(provider.sentMessages).toHaveLength(1); + expect(auditCalls).toBe(2); + }); +}); diff --git a/apps/gateway/src/agent/__tests__/session-ownership.test.ts b/apps/gateway/src/agent/__tests__/session-ownership.test.ts new file mode 100644 index 00000000..4fc0a284 --- /dev/null +++ b/apps/gateway/src/agent/__tests__/session-ownership.test.ts @@ -0,0 +1,262 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} })); +vi.mock('../../commands/command-executor.service.js', () => ({ + CommandExecutorService: class CommandExecutorService {}, +})); +vi.mock('../routing/routing-engine.service.js', () => ({ + RoutingEngineService: class RoutingEngineService {}, +})); + +import { SessionsController } from '../sessions.controller.js'; +import { ChatController } from '../../chat/chat.controller.js'; +import { ChatGateway } from '../../chat/chat.gateway.js'; +import type { AgentSession } from '../agent.service.js'; +import type { SessionInfoDto } from '../session.dto.js'; + +const USER_A = { id: 'user-a', tenantId: 'tenant-a' }; +const USER_B = { id: 'user-b', tenantId: 'tenant-b' }; +const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111'; + +function makeSessionInfo(overrides?: Partial): SessionInfoDto { + return { + id: CONVERSATION_ID, + provider: 'test-provider', + modelId: 'test-model', + createdAt: new Date('2026-07-12T00:00:00Z').toISOString(), + promptCount: 0, + channels: [], + durationMs: 0, + metrics: { + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + modelSwitches: 0, + messageCount: 0, + lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(), + }, + ...overrides, + }; +} + +function makeAgentSession(owner = USER_A): AgentSession { + return { + id: CONVERSATION_ID, + provider: 'test-provider', + modelId: 'test-model', + piSession: { + thinkingLevel: 'off', + getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']), + setThinkingLevel: vi.fn(), + abort: vi.fn().mockResolvedValue(undefined), + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + getSessionStats: vi.fn(), + getContextUsage: vi.fn(), + } as unknown as AgentSession['piSession'], + listeners: new Set(), + unsubscribe: vi.fn(), + createdAt: Date.now(), + promptCount: 0, + channels: new Set(), + skillPromptAdditions: [], + sandboxDir: '/tmp', + allowedTools: null, + userId: owner.id, + tenantId: owner.tenantId, + metrics: { + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + modelSwitches: 0, + messageCount: 0, + lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(), + }, + }; +} + +function makeScopedAgentService() { + const foreign = makeAgentSession(USER_A); + return { + listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) => + scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })], + ), + getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) => + scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }), + ), + destroySession: vi.fn(), + getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) => + scope?.userId === USER_B.id ? undefined : foreign, + ), + createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')), + onEvent: vi.fn(() => vi.fn()), + addChannel: vi.fn(), + removeChannel: vi.fn(), + recordMessage: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('TESS-M1-SEC-002 AgentService ownership boundary', () => { + it('requires explicit owner+tenant scope on protected session operations', () => { + const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8'); + + expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)'); + expect(source).toContain('listSessions(scope: ActorTenantScope)'); + expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)'); + expect(source).toContain( + 'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)', + ); + expect(source).toContain( + 'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)', + ); + expect(source).toContain( + 'async prompt(sessionId: string, message: string, scope: ActorTenantScope)', + ); + expect(source).toContain('scope: ActorTenantScope,'); + expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)'); + expect(source).not.toContain('scope?: ActorTenantScope'); + }); +}); + +describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => { + it('lists only sessions owned by the authenticated owner+tenant scope', () => { + const agentService = makeScopedAgentService(); + const controller = new SessionsController(agentService as never); + + expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 }); + expect(agentService.listSessions).toHaveBeenCalledWith({ + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + }); + + it('does not reveal another owner/tenant session by guessed id', () => { + const agentService = makeScopedAgentService(); + const controller = new SessionsController(agentService as never); + + expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException); + expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + }); + + it('does not terminate another owner/tenant session by guessed id', async () => { + const agentService = makeScopedAgentService(); + const controller = new SessionsController(agentService as never); + + await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(agentService.destroySession).not.toHaveBeenCalled(); + }); +}); + +describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => { + it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => { + const agentService = makeScopedAgentService(); + const controller = new ChatController(agentService as never); + + await expect( + controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B), + ).rejects.toMatchObject({ status: 404 }); + + expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect(agentService.prompt).not.toHaveBeenCalled(); + }); +}); + +describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => { + function makeGateway(agentService = makeScopedAgentService()) { + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue(undefined), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + addMessage: vi.fn().mockResolvedValue(undefined), + }, + }; + const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) }; + const commandExecutor = { execute: vi.fn() }; + const routingEngine = { + resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }), + }; + const gateway = new ChatGateway( + agentService as never, + {} as never, + brain as never, + commandRegistry as never, + commandExecutor as never, + routingEngine as never, + ); + return { gateway, agentService }; + } + + function makeSocket() { + return { + id: 'socket-b', + connected: true, + data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } }, + emit: vi.fn(), + disconnect: vi.fn(), + }; + } + + it('does not attach or send to another owner/tenant session by guessed conversationId', async () => { + const { gateway, agentService } = makeGateway(); + const socket = makeSocket(); + + await gateway.handleMessage(socket as never, { + conversationId: CONVERSATION_ID, + content: 'attach to foreign session', + }); + + expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect(agentService.onEvent).not.toHaveBeenCalled(); + expect(agentService.addChannel).not.toHaveBeenCalled(); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: CONVERSATION_ID }), + ); + }); + + it('does not mutate thinking level on another owner/tenant session', () => { + const { gateway, agentService } = makeGateway(); + const socket = makeSocket(); + + gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' }); + + expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: CONVERSATION_ID }), + ); + }); + + it('does not terminate another owner/tenant session over WebSocket abort', async () => { + const { gateway, agentService } = makeGateway(); + const socket = makeSocket(); + + await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }); + + expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: CONVERSATION_ID }), + ); + }); +}); diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index 94b9fa3c..36169cd8 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -1,4 +1,5 @@ import { Global, Module } from '@nestjs/common'; +import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } from '@mosaicstack/agent'; import { AgentService } from './agent.service.js'; import { ProviderService } from './provider.service.js'; import { ProviderCredentialsService } from './provider-credentials.service.js'; @@ -8,24 +9,79 @@ import { SkillLoaderService } from './skill-loader.service.js'; import { ProvidersController } from './providers.controller.js'; import { SessionsController } from './sessions.controller.js'; import { AgentConfigsController } from './agent-configs.controller.js'; +import { InteractionController } from './interaction.controller.js'; import { RoutingController } from './routing/routing.controller.js'; +import { DurableSessionRepository } from './durable-session.repository.js'; +import { DurableSessionService } from './durable-session.service.js'; import { CoordModule } from '../coord/coord.module.js'; import { McpClientModule } from '../mcp-client/mcp-client.module.js'; import { SkillsModule } from '../skills/skills.module.js'; import { GCModule } from '../gc/gc.module.js'; +import { LogModule } from '../log/log.module.js'; +import { CommandsModule } from '../commands/commands.module.js'; +import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js'; +import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js'; +import { ConnectorLeaseRepository } from './connector-lease.repository.js'; +import { + CONNECTOR_LEASE_POLICY, + ConnectorLeaseService, + DenyConnectorLeasePolicy, +} from './connector-lease.service.js'; +import { + AGENT_RUNTIME_PROVIDER_REGISTRY, + RUNTIME_APPROVAL_VERIFIER, + RUNTIME_PROVIDER_AUDIT_SINK, + RuntimeProviderAuditService, + RuntimeProviderService, +} from './runtime-provider-registry.service.js'; + +export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegistry { + const registry = new AgentRuntimeProviderRegistry(); + registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport())); + return registry; +} @Global() @Module({ - imports: [CoordModule, McpClientModule, SkillsModule, GCModule], + imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule], providers: [ ProviderService, ProviderCredentialsService, RoutingService, RoutingEngineService, SkillLoaderService, + DurableSessionRepository, + DurableSessionService, + ConnectorLeaseRepository, + DenyConnectorLeasePolicy, + { + provide: CONNECTOR_LEASE_POLICY, + useExisting: DenyConnectorLeasePolicy, + }, + ConnectorLeaseService, + { + provide: AGENT_RUNTIME_PROVIDER_REGISTRY, + useFactory: createGatewayRuntimeProviderRegistry, + }, + RuntimeProviderAuditService, + { + provide: RUNTIME_PROVIDER_AUDIT_SINK, + useExisting: RuntimeProviderAuditService, + }, + { + provide: RUNTIME_APPROVAL_VERIFIER, + useExisting: CommandRuntimeApprovalVerifier, + }, + RuntimeProviderService, AgentService, ], - controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController], + controllers: [ + ProvidersController, + SessionsController, + AgentConfigsController, + InteractionController, + RoutingController, + ], exports: [ AgentService, ProviderService, @@ -33,6 +89,10 @@ import { GCModule } from '../gc/gc.module.js'; RoutingService, RoutingEngineService, SkillLoaderService, + DurableSessionService, + RuntimeProviderService, + ConnectorLeaseService, + AGENT_RUNTIME_PROVIDER_REGISTRY, ], }) export class AgentModule {} diff --git a/apps/gateway/src/agent/agent.service.ts b/apps/gateway/src/agent/agent.service.ts index 83cb057a..38192969 100644 --- a/apps/gateway/src/agent/agent.service.ts +++ b/apps/gateway/src/agent/agent.service.ts @@ -1,4 +1,11 @@ -import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common'; +import { + ForbiddenException, + Inject, + Injectable, + Logger, + Optional, + type OnModuleDestroy, +} from '@nestjs/common'; import { createAgentSession, DefaultResourceLoader, @@ -8,9 +15,11 @@ import { type ToolDefinition, } from '@mariozechner/pi-coding-agent'; import type { Brain } from '@mosaicstack/brain'; -import type { Memory } from '@mosaicstack/memory'; +import type { ChannelAttachmentDto } from '@mosaicstack/types'; +import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory'; import { BRAIN } from '../brain/brain.tokens.js'; import { MEMORY } from '../memory/memory.tokens.js'; +import { OPERATOR_MEMORY_PLUGIN } from '../memory/memory.module.js'; import { EmbeddingService } from '../memory/embedding.service.js'; import { CoordService } from '../coord/coord.service.js'; import { ProviderService } from './provider.service.js'; @@ -28,12 +37,15 @@ import type { SessionInfoDto, SessionMetrics } from './session.dto.js'; import { SystemOverrideService } from '../preferences/system-override.service.js'; import { PreferencesService } from '../preferences/preferences.service.js'; import { SessionGCService } from '../gc/session-gc.service.js'; +import type { ActorTenantScope } from '../auth/session-scope.js'; /** A single message from DB conversation history, used for context injection. */ export interface ConversationHistoryMessage { role: 'user' | 'assistant' | 'system'; content: string; createdAt: Date; + /** Validated, URI-referenced channel attachments preserved on session resume. */ + attachments?: readonly ChannelAttachmentDto[]; } export interface AgentSessionOptions { @@ -68,6 +80,8 @@ export interface AgentSessionOptions { agentConfigId?: string; /** ID of the user who owns this session. Used for preferences and system override lookups. */ userId?: string; + /** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */ + tenantId?: string; /** * Prior conversation messages to inject as context when resuming a session. * These messages are formatted and prepended to the system prompt so the @@ -94,6 +108,8 @@ export interface AgentSession { allowedTools: string[] | null; /** User ID that owns this session, used for preference lookups. */ userId?: string; + /** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */ + tenantId?: string; /** Agent config ID applied to this session, if any (M5-001). */ agentConfigId?: string; /** Human-readable agent name applied to this session, if any (M5-001). */ @@ -123,6 +139,9 @@ export class AgentService implements OnModuleDestroy { @Inject(PreferencesService) private readonly preferencesService: PreferencesService | null, @Inject(SessionGCService) private readonly gc: SessionGCService, + @Optional() + @Inject(OPERATOR_MEMORY_PLUGIN) + private readonly operatorMemory: OperatorMemoryPlugin | null = null, ) {} /** @@ -134,6 +153,7 @@ export class AgentService implements OnModuleDestroy { private buildToolsForSandbox( sandboxDir: string, sessionUserId: string | undefined, + sessionScope?: { tenantId: string; ownerId: string; sessionId: string }, ): ToolDefinition[] { return [ ...createBrainTools(this.brain), @@ -142,6 +162,9 @@ export class AgentService implements OnModuleDestroy { this.memory, this.embeddingService.available ? this.embeddingService : null, sessionUserId, + this.operatorMemory && sessionScope + ? { plugin: this.operatorMemory, scope: sessionScope } + : undefined, ), ...createFileTools(sandboxDir), ...createGitTools(sandboxDir), @@ -174,12 +197,20 @@ export class AgentService implements OnModuleDestroy { .filter((t) => t.length > 0); } - async createSession(sessionId: string, options?: AgentSessionOptions): Promise { + async createSession(sessionId: string, options: AgentSessionOptions): Promise { + const scope = this.scopeFromOptions(options); const existing = this.sessions.get(sessionId); - if (existing) return existing; + if (existing) { + this.assertSessionScope(existing, scope); + return existing; + } const inflight = this.creating.get(sessionId); - if (inflight) return inflight; + if (inflight) { + const session = await inflight; + this.assertSessionScope(session, scope); + return session; + } const promise = this.doCreateSession(sessionId, options).finally(() => { this.creating.delete(sessionId); @@ -208,6 +239,7 @@ export class AgentService implements OnModuleDestroy { isAdmin: options.isAdmin, agentConfigId: options.agentConfigId, userId: options.userId, + tenantId: options.tenantId, conversationHistory: options.conversationHistory, }; this.logger.log( @@ -247,7 +279,15 @@ export class AgentService implements OnModuleDestroy { } // Build per-session tools scoped to the sandbox directory and authenticated user - const sandboxTools = this.buildToolsForSandbox(sandboxDir, mergedOptions?.userId); + const sessionUserId = mergedOptions?.userId; + const sessionTenantId = this.tenantIdFor(sessionUserId, mergedOptions?.tenantId); + const sandboxTools = this.buildToolsForSandbox( + sandboxDir, + sessionUserId, + sessionUserId && sessionTenantId + ? { tenantId: sessionTenantId, ownerId: sessionUserId, sessionId } + : undefined, + ); // Combine static tools with dynamically discovered MCP client tools and skill tools const mcpTools = this.mcpClientService.getToolDefinitions(); @@ -342,6 +382,7 @@ export class AgentService implements OnModuleDestroy { sandboxDir, allowedTools, userId: mergedOptions?.userId, + tenantId: sessionTenantId, agentConfigId: mergedOptions?.agentConfigId, agentName: resolvedAgentName, metrics: { @@ -390,7 +431,7 @@ export class AgentService implements OnModuleDestroy { const formatMessage = (msg: ConversationHistoryMessage): string => { const roleLabel = msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System'; - return `**${roleLabel}:** ${msg.content}`; + return `**${roleLabel}:** ${msg.content}${this.attachmentContext(msg.attachments ?? [])}`; }; const formatted = history.map((msg) => formatMessage(msg)); @@ -449,6 +490,21 @@ export class AgentService implements OnModuleDestroy { return result; } + private attachmentContext(attachments: readonly ChannelAttachmentDto[]): string { + if (attachments.length === 0) return ''; + return `\n\n[Untrusted channel attachments]\n${attachments + .map((attachment: ChannelAttachmentDto): string => + JSON.stringify({ + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + url: attachment.url, + ...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}), + }), + ) + .join('\n')}`; + } + private resolveModel(options?: AgentSessionOptions) { if (!options?.provider && !options?.modelId) { return this.providerService.getDefaultModel() ?? null; @@ -473,38 +529,70 @@ export class AgentService implements OnModuleDestroy { return this.providerService.getDefaultModel() ?? null; } - getSession(sessionId: string): AgentSession | undefined { - return this.sessions.get(sessionId); + getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined { + const session = this.sessions.get(sessionId); + if (!session || !this.sessionMatchesScope(session, scope)) return undefined; + return session; } - listSessions(): SessionInfoDto[] { + listSessions(scope: ActorTenantScope): SessionInfoDto[] { const now = Date.now(); - return Array.from(this.sessions.values()).map((s) => ({ - id: s.id, - provider: s.provider, - modelId: s.modelId, - ...(s.agentName ? { agentName: s.agentName } : {}), - createdAt: new Date(s.createdAt).toISOString(), - promptCount: s.promptCount, - channels: Array.from(s.channels), - durationMs: now - s.createdAt, - metrics: { ...s.metrics }, - })); + return Array.from(this.sessions.values()) + .filter((s) => this.sessionMatchesScope(s, scope)) + .map((s) => this.toSessionInfo(s, now)); } - getSessionInfo(sessionId: string): SessionInfoDto | undefined { + listAllSessionsForSystem(): SessionInfoDto[] { + const now = Date.now(); + return Array.from(this.sessions.values()).map((s) => this.toSessionInfo(s, now)); + } + + getSessionInfo(sessionId: string, scope: ActorTenantScope): SessionInfoDto | undefined { const s = this.sessions.get(sessionId); - if (!s) return undefined; + if (!s || !this.sessionMatchesScope(s, scope)) return undefined; + return this.toSessionInfo(s); + } + + private scopeFromOptions(options: AgentSessionOptions): ActorTenantScope { + if (!options.userId) { + throw new ForbiddenException('Session owner scope is required'); + } return { - id: s.id, - provider: s.provider, - modelId: s.modelId, - ...(s.agentName ? { agentName: s.agentName } : {}), - createdAt: new Date(s.createdAt).toISOString(), - promptCount: s.promptCount, - channels: Array.from(s.channels), - durationMs: Date.now() - s.createdAt, - metrics: { ...s.metrics }, + userId: options.userId, + tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId, + }; + } + + private tenantIdFor( + userId: string | undefined, + tenantId: string | undefined, + ): string | undefined { + return tenantId ?? userId; + } + + private sessionMatchesScope(session: AgentSession, scope: ActorTenantScope): boolean { + return ( + session.userId === scope.userId && (session.tenantId ?? session.userId) === scope.tenantId + ); + } + + private assertSessionScope(session: AgentSession, scope: ActorTenantScope): void { + if (!this.sessionMatchesScope(session, scope)) { + throw new ForbiddenException('Session does not belong to the current owner/tenant scope'); + } + } + + private toSessionInfo(session: AgentSession, now = Date.now()): SessionInfoDto { + return { + id: session.id, + provider: session.provider, + modelId: session.modelId, + ...(session.agentName ? { agentName: session.agentName } : {}), + createdAt: new Date(session.createdAt).toISOString(), + promptCount: session.promptCount, + channels: Array.from(session.channels), + durationMs: now - session.createdAt, + metrics: { ...session.metrics }, }; } @@ -553,9 +641,10 @@ export class AgentService implements OnModuleDestroy { * not reconstructed — the model is used on the next createSession call for * the same conversationId when the session is torn down or a new one is created. */ - updateSessionModel(sessionId: string, modelId: string): void { + updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void { const session = this.sessions.get(sessionId); if (!session) return; + this.assertSessionScope(session, scope); const prev = session.modelId; session.modelId = modelId; this.recordModelSwitch(sessionId); @@ -572,48 +661,67 @@ export class AgentService implements OnModuleDestroy { sessionId: string, agentConfigId: string, agentName: string, + scope: ActorTenantScope, modelId?: string, ): void { const session = this.sessions.get(sessionId); if (!session) return; + this.assertSessionScope(session, scope); session.agentConfigId = agentConfigId; session.agentName = agentName; if (modelId) { - this.updateSessionModel(sessionId, modelId); + this.updateSessionModel(sessionId, modelId, scope); } this.logger.log( `Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`, ); } - addChannel(sessionId: string, channel: string): void { + addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void { const session = this.sessions.get(sessionId); - if (session) { - session.channels.add(channel); - } + if (!session) return; + this.assertSessionScope(session, scope); + session.channels.add(channel); } - removeChannel(sessionId: string, channel: string): void { + removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void { const session = this.sessions.get(sessionId); - if (session) { - session.channels.delete(channel); - } + if (!session) return; + this.assertSessionScope(session, scope); + session.channels.delete(channel); } - async prompt(sessionId: string, message: string): Promise { + async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise; + async prompt( + sessionId: string, + message: string, + scope: ActorTenantScope, + attachments: readonly ChannelAttachmentDto[] | undefined, + ): Promise; + async prompt( + sessionId: string, + message: string, + scope: ActorTenantScope, + attachments: readonly ChannelAttachmentDto[] = [], + ): Promise { const session = this.sessions.get(sessionId); if (!session) { throw new Error(`No agent session found: ${sessionId}`); } + this.assertSessionScope(session, scope); session.promptCount += 1; + // Channel attachments are untrusted URI references. Preserve exact, + // authenticated metadata for the agent without treating it as authority. + const attachmentContext = this.attachmentContext(attachments); + // Prepend session-scoped system override if present (renew TTL on each turn) - let effectiveMessage = message; + let effectiveMessage = `${message}${attachmentContext}`; if (this.systemOverride) { - const override = await this.systemOverride.get(sessionId); + const override = await this.systemOverride.get(sessionId, scope); if (override) { - effectiveMessage = `[System Override]\n${override}\n\n${message}`; - await this.systemOverride.renew(sessionId); + effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`; + await this.systemOverride.renew(sessionId, scope); this.logger.debug(`Applied system override for session ${sessionId}`); } } @@ -629,16 +737,28 @@ export class AgentService implements OnModuleDestroy { } } - onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void { + onEvent( + sessionId: string, + listener: (event: AgentSessionEvent) => void, + scope: ActorTenantScope, + ): () => void { const session = this.sessions.get(sessionId); if (!session) { throw new Error(`No agent session found: ${sessionId}`); } + this.assertSessionScope(session, scope); session.listeners.add(listener); return () => session.listeners.delete(listener); } - async destroySession(sessionId: string): Promise { + async destroySession(sessionId: string, scope: ActorTenantScope): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + this.assertSessionScope(session, scope); + await this.destroySessionForSystem(sessionId); + } + + private async destroySessionForSystem(sessionId: string): Promise { const session = this.sessions.get(sessionId); if (!session) return; this.logger.log(`Destroying agent session ${sessionId}`); @@ -667,7 +787,7 @@ export class AgentService implements OnModuleDestroy { async onModuleDestroy(): Promise { this.logger.log('Shutting down all agent sessions'); - const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id)); + const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id)); const results = await Promise.allSettled(stops); for (const result of results) { if (result.status === 'rejected') { diff --git a/apps/gateway/src/agent/connector-lease.integration.test.ts b/apps/gateway/src/agent/connector-lease.integration.test.ts new file mode 100644 index 00000000..a54c123b --- /dev/null +++ b/apps/gateway/src/agent/connector-lease.integration.test.ts @@ -0,0 +1,341 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +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 { + connectorLeaseAuditLog, + createPgliteDb, + eq, + runPgliteMigrations, + type DbHandle, +} from '@mosaicstack/db'; +import type { ConnectorExecutionContext, FencedConnectorAdapter } from '@mosaicstack/types'; +import { DB } from '../database/database.module.js'; +import { ConnectorLeaseRepository } from './connector-lease.repository.js'; +import { + CONNECTOR_LEASE_POLICY, + ConnectorLeaseService, + type ConnectorLeasePolicy, + type ConnectorLeasePolicySubject, +} from './connector-lease.service.js'; + +const authorize = vi.fn().mockResolvedValue(true); +const policy: ConnectorLeasePolicy = { authorize }; +const context = { + actorScope: { userId: 'operator-a', tenantId: 'tenant-a' }, + correlationId: 'correlation-acquire', +}; + +describe('gateway connector lease fencing integration', (): void => { + let dataDir: string; + let handle: DbHandle; + let moduleRef: TestingModule; + let service: ConnectorLeaseService; + let repository: ConnectorLeaseRepository; + + beforeAll(async (): Promise => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-14T17:00:00.000Z')); + dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-connector-lease-')); + handle = createPgliteDb(dataDir); + await runPgliteMigrations(handle); + moduleRef = await Test.createTestingModule({ + providers: [ + ConnectorLeaseRepository, + ConnectorLeaseService, + { provide: DB, useValue: handle.db }, + { provide: CONNECTOR_LEASE_POLICY, useValue: policy }, + ], + }).compile(); + service = moduleRef.get(ConnectorLeaseService); + repository = moduleRef.get(ConnectorLeaseRepository); + }); + + afterAll(async (): Promise => { + vi.useRealTimers(); + await moduleRef.close(); + await handle.close(); + await rm(dataDir, { recursive: true, force: true }); + }); + + it('derives tenant authority at the gateway and validates a grant before side effects', async (): Promise => { + const lease = await service.acquire( + { + logicalAgentId: 'Mos', + bindingId: 'operator-chat', + connectorId: 'pi-worker-a', + scopes: ['runtime.send'], + ttlMs: 60_000, + }, + context, + ); + const grant = await service.issueGrant( + { lease, scopes: ['runtime.send'], ttlMs: 30_000 }, + { ...context, correlationId: 'correlation-grant' }, + ); + const execute = vi.fn(async (_message: string, leaseContext: ConnectorExecutionContext) => { + return leaseContext.leaseEpoch; + }); + const adapter: FencedConnectorAdapter = { execute }; + + await expect(service.executeGrant(grant, 'runtime.send', 'hello', adapter)).resolves.toBe('1'); + expect(execute).toHaveBeenCalledOnce(); + expect(authorize).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'grant.issue', + requestedScopes: ['runtime.send'], + requestedTtlMs: 30_000, + }), + ); + expect(execute.mock.calls[0]?.[1]).toMatchObject({ + identity: { tenantId: 'tenant-a', logicalAgentId: 'mos' }, + bindingId: 'operator-chat', + connectorId: 'pi-worker-a', + }); + }); + + it('normalizes lease-derived policy subjects before authorization', async (): Promise => { + const lease = await service.acquire( + { + logicalAgentId: 'mos', + bindingId: 'operator-chat-policy', + connectorId: 'pi-worker-a', + scopes: ['runtime.send'], + ttlMs: 60_000, + }, + { ...context, correlationId: 'correlation-policy-setup' }, + ); + const aliasedLease = { + ...lease, + identity: { ...lease.identity, logicalAgentId: ' MOS ' }, + bindingId: ' Operator-Chat-Policy ', + connectorId: ' PI-Worker-A ', + scopes: [' Runtime.Send '], + leaseEpoch: `00${lease.leaseEpoch}`, + }; + + await service.heartbeat(aliasedLease, 30_000, { + ...context, + correlationId: 'correlation-policy-heartbeat', + }); + expect(authorize).toHaveBeenLastCalledWith( + expect.objectContaining({ + action: 'lease.heartbeat', + logicalAgentId: 'mos', + bindingId: 'operator-chat-policy', + connectorId: 'pi-worker-a', + requestedScopes: ['runtime.send'], + }), + ); + + await service.issueGrant( + { lease: aliasedLease, scopes: [' Runtime.Send '], ttlMs: 1_000 }, + { ...context, correlationId: 'correlation-policy-grant' }, + ); + expect(authorize).toHaveBeenLastCalledWith( + expect.objectContaining({ + action: 'grant.issue', + logicalAgentId: 'mos', + bindingId: 'operator-chat-policy', + connectorId: 'pi-worker-a', + requestedScopes: ['runtime.send'], + }), + ); + + await service.release(aliasedLease, { + ...context, + correlationId: 'correlation-policy-release', + }); + expect(authorize).toHaveBeenLastCalledWith( + expect.objectContaining({ + action: 'lease.release', + logicalAgentId: 'mos', + bindingId: 'operator-chat-policy', + connectorId: 'pi-worker-a', + requestedScopes: ['runtime.send'], + }), + ); + }); + + it('denies stale, forged, expired, cross-tenant, and cross-binding grants before effects', async (): Promise => { + const bindingId = 'operator-chat-denials'; + const current = await service.acquire( + { + logicalAgentId: 'mos', + bindingId, + connectorId: 'pi-worker-a', + scopes: ['runtime.send'], + ttlMs: 60_000, + }, + { ...context, correlationId: 'correlation-denial-setup' }, + ); + const stale = await service.issueGrant( + { lease: current, scopes: ['runtime.send'], ttlMs: 30_000 }, + { ...context, correlationId: 'correlation-stale' }, + ); + await service.takeover( + { + logicalAgentId: 'mos', + bindingId, + connectorId: 'pi-worker-b', + scopes: ['runtime.send'], + ttlMs: 60_000, + expectedEpoch: current.leaseEpoch, + }, + { ...context, correlationId: 'correlation-takeover' }, + ); + const adapter = { execute: vi.fn().mockResolvedValue(undefined) }; + + await expect(service.executeGrant(stale, 'runtime.send', undefined, adapter)).rejects.toThrow(); + + const active = await service.current('mos', bindingId, context); + if (!active) throw new Error('active lease fixture is unavailable'); + const grant = await service.issueGrant( + { lease: active, scopes: ['runtime.send'], ttlMs: 1_000 }, + { ...context, correlationId: 'correlation-active' }, + ); + await expect( + service.executeGrant({ ...grant }, 'runtime.send', undefined, adapter), + ).rejects.toThrow(); + await expect( + service.executeGrant( + { ...grant, bindingId: 'other-binding' }, + 'runtime.send', + undefined, + adapter, + ), + ).rejects.toThrow(); + await expect( + service.issueGrant( + { lease: active, scopes: ['runtime.send'], ttlMs: 30_000 }, + { + actorScope: { userId: 'operator-b', tenantId: 'tenant-b' }, + correlationId: 'correlation-cross-tenant', + }, + ), + ).rejects.toThrow(); + const crossTenantAudit = await handle.db + .select() + .from(connectorLeaseAuditLog) + .where(eq(connectorLeaseAuditLog.correlationId, 'correlation-cross-tenant')); + expect(crossTenantAudit).toHaveLength(1); + expect(crossTenantAudit[0]).toMatchObject({ + tenantId: 'tenant-b', + logicalAgentId: 'untrusted', + bindingId: 'untrusted', + connectorId: 'untrusted', + reason: 'policy_denied', + }); + + vi.setSystemTime(new Date('2026-07-14T17:00:02.000Z')); + await expect(service.executeGrant(grant, 'runtime.send', undefined, adapter)).rejects.toThrow(); + expect(adapter.execute).not.toHaveBeenCalled(); + }); + + it('rejects submitted lifecycle scopes that differ from durable authority before policy or mutation', async (): Promise => { + authorize.mockResolvedValue(true); + const heartbeatLease = await service.acquire( + { + logicalAgentId: 'mos', + bindingId: 'operator-chat-heartbeat-scope', + connectorId: 'pi-worker-a', + scopes: ['runtime.send'], + ttlMs: 60_000, + }, + { ...context, correlationId: 'correlation-heartbeat-scope-setup' }, + ); + const releaseLease = await service.acquire( + { + logicalAgentId: 'mos', + bindingId: 'operator-chat-release-scope', + connectorId: 'pi-worker-a', + scopes: ['runtime.send'], + ttlMs: 60_000, + }, + { ...context, correlationId: 'correlation-release-scope-setup' }, + ); + const forgedHeartbeat = { ...heartbeatLease, scopes: ['tool.execute'] }; + const forgedRelease = { ...releaseLease, scopes: ['tool.execute'] }; + + authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => { + return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'tool.execute'; + }); + authorize.mockClear(); + + await expect( + service.heartbeat(forgedHeartbeat, 30_000, { + ...context, + correlationId: 'correlation-heartbeat-scope-forgery', + }), + ).rejects.toThrow('Connector authority policy denied'); + await expect( + service.release(forgedRelease, { + ...context, + correlationId: 'correlation-release-scope-forgery', + }), + ).rejects.toThrow('Connector authority policy denied'); + expect(authorize).not.toHaveBeenCalled(); + + const currentHeartbeat = await repository.findCurrent({ + identity: heartbeatLease.identity, + bindingId: heartbeatLease.bindingId, + }); + const currentRelease = await repository.findCurrent({ + identity: releaseLease.identity, + bindingId: releaseLease.bindingId, + }); + expect(currentHeartbeat).toMatchObject({ + leaseId: heartbeatLease.leaseId, + scopes: ['runtime.send'], + heartbeatAt: heartbeatLease.heartbeatAt, + expiresAt: heartbeatLease.expiresAt, + }); + expect(currentRelease).toMatchObject({ + leaseId: releaseLease.leaseId, + scopes: ['runtime.send'], + }); + expect(currentRelease?.releasedAt).toBeUndefined(); + + const forgedAudits = await handle.db + .select() + .from(connectorLeaseAuditLog) + .where(eq(connectorLeaseAuditLog.correlationId, 'correlation-heartbeat-scope-forgery')); + expect(forgedAudits).toHaveLength(1); + expect(forgedAudits[0]).toMatchObject({ + bindingId: heartbeatLease.bindingId, + connectorId: heartbeatLease.connectorId, + event: 'reject', + outcome: 'denied', + reason: 'policy_denied', + }); + const forgedReleaseAudits = await handle.db + .select() + .from(connectorLeaseAuditLog) + .where(eq(connectorLeaseAuditLog.correlationId, 'correlation-release-scope-forgery')); + expect(forgedReleaseAudits).toHaveLength(1); + expect(forgedReleaseAudits[0]).toMatchObject({ + bindingId: releaseLease.bindingId, + connectorId: releaseLease.connectorId, + event: 'reject', + outcome: 'denied', + reason: 'policy_denied', + }); + + authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => { + return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'runtime.send'; + }); + await expect( + service.heartbeat(heartbeatLease, 30_000, { + ...context, + correlationId: 'correlation-heartbeat-scope-canonical', + }), + ).resolves.toMatchObject({ scopes: ['runtime.send'] }); + await expect( + service.release(releaseLease, { + ...context, + correlationId: 'correlation-release-scope-canonical', + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/gateway/src/agent/connector-lease.postgres.integration.test.ts b/apps/gateway/src/agent/connector-lease.postgres.integration.test.ts new file mode 100644 index 00000000..feebfc7c --- /dev/null +++ b/apps/gateway/src/agent/connector-lease.postgres.integration.test.ts @@ -0,0 +1,76 @@ +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + connectorLeaseAuditLog, + createDb, + eq, + logicalAgentConnectorLeases, + type DbHandle, +} from '@mosaicstack/db'; +import { ConnectorLeaseCoordinator } from '@mosaicstack/agent'; +import { ConnectorLeaseRepository } from './connector-lease.repository.js'; + +const hasPostgres = Boolean(process.env['DATABASE_URL']); +const tenantId = `lease-test-${randomUUID()}`; +const identity = { tenantId, logicalAgentId: 'mos' } as const; + +describe.skipIf(!hasPostgres)('ConnectorLeaseRepository real PostgreSQL integration', (): void => { + let handle: DbHandle; + + beforeAll((): void => { + handle = createDb(process.env['DATABASE_URL']); + }); + + afterAll(async (): Promise => { + if (!handle) return; + await handle.db + .delete(connectorLeaseAuditLog) + .where(eq(connectorLeaseAuditLog.tenantId, tenantId)); + await handle.db + .delete(logicalAgentConnectorLeases) + .where(eq(logicalAgentConnectorLeases.tenantId, tenantId)); + await handle.close(); + }); + + it('preserves the exclusive CAS fence across a real pool close/reopen', async (): Promise => { + const command = { + identity, + bindingId: 'operator-chat', + scopes: ['runtime.send'], + ttlMs: 60_000, + } as const; + const firstCoordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db)); + const contenders = await Promise.allSettled([ + firstCoordinator.acquire({ + ...command, + connectorId: 'connector-a', + correlationId: 'postgres-acquire-a', + }), + firstCoordinator.acquire({ + ...command, + connectorId: 'connector-b', + correlationId: 'postgres-acquire-b', + }), + ]); + const acquired = contenders.find((result) => result.status === 'fulfilled'); + if (!acquired || acquired.status !== 'fulfilled') throw new Error('no lease contender won'); + expect(contenders.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + + await handle.close(); + handle = createDb(process.env['DATABASE_URL']); + const reopened = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db)); + const persisted = await reopened.current({ identity, bindingId: 'operator-chat' }); + expect(persisted).toMatchObject({ + leaseId: acquired.value.leaseId, + leaseEpoch: '1', + }); + + const takeover = await reopened.takeover({ + ...command, + connectorId: 'connector-c', + correlationId: 'postgres-takeover', + expectedEpoch: acquired.value.leaseEpoch, + }); + expect(takeover).toMatchObject({ connectorId: 'connector-c', leaseEpoch: '2' }); + }); +}); diff --git a/apps/gateway/src/agent/connector-lease.repository.test.ts b/apps/gateway/src/agent/connector-lease.repository.test.ts new file mode 100644 index 00000000..999257be --- /dev/null +++ b/apps/gateway/src/agent/connector-lease.repository.test.ts @@ -0,0 +1,149 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + connectorLeaseAuditLog, + createPgliteDb, + eq, + runPgliteMigrations, + type DbHandle, +} from '@mosaicstack/db'; +import { ConnectorLeaseCoordinator, ConnectorLeaseError } from '@mosaicstack/agent'; +import { ConnectorLeaseRepository } from './connector-lease.repository.js'; + +const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const; + +function acquireCommand(connectorId: string, correlationId: string) { + return { + identity, + bindingId: 'operator-chat', + connectorId, + scopes: ['runtime.send', 'tool.execute'], + ttlMs: 60_000, + correlationId, + }; +} + +describe('ConnectorLeaseRepository PostgreSQL semantics', (): void => { + let dataDir: string; + let handle: DbHandle; + let now: Date; + let coordinator: ConnectorLeaseCoordinator; + + beforeEach(async (): Promise => { + dataDir = await mkdtemp(join(tmpdir(), 'mosaic-connector-lease-')); + handle = createPgliteDb(dataDir); + await runPgliteMigrations(handle); + now = new Date('2026-07-14T17:00:00.000Z'); + coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), { + now: (): Date => now, + }); + }); + + afterEach(async (): Promise => { + await handle.close(); + await rm(dataDir, { recursive: true, force: true }); + }); + + it('allows only one concurrent contender to acquire a binding', async (): Promise => { + const outcomes = await Promise.allSettled([ + coordinator.acquire(acquireCommand('connector-a', 'correlation-a')), + coordinator.acquire(acquireCommand('connector-b', 'correlation-b')), + ]); + + expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + const rejected = outcomes.find((result) => result.status === 'rejected'); + expect(rejected).toMatchObject({ + reason: { code: 'lease_held' } satisfies Partial, + }); + }); + + it('uses compare-and-swap takeover and increments the fencing epoch monotonically', async (): Promise => { + const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a')); + const results = await Promise.allSettled([ + coordinator.takeover({ + ...acquireCommand('connector-b', 'correlation-b'), + expectedEpoch: acquired.leaseEpoch, + }), + coordinator.takeover({ + ...acquireCommand('connector-c', 'correlation-c'), + expectedEpoch: acquired.leaseEpoch, + }), + ]); + const winner = results.find((result) => result.status === 'fulfilled'); + + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect(winner?.status === 'fulfilled' ? winner.value.leaseEpoch : null).toBe('2'); + expect(results.find((result) => result.status === 'rejected')).toMatchObject({ + reason: { code: 'cas_mismatch' } satisfies Partial, + }); + }); + + it('heartbeats and releases only the current connector epoch', async (): Promise => { + const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a')); + now = new Date('2026-07-14T17:00:30.000Z'); + const renewed = await coordinator.heartbeat({ + lease: acquired, + ttlMs: 120_000, + correlationId: 'correlation-renew', + }); + expect(renewed.expiresAt).toBe('2026-07-14T17:02:30.000Z'); + + await coordinator.release({ lease: renewed, correlationId: 'correlation-release' }); + await expect( + coordinator.heartbeat({ + lease: renewed, + ttlMs: 120_000, + correlationId: 'correlation-stale', + }), + ).rejects.toMatchObject({ code: 'lease_released' } satisfies Partial); + }); + + it('survives close/reopen and requires CAS takeover to recover an expired lease', async (): Promise => { + const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a')); + await handle.close(); + + now = new Date('2026-07-14T17:02:00.000Z'); + handle = createPgliteDb(dataDir); + await runPgliteMigrations(handle); + coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), { + now: (): Date => now, + }); + + await expect( + coordinator.acquire(acquireCommand('connector-b', 'correlation-plain-acquire')), + ).rejects.toMatchObject({ code: 'takeover_required' } satisfies Partial); + const recovered = await coordinator.takeover({ + ...acquireCommand('connector-b', 'correlation-takeover'), + expectedEpoch: acquired.leaseEpoch, + }); + expect(recovered).toMatchObject({ connectorId: 'connector-b', leaseEpoch: '2' }); + }); + + it('writes credential-safe lifecycle and rejection audit records', async (): Promise => { + const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a')); + await coordinator.heartbeat({ + lease: acquired, + ttlMs: 60_000, + correlationId: 'correlation-renew', + }); + await expect( + coordinator.acquire(acquireCommand('connector-b', 'correlation-reject')), + ).rejects.toBeInstanceOf(ConnectorLeaseError); + + const rows = await handle.db + .select() + .from(connectorLeaseAuditLog) + .where(eq(connectorLeaseAuditLog.tenantId, identity.tenantId)); + expect(rows.map((row) => row.event)).toEqual( + expect.arrayContaining(['acquire', 'renew', 'reject']), + ); + const serialized = JSON.stringify(rows, (_key: string, value: unknown): unknown => + typeof value === 'bigint' ? value.toString(10) : value, + ); + expect(serialized).not.toContain('tool.execute'); + expect(serialized).not.toContain('runtime.send'); + expect(serialized).not.toMatch(/token|secret|credential/i); + }); +}); diff --git a/apps/gateway/src/agent/connector-lease.repository.ts b/apps/gateway/src/agent/connector-lease.repository.ts new file mode 100644 index 00000000..fd450eae --- /dev/null +++ b/apps/gateway/src/agent/connector-lease.repository.ts @@ -0,0 +1,354 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + and, + connectorLeaseAuditLog, + eq, + gt, + isNull, + logicalAgentConnectorLeases, + sql, + type Db, +} from '@mosaicstack/db'; +import { ConnectorLeaseError } from '@mosaicstack/agent'; +import type { + ConnectorLease, + ConnectorLeaseAcquireMutation, + ConnectorLeaseAuditEvent, + ConnectorLeaseHeartbeatMutation, + ConnectorLeaseRejectReason, + ConnectorLeaseReleaseMutation, + ConnectorLeaseStore, + ConnectorLeaseTakeoverMutation, + LogicalAgentBinding, +} from '@mosaicstack/types'; +import { DB } from '../database/database.module.js'; + +interface SuccessfulMutation { + readonly ok: true; + readonly lease: ConnectorLease; +} + +interface FailedMutation { + readonly ok: false; + readonly reason: ConnectorLeaseRejectReason; +} + +type MutationResult = SuccessfulMutation | FailedMutation; + +@Injectable() +export class ConnectorLeaseRepository implements ConnectorLeaseStore { + constructor(@Inject(DB) private readonly db: Db) {} + + async acquire(input: ConnectorLeaseAcquireMutation): Promise { + const result: MutationResult = await this.db.transaction( + async (tx): Promise => { + const inserted = await tx + .insert(logicalAgentConnectorLeases) + .values({ + leaseId: input.leaseId, + tenantId: input.identity.tenantId, + logicalAgentId: input.identity.logicalAgentId, + bindingId: input.bindingId, + connectorId: input.connectorId, + scopes: [...input.scopes], + leaseEpoch: 1n, + acquiredAt: new Date(input.now), + heartbeatAt: new Date(input.now), + expiresAt: new Date(input.expiresAt), + updatedAt: new Date(input.now), + }) + .onConflictDoNothing() + .returning(); + const row = inserted[0]; + if (row) { + const lease = toLease(row); + await insertAudit(tx, lifecycleAudit(input, lease, 'acquire')); + return { ok: true, lease }; + } + + const current = await findRow(tx, input); + if (current && current.expiresAt <= new Date(input.now) && !current.releasedAt) { + await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry')); + } + const reason: ConnectorLeaseRejectReason = + current && (current.releasedAt || current.expiresAt <= new Date(input.now)) + ? 'takeover_required' + : 'lease_held'; + await insertAudit(tx, rejectionAudit(input, current ? toLease(current) : null, reason)); + return { ok: false, reason }; + }, + ); + return unwrap(result); + } + + async takeover(input: ConnectorLeaseTakeoverMutation): Promise { + const result: MutationResult = await this.db.transaction( + async (tx): Promise => { + const current = await findRow(tx, input); + if (!current || current.leaseEpoch.toString(10) !== input.expectedEpoch) { + await insertAudit( + tx, + rejectionAudit(input, current ? toLease(current) : null, 'cas_mismatch'), + ); + return { ok: false, reason: 'cas_mismatch' }; + } + if (current.expiresAt <= new Date(input.now) && !current.releasedAt) { + await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry')); + } + const updated = await tx + .update(logicalAgentConnectorLeases) + .set({ + leaseId: input.leaseId, + connectorId: input.connectorId, + scopes: [...input.scopes], + leaseEpoch: sql`${logicalAgentConnectorLeases.leaseEpoch} + 1`, + acquiredAt: new Date(input.now), + heartbeatAt: new Date(input.now), + expiresAt: new Date(input.expiresAt), + releasedAt: null, + updatedAt: new Date(input.now), + }) + .where( + and( + bindingPredicate(input), + eq(logicalAgentConnectorLeases.leaseId, current.leaseId), + eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.expectedEpoch)), + ), + ) + .returning(); + const row = updated[0]; + if (!row) { + await insertAudit(tx, rejectionAudit(input, toLease(current), 'cas_mismatch')); + return { ok: false, reason: 'cas_mismatch' }; + } + const lease = toLease(row); + await insertAudit(tx, lifecycleAudit(input, lease, 'takeover')); + return { ok: true, lease }; + }, + ); + return unwrap(result); + } + + async heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise { + const result: MutationResult = await this.db.transaction( + async (tx): Promise => { + const updated = await tx + .update(logicalAgentConnectorLeases) + .set({ + heartbeatAt: new Date(input.now), + expiresAt: new Date(input.expiresAt), + updatedAt: new Date(input.now), + }) + .where( + and( + bindingPredicate(input.lease), + eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId), + eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId), + eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)), + isNull(logicalAgentConnectorLeases.releasedAt), + gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)), + ), + ) + .returning(); + const row = updated[0]; + if (row) { + const lease = toLease(row); + await insertAudit(tx, lifecycleAudit(input, lease, 'renew')); + return { ok: true, lease }; + } + const current = await findRow(tx, input.lease); + const reason = classifyAuthorityFailure( + current ? toLease(current) : null, + input.lease, + input.now, + ); + if (reason === 'lease_expired' && current) { + await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry')); + } + await insertAudit( + tx, + rejectionAudit( + { ...input.lease, correlationId: input.correlationId, now: input.now }, + current ? toLease(current) : null, + reason, + ), + ); + return { ok: false, reason }; + }, + ); + return unwrap(result); + } + + async release(input: ConnectorLeaseReleaseMutation): Promise { + const result: MutationResult = await this.db.transaction( + async (tx): Promise => { + const updated = await tx + .update(logicalAgentConnectorLeases) + .set({ + releasedAt: new Date(input.now), + expiresAt: new Date(input.now), + updatedAt: new Date(input.now), + }) + .where( + and( + bindingPredicate(input.lease), + eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId), + eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId), + eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)), + isNull(logicalAgentConnectorLeases.releasedAt), + gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)), + ), + ) + .returning(); + const row = updated[0]; + if (row) { + const lease = toLease(row); + await insertAudit(tx, lifecycleAudit(input, lease, 'release')); + return { ok: true, lease }; + } + const current = await findRow(tx, input.lease); + const reason = classifyAuthorityFailure( + current ? toLease(current) : null, + input.lease, + input.now, + ); + if (reason === 'lease_expired' && current) { + await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry')); + } + await insertAudit( + tx, + rejectionAudit( + { ...input.lease, correlationId: input.correlationId, now: input.now }, + current ? toLease(current) : null, + reason, + ), + ); + return { ok: false, reason }; + }, + ); + unwrap(result); + } + + async findCurrent(binding: LogicalAgentBinding): Promise { + const row = await findRow(this.db, binding); + return row ? toLease(row) : null; + } + + async recordAudit(event: ConnectorLeaseAuditEvent): Promise { + await insertAudit(this.db, event); + } +} + +function unwrap(result: MutationResult): ConnectorLease { + if (!result.ok) throw new ConnectorLeaseError(result.reason, safeErrorMessage(result.reason)); + return result.lease; +} + +function safeErrorMessage(reason: ConnectorLeaseRejectReason): string { + return `Connector lease mutation denied: ${reason}`; +} + +function bindingPredicate(binding: LogicalAgentBinding) { + return and( + eq(logicalAgentConnectorLeases.tenantId, binding.identity.tenantId), + eq(logicalAgentConnectorLeases.logicalAgentId, binding.identity.logicalAgentId), + eq(logicalAgentConnectorLeases.bindingId, binding.bindingId), + ); +} + +async function findRow( + db: Pick, + binding: LogicalAgentBinding, +): Promise { + const rows = await db + .select() + .from(logicalAgentConnectorLeases) + .where(bindingPredicate(binding)) + .limit(1); + return rows[0] ?? null; +} + +function toLease(row: typeof logicalAgentConnectorLeases.$inferSelect): ConnectorLease { + return Object.freeze({ + identity: Object.freeze({ tenantId: row.tenantId, logicalAgentId: row.logicalAgentId }), + bindingId: row.bindingId, + leaseId: row.leaseId, + connectorId: row.connectorId, + scopes: Object.freeze([...row.scopes]), + leaseEpoch: row.leaseEpoch.toString(10), + acquiredAt: row.acquiredAt.toISOString(), + heartbeatAt: row.heartbeatAt.toISOString(), + expiresAt: row.expiresAt.toISOString(), + ...(row.releasedAt ? { releasedAt: row.releasedAt.toISOString() } : {}), + }); +} + +function classifyAuthorityFailure( + current: ConnectorLease | null, + claimed: ConnectorLease, + now: string, +): ConnectorLeaseRejectReason { + if (!current) return 'lease_missing'; + if (current.releasedAt) return 'lease_released'; + if (new Date(current.expiresAt) <= new Date(now)) return 'lease_expired'; + if (current.leaseEpoch !== claimed.leaseEpoch) return 'stale_epoch'; + return 'connector_mismatch'; +} + +function lifecycleAudit( + input: { readonly correlationId: string; readonly now: string }, + lease: ConnectorLease, + event: Exclude, +): ConnectorLeaseAuditEvent { + return { + identity: lease.identity, + bindingId: lease.bindingId, + connectorId: lease.connectorId, + leaseId: lease.leaseId, + leaseEpoch: lease.leaseEpoch, + event, + outcome: 'succeeded', + correlationId: input.correlationId, + occurredAt: input.now, + }; +} + +function rejectionAudit( + input: { + readonly identity: ConnectorLease['identity']; + readonly bindingId: string; + readonly connectorId: string; + readonly correlationId: string; + readonly now: string; + }, + current: ConnectorLease | null, + reason: ConnectorLeaseRejectReason, +): ConnectorLeaseAuditEvent { + return { + identity: input.identity, + bindingId: input.bindingId, + connectorId: input.connectorId, + event: 'reject', + outcome: 'denied', + correlationId: input.correlationId, + occurredAt: input.now, + ...(current ? { leaseId: current.leaseId, leaseEpoch: current.leaseEpoch } : {}), + reason, + }; +} + +async function insertAudit(db: Pick, event: ConnectorLeaseAuditEvent): Promise { + await db.insert(connectorLeaseAuditLog).values({ + tenantId: event.identity.tenantId, + logicalAgentId: event.identity.logicalAgentId, + bindingId: event.bindingId, + connectorId: event.connectorId, + ...(event.leaseId ? { leaseId: event.leaseId } : {}), + ...(event.leaseEpoch ? { leaseEpoch: BigInt(event.leaseEpoch) } : {}), + event: event.event, + outcome: event.outcome, + ...(event.reason ? { reason: event.reason } : {}), + correlationId: event.correlationId, + occurredAt: new Date(event.occurredAt), + }); +} diff --git a/apps/gateway/src/agent/connector-lease.service.ts b/apps/gateway/src/agent/connector-lease.service.ts new file mode 100644 index 00000000..a6db3121 --- /dev/null +++ b/apps/gateway/src/agent/connector-lease.service.ts @@ -0,0 +1,285 @@ +import { ForbiddenException, Inject, Injectable } from '@nestjs/common'; +import { ConnectorLeaseCoordinator, normalizeConnectorLease } from '@mosaicstack/agent'; +import { + normalizeConnectorId, + normalizeConnectorScopes, + normalizeCorrelationId, + normalizeLogicalAgentIdentity, + normalizeLogicalBindingId, + type AcquireConnectorLeaseInput, + type ConnectorExecutionGrant, + type ConnectorLease, + type ConnectorLeaseAuditEvent, + type FencedConnectorAdapter, +} from '@mosaicstack/types'; +import type { ActorTenantScope } from '../auth/session-scope.js'; +import { ConnectorLeaseRepository } from './connector-lease.repository.js'; + +export const CONNECTOR_LEASE_POLICY = Symbol('CONNECTOR_LEASE_POLICY'); + +export type ConnectorLeasePolicyAction = + | 'lease.acquire' + | 'lease.takeover' + | 'lease.heartbeat' + | 'lease.release' + | 'lease.read' + | 'grant.issue'; + +export interface ConnectorLeaseRequestContext { + readonly actorScope: ActorTenantScope; + readonly correlationId: string; +} + +export interface GatewayConnectorLeaseRequest { + readonly logicalAgentId: string; + readonly bindingId: string; + readonly connectorId: string; + readonly scopes: readonly string[]; + readonly ttlMs: number; +} + +export interface GatewayConnectorLeaseTakeoverRequest extends GatewayConnectorLeaseRequest { + readonly expectedEpoch: string; +} + +export interface GatewayConnectorGrantRequest { + readonly lease: ConnectorLease; + readonly scopes: readonly string[]; + readonly ttlMs: number; +} + +export interface ConnectorLeasePolicySubject { + readonly action: ConnectorLeasePolicyAction; + readonly actorId: string; + readonly tenantId: string; + readonly logicalAgentId: string; + readonly bindingId: string; + readonly connectorId: string; + readonly requestedScopes: readonly string[]; + readonly requestedTtlMs: number | null; +} + +export interface ConnectorLeasePolicy { + authorize(subject: ConnectorLeasePolicySubject): Promise; +} + +/** M1 has no concrete cutover policy: unconfigured production use fails closed. */ +@Injectable() +export class DenyConnectorLeasePolicy implements ConnectorLeasePolicy { + async authorize(_subject: ConnectorLeasePolicySubject): Promise { + return false; + } +} + +/** Gateway-owned policy surface for durable connector authority and fenced effects. */ +@Injectable() +export class ConnectorLeaseService { + private readonly coordinator: ConnectorLeaseCoordinator; + + constructor( + @Inject(ConnectorLeaseRepository) private readonly repository: ConnectorLeaseRepository, + @Inject(CONNECTOR_LEASE_POLICY) private readonly policy: ConnectorLeasePolicy, + ) { + this.coordinator = new ConnectorLeaseCoordinator(repository); + } + + async acquire( + request: GatewayConnectorLeaseRequest, + context: ConnectorLeaseRequestContext, + ): Promise { + const command = this.command(request, context); + await this.assertPolicy('lease.acquire', command, context, command.scopes, command.ttlMs); + return this.coordinator.acquire({ ...command, correlationId: this.correlation(context) }); + } + + async takeover( + request: GatewayConnectorLeaseTakeoverRequest, + context: ConnectorLeaseRequestContext, + ): Promise { + const command = this.command(request, context); + await this.assertPolicy('lease.takeover', command, context, command.scopes, command.ttlMs); + return this.coordinator.takeover({ + ...command, + expectedEpoch: request.expectedEpoch, + correlationId: this.correlation(context), + }); + } + + async heartbeat( + lease: ConnectorLease, + ttlMs: number, + context: ConnectorLeaseRequestContext, + ): Promise { + const normalizedLease = normalizeConnectorLease(lease); + const durableLease = await this.durableLifecycleLease(normalizedLease, context); + await this.assertPolicy('lease.heartbeat', durableLease, context, durableLease.scopes, ttlMs); + return this.coordinator.heartbeat({ + lease: durableLease, + ttlMs, + correlationId: this.correlation(context), + }); + } + + async release(lease: ConnectorLease, context: ConnectorLeaseRequestContext): Promise { + const normalizedLease = normalizeConnectorLease(lease); + const durableLease = await this.durableLifecycleLease(normalizedLease, context); + await this.assertPolicy('lease.release', durableLease, context, durableLease.scopes, null); + await this.coordinator.release({ + lease: durableLease, + correlationId: this.correlation(context), + }); + } + + async current( + logicalAgentId: string, + bindingId: string, + context: ConnectorLeaseRequestContext, + ): Promise { + const binding = { + identity: normalizeLogicalAgentIdentity({ + tenantId: context.actorScope.tenantId, + logicalAgentId, + }), + bindingId: normalizeLogicalBindingId(bindingId), + connectorId: 'gateway', + }; + await this.assertPolicy('lease.read', binding, context, [], null); + return this.coordinator.current(binding); + } + + async issueGrant( + request: GatewayConnectorGrantRequest, + context: ConnectorLeaseRequestContext, + ): Promise { + const lease = normalizeConnectorLease(request.lease); + await this.assertTenant(lease, context); + const scopes = normalizeConnectorScopes(request.scopes); + await this.assertPolicy('grant.issue', lease, context, scopes, request.ttlMs); + return this.coordinator.issueGrant({ + lease, + scopes, + ttlMs: request.ttlMs, + correlationId: this.correlation(context), + }); + } + + async executeGrant( + grant: ConnectorExecutionGrant, + requiredScope: string, + input: TInput, + adapter: FencedConnectorAdapter, + ): Promise { + return this.coordinator.executeGrant(grant, requiredScope, input, adapter); + } + + private command( + request: GatewayConnectorLeaseRequest, + context: ConnectorLeaseRequestContext, + ): Omit { + return { + identity: normalizeLogicalAgentIdentity({ + tenantId: context.actorScope.tenantId, + logicalAgentId: request.logicalAgentId, + }), + bindingId: normalizeLogicalBindingId(request.bindingId), + connectorId: normalizeConnectorId(request.connectorId), + scopes: normalizeConnectorScopes(request.scopes), + ttlMs: request.ttlMs, + }; + } + + private async assertTenant( + lease: Pick, + context: ConnectorLeaseRequestContext, + ): Promise { + if (lease.identity.tenantId !== context.actorScope.tenantId) { + await this.recordPolicyDenial( + { + identity: { + tenantId: context.actorScope.tenantId, + logicalAgentId: 'untrusted', + }, + bindingId: 'untrusted', + connectorId: 'untrusted', + }, + context, + ); + throw new ForbiddenException('Connector authority tenant scope denied'); + } + } + + private async durableLifecycleLease( + submittedLease: ConnectorLease, + context: ConnectorLeaseRequestContext, + ): Promise { + await this.assertTenant(submittedLease, context); + const durableLease = await this.coordinator.current(submittedLease); + if (!durableLease || !hasSameLifecycleAuthority(submittedLease, durableLease)) { + await this.recordPolicyDenial(durableLease ?? submittedLease, context); + throw new ForbiddenException('Connector authority policy denied'); + } + return durableLease; + } + + private async assertPolicy( + action: ConnectorLeasePolicyAction, + subject: Pick, + context: ConnectorLeaseRequestContext, + requestedScopes: readonly string[], + requestedTtlMs: number | null, + ): Promise { + const allowed = await this.policy.authorize({ + action, + actorId: context.actorScope.userId, + tenantId: subject.identity.tenantId, + logicalAgentId: subject.identity.logicalAgentId, + bindingId: subject.bindingId, + connectorId: subject.connectorId, + requestedScopes: Object.freeze([...requestedScopes]), + requestedTtlMs, + }); + if (!allowed) { + await this.recordPolicyDenial(subject, context); + throw new ForbiddenException('Connector authority policy denied'); + } + } + + private async recordPolicyDenial( + subject: Pick, + context: ConnectorLeaseRequestContext, + ): Promise { + const event: ConnectorLeaseAuditEvent = { + identity: subject.identity, + bindingId: subject.bindingId, + connectorId: subject.connectorId, + event: 'reject', + outcome: 'denied', + reason: 'policy_denied', + correlationId: this.correlation(context), + occurredAt: new Date().toISOString(), + }; + await this.repository.recordAudit(event); + } + + private correlation(context: ConnectorLeaseRequestContext): string { + return normalizeCorrelationId(context.correlationId); + } +} + +function hasSameLifecycleAuthority( + submittedLease: ConnectorLease, + durableLease: ConnectorLease, +): boolean { + return ( + submittedLease.identity.tenantId === durableLease.identity.tenantId && + submittedLease.identity.logicalAgentId === durableLease.identity.logicalAgentId && + submittedLease.bindingId === durableLease.bindingId && + submittedLease.leaseId === durableLease.leaseId && + submittedLease.connectorId === durableLease.connectorId && + submittedLease.leaseEpoch === durableLease.leaseEpoch && + submittedLease.scopes.length === durableLease.scopes.length && + submittedLease.scopes.every((scope: string, index: number): boolean => { + return scope === durableLease.scopes[index]; + }) + ); +} diff --git a/apps/gateway/src/agent/durable-session.dto.ts b/apps/gateway/src/agent/durable-session.dto.ts new file mode 100644 index 00000000..5280505e --- /dev/null +++ b/apps/gateway/src/agent/durable-session.dto.ts @@ -0,0 +1,10 @@ +import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js'; + +/** Server-side request for a replay-safe provider message. */ +export interface ProviderOutboxDto { + sessionId: string; + idempotencyKey: string; + correlationId: string; + content: string; + context: RuntimeProviderRequestContext; +} diff --git a/apps/gateway/src/agent/durable-session.repository.test.ts b/apps/gateway/src/agent/durable-session.repository.test.ts new file mode 100644 index 00000000..b69dcae6 --- /dev/null +++ b/apps/gateway/src/agent/durable-session.repository.test.ts @@ -0,0 +1,416 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/db'; +import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db'; +import { DurableSessionRepository } from './durable-session.repository.js'; +import { DurableSessionService } from './durable-session.service.js'; + +const IDENTITY: DurableSessionIdentity = { + agentName: 'Nova', + sessionId: 'tess-pglite-session', + tenantId: 'tenant-pglite', + ownerId: 'tess-owner', + providerId: 'fleet', + runtimeSessionId: 'nova', +}; + +describe('DurableSessionRepository', () => { + let dataDir: string | undefined; + let handle: DbHandle; + let previousAuthSecret: string | undefined; + + beforeAll(async (): Promise => { + previousAuthSecret = process.env['BETTER_AUTH_SECRET']; + process.env['BETTER_AUTH_SECRET'] = 'tess-durable-state-test-sealing-key'; + dataDir = mkdtempSync(join(tmpdir(), 'tess-durable-state-')); + handle = createPgliteDb(dataDir); + await runPgliteMigrations(handle); + await seedOwner(handle); + }, 30_000); + + beforeEach(async (): Promise => { + await handle.db.execute(sql`DELETE FROM interaction_handoffs`); + await handle.db.execute(sql`DELETE FROM interaction_checkpoints`); + await handle.db.execute(sql`DELETE FROM interaction_inbox`); + await handle.db.execute(sql`DELETE FROM interaction_outbox`); + await handle.db.execute(sql`DELETE FROM interaction_sessions`); + }); + + afterAll(async (): Promise => { + await handle.close(); + if (dataDir) rmSync(dataDir, { recursive: true, force: true }); + if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET']; + else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret; + }); + + it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => { + const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db)); + await beforeRestart.create(IDENTITY); + await beforeRestart.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'inbox-before-kill', + correlationId: 'correlation-before-kill', + content: 'resume after a kill', + }); + await beforeRestart.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'outbox-before-kill', + correlationId: 'correlation-before-kill', + channelId: 'cli', + kind: 'provider.send', + content: 'one response only', + }); + await beforeRestart.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-before-kill', + cursor: 'cursor-before-kill', + summary: 'restart-safe state', + compactionEpoch: 1, + }); + await beforeRestart.handoff({ + sessionId: IDENTITY.sessionId, + handoffId: 'handoff-before-kill', + destination: 'mos', + correlationId: 'correlation-before-kill', + checkpointId: 'checkpoint-before-kill', + status: 'pending', + }); + await beforeRestart.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-after-handoff', + cursor: 'cursor-after-handoff', + summary: 'newer state cannot strand the portable handoff', + compactionEpoch: 2, + }); + + await handle.close(); + handle = createPgliteDb(dataDir!); + + const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db)); + const recovered = await afterRestart.recover(IDENTITY.sessionId); + const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill'); + const handled: string[] = []; + const effects: string[] = []; + + await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise => { + handled.push(entry.idempotencyKey); + }); + await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise => { + effects.push(entry.idempotencyKey); + }); + await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise => { + handled.push(entry.idempotencyKey); + }); + await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise => { + effects.push(entry.idempotencyKey); + }); + + expect(recovered.identity).toEqual(IDENTITY); + expect(recovered.checkpoint).toMatchObject({ checkpointId: 'checkpoint-after-handoff' }); + expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-before-kill' }]); + expect(resumedHandoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-before-kill' }); + expect(handled).toEqual(['inbox-before-kill']); + expect(effects).toEqual(['outbox-before-kill']); + }, 30_000); + + it('redacts sensitive durable payloads before persistence', async () => { + const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db)); + await coordinator.create(IDENTITY); + await coordinator.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'redacted-inbox', + correlationId: 'correlation-redaction', + content: 'api_key=super-secret-canary', + }); + await coordinator.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'redacted-outbox', + correlationId: 'correlation-redaction', + channelId: 'cli', + kind: 'provider.send', + content: 'email operator@example.test api_key=super-secret-canary', + }); + await coordinator.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'redacted-checkpoint', + cursor: 'bearer super-secret-canary', + summary: 'email operator@example.test', + compactionEpoch: 0, + }); + + const snapshot = await coordinator.snapshot(IDENTITY.sessionId); + const [persisted] = await handle.db + .select({ content: interactionInbox.content }) + .from(interactionInbox) + .where(eq(interactionInbox.idempotencyKey, 'redacted-inbox')); + + expect(JSON.stringify(snapshot)).not.toContain('super-secret-canary'); + expect(JSON.stringify(snapshot)).not.toContain('operator@example.test'); + expect(persisted?.content).not.toContain('super-secret-canary'); + expect(persisted?.content).not.toContain('[REDACTED]'); + }, 30_000); + + it('fails closed when the configured idempotency secret is unavailable', async () => { + const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db)); + await coordinator.create(IDENTITY); + const secret = process.env['BETTER_AUTH_SECRET']; + delete process.env['BETTER_AUTH_SECRET']; + try { + await expect( + coordinator.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'requires-idempotency-secret', + correlationId: 'correlation-secret', + content: 'sensitive payload', + }), + ).rejects.toThrow(/required for durable idempotency digests/); + } finally { + if (secret === undefined) delete process.env['BETTER_AUTH_SECRET']; + else process.env['BETTER_AUTH_SECRET'] = secret; + } + }, 30_000); + + it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => { + const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db)); + await coordinator.create(IDENTITY); + const input = { + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-secret-conflict', + cursor: 'api_key=secret-one', + summary: 'bearer secret-one', + compactionEpoch: 1, + }; + await coordinator.checkpoint(input); + + await expect( + coordinator.checkpoint({ + ...input, + cursor: 'api_key=secret-two', + summary: 'bearer secret-two', + }), + ).rejects.toThrow(/checkpoint identity conflict/); + + const [persisted] = await handle.db + .select({ + digest: interactionCheckpoints.contentDigest, + cursor: interactionCheckpoints.cursor, + }) + .from(interactionCheckpoints) + .where(eq(interactionCheckpoints.checkpointId, input.checkpointId)); + expect(persisted?.cursor).not.toContain('secret-one'); + expect(persisted?.digest).not.toBe( + createHash('sha256') + .update(JSON.stringify([input.cursor, input.summary])) + .digest('hex'), + ); + + await coordinator.checkpoint({ + ...input, + checkpointId: 'checkpoint-delimiter-conflict', + cursor: 'a\u0000b', + summary: 'c', + }); + await expect( + coordinator.checkpoint({ + ...input, + checkpointId: 'checkpoint-delimiter-conflict', + cursor: 'a', + summary: 'b\u0000c', + }), + ).rejects.toThrow(/checkpoint identity conflict/); + }, 30_000); + + it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => { + const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db)); + await coordinator.create(IDENTITY); + const inbox = { + sessionId: IDENTITY.sessionId, + idempotencyKey: 'inbox-secret-conflict', + correlationId: 'correlation-inbox-secret', + content: 'api_key=secret-one', + }; + const outbox = { + sessionId: IDENTITY.sessionId, + idempotencyKey: 'outbox-secret-conflict', + correlationId: 'correlation-outbox-secret', + channelId: 'cli', + kind: 'provider.send', + content: 'api_key=secret-one', + }; + await coordinator.receive(inbox); + await coordinator.enqueueOutbox(outbox); + + await expect(coordinator.receive({ ...inbox, content: 'api_key=secret-two' })).rejects.toThrow( + /idempotency conflict/, + ); + await expect( + coordinator.enqueueOutbox({ ...outbox, content: 'api_key=secret-two' }), + ).rejects.toThrow(/idempotency conflict/); + }, 30_000); + + it('rejects database inbox and outbox idempotency-key conflicts', async () => { + const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db)); + await coordinator.create(IDENTITY); + await coordinator.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'inbox-conflict', + correlationId: 'correlation-inbox', + content: 'original inbox', + }); + await coordinator.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'outbox-conflict', + correlationId: 'correlation-outbox', + channelId: 'cli', + kind: 'provider.send', + content: 'original outbox', + }); + + await expect( + coordinator.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'inbox-conflict', + correlationId: 'forged-correlation', + content: 'original inbox', + }), + ).rejects.toThrow(/idempotency conflict/); + await expect( + coordinator.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'outbox-conflict', + correlationId: 'correlation-outbox', + channelId: 'forged-channel', + kind: 'provider.send', + content: 'original outbox', + }), + ).rejects.toThrow(/idempotency conflict/); + }, 30_000); + + it('does not requeue a live outbox claim during a normal scoped dispatch', async () => { + const repository = new DurableSessionRepository(handle.db); + const coordinator = new DurableSessionCoordinator(repository); + const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) }; + const service = new DurableSessionService(repository, runtimeProviders as never); + const input = { + sessionId: IDENTITY.sessionId, + idempotencyKey: 'live-effect', + correlationId: 'correlation-live', + content: 'must not duplicate', + context: { + actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId }, + channelId: 'cli', + correlationId: 'correlation-live', + }, + }; + + await coordinator.create(IDENTITY); + await service.queueProviderSend(input); + expect(await repository.claimOutbox(IDENTITY.sessionId)).toMatchObject({ + status: 'processing', + }); + + await service.dispatchProviderOutbox(IDENTITY.sessionId, input); + await expect( + service.recoverProviderSession(IDENTITY.sessionId, { + ...input, + context: { + ...input.context, + actorScope: { userId: 'intruder', tenantId: 'tenant-pglite' }, + }, + }), + ).rejects.toThrow(/scope or correlation mismatch/); + + expect(runtimeProviders.sendMessage).not.toHaveBeenCalled(); + expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({ + outbox: [{ idempotencyKey: 'live-effect', status: 'processing' }], + }); + }, 30_000); + + it('rejects an outbox correlation mismatch before claiming the pending effect', async () => { + const repository = new DurableSessionRepository(handle.db); + const coordinator = new DurableSessionCoordinator(repository); + const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) }; + const service = new DurableSessionService(repository, runtimeProviders as never); + const input = { + sessionId: IDENTITY.sessionId, + idempotencyKey: 'mismatch-effect', + correlationId: 'correlation-expected', + content: 'must remain pending', + context: { + actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId }, + channelId: 'cli', + correlationId: 'correlation-expected', + }, + }; + + await coordinator.create(IDENTITY); + await service.queueProviderSend(input); + await expect( + service.dispatchProviderOutbox(IDENTITY.sessionId, { + ...input, + correlationId: 'correlation-forged', + context: { ...input.context, correlationId: 'correlation-forged' }, + }), + ).rejects.toThrow(/scope or correlation mismatch/); + + expect(runtimeProviders.sendMessage).not.toHaveBeenCalled(); + expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({ + outbox: [{ idempotencyKey: 'mismatch-effect', status: 'pending' }], + }); + }, 30_000); + + it('dispatches only the outbox record bound to the supplied correlation and channel', async () => { + const repository = new DurableSessionRepository(handle.db); + const coordinator = new DurableSessionCoordinator(repository); + const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) }; + const service = new DurableSessionService(repository, runtimeProviders as never); + const first = { + sessionId: IDENTITY.sessionId, + idempotencyKey: 'scoped-effect-one', + correlationId: 'correlation-one', + content: 'first result', + context: { + actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId }, + channelId: 'cli', + correlationId: 'correlation-one', + }, + }; + const second = { + ...first, + idempotencyKey: 'scoped-effect-two', + correlationId: 'correlation-two', + content: 'second result', + context: { ...first.context, correlationId: 'correlation-two' }, + }; + + await coordinator.create(IDENTITY); + await service.queueProviderSend(first); + await service.queueProviderSend(second); + await service.dispatchProviderOutbox(IDENTITY.sessionId, first); + + expect(runtimeProviders.sendMessage).toHaveBeenCalledTimes(1); + expect(runtimeProviders.sendMessage).toHaveBeenCalledWith( + IDENTITY.providerId, + IDENTITY.runtimeSessionId, + { content: 'first result', idempotencyKey: 'scoped-effect-one' }, + first.context, + ); + expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({ + outbox: [ + { idempotencyKey: 'scoped-effect-one', status: 'delivered' }, + { idempotencyKey: 'scoped-effect-two', status: 'pending' }, + ], + }); + }, 30_000); +}); + +async function seedOwner(handle: DbHandle): Promise { + await handle.db.execute(sql` + INSERT INTO users (id, name, email, email_verified, created_at, updated_at) + VALUES ('tess-owner', 'Tess Owner', 'tess-owner@example.test', false, now(), now()) + `); +} diff --git a/apps/gateway/src/agent/durable-session.repository.ts b/apps/gateway/src/agent/durable-session.repository.ts new file mode 100644 index 00000000..bc000a85 --- /dev/null +++ b/apps/gateway/src/agent/durable-session.repository.ts @@ -0,0 +1,529 @@ +import { createHash, createHmac } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { + and, + asc, + desc, + eq, + interactionCheckpoints, + interactionHandoffs, + interactionInbox, + interactionOutbox, + interactionSessions, + type Db, +} from '@mosaicstack/db'; +import { seal, unseal } from '@mosaicstack/auth'; +import { redactSensitiveContent } from '@mosaicstack/log'; +import type { + DurableCheckpoint, + DurableCheckpointInput, + DurableEnqueueResult, + DurableHandoff, + DurableHandoffInput, + DurableInboxEntry, + DurableInboxInput, + DurableInboxStatus, + DurableOutboxEntry, + DurableOutboxInput, + DurableOutboxStatus, + DurableSessionIdentity, + DurableSessionSnapshot, + DurableSessionStore, +} from '@mosaicstack/agent'; +import { DB } from '../database/database.module.js'; + +@Injectable() +export class DurableSessionRepository implements DurableSessionStore { + constructor(@Inject(DB) private readonly db: Db) {} + + async create(identity: DurableSessionIdentity): Promise { + await this.db + .insert(interactionSessions) + .values({ + id: identity.sessionId, + agentName: identity.agentName, + tenantId: identity.tenantId, + ownerId: identity.ownerId, + providerId: identity.providerId, + runtimeSessionId: identity.runtimeSessionId, + }) + .onConflictDoNothing(); + + const existing = await this.session(identity.sessionId); + if (!existing || !sameEnrollmentScope(existing, identity)) { + throw new Error(`Durable session identity conflict: ${identity.sessionId}`); + } + // A recovered/re-enrolled runtime can receive a new provider session ID; + // the conversation handle and owner scope remain immutable. + if ( + existing.providerId !== identity.providerId || + existing.runtimeSessionId !== identity.runtimeSessionId + ) { + await this.db + .update(interactionSessions) + .set({ providerId: identity.providerId, runtimeSessionId: identity.runtimeSessionId }) + .where(eq(interactionSessions.id, identity.sessionId)); + } + } + + async snapshot(sessionId: string): Promise { + const identity = await this.session(sessionId); + if (!identity) return null; + + const [inbox, outbox, checkpoints, handoffs] = await Promise.all([ + this.db + .select() + .from(interactionInbox) + .where(eq(interactionInbox.sessionId, sessionId)) + .orderBy(asc(interactionInbox.createdAt)), + this.db + .select() + .from(interactionOutbox) + .where(eq(interactionOutbox.sessionId, sessionId)) + .orderBy(asc(interactionOutbox.createdAt)), + this.db + .select() + .from(interactionCheckpoints) + .where(eq(interactionCheckpoints.sessionId, sessionId)) + .orderBy( + desc(interactionCheckpoints.compactionEpoch), + desc(interactionCheckpoints.createdAt), + ) + .limit(1), + this.db + .select() + .from(interactionHandoffs) + .where(eq(interactionHandoffs.sessionId, sessionId)) + .orderBy(asc(interactionHandoffs.createdAt)), + ]); + + const checkpoint = checkpoints[0]; + return { + identity, + inbox: inbox.map(toInbox), + outbox: outbox.map(toOutbox), + ...(checkpoint ? { checkpoint: toCheckpoint(checkpoint) } : {}), + handoffs: handoffs.map(toHandoff), + }; + } + + async enqueueInbox(input: DurableInboxInput): Promise> { + const digest = contentDigest(input.content); + const record: DurableInboxInput = { + ...input, + content: redactSensitiveContent(input.content).content, + }; + const inserted = await this.db + .insert(interactionInbox) + .values({ + ...record, + content: seal(record.content), + contentDigest: digest, + status: 'pending', + }) + .onConflictDoNothing() + .returning({ status: interactionInbox.status }); + if (inserted[0]) return { accepted: true, status: inserted[0].status }; + + const existing = await this.db + .select() + .from(interactionInbox) + .where( + and( + eq(interactionInbox.sessionId, input.sessionId), + eq(interactionInbox.idempotencyKey, input.idempotencyKey), + ), + ) + .limit(1); + if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`); + const entry = toInbox(existing[0]); + if ( + !sameInbox(entry, record) || + !matchesContentDigest(existing[0].contentDigest, input.content) + ) { + throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`); + } + return { accepted: false, status: entry.status }; + } + + async claimInbox(sessionId: string): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + const candidate = await this.db + .select() + .from(interactionInbox) + .where( + and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'pending')), + ) + .orderBy(asc(interactionInbox.createdAt)) + .limit(1); + const entry = candidate[0]; + if (!entry) return null; + const claimed = await this.db + .update(interactionInbox) + .set({ status: 'processing', updatedAt: new Date() }) + .where(and(eq(interactionInbox.id, entry.id), eq(interactionInbox.status, 'pending'))) + .returning(); + if (claimed[0]) return toInbox(claimed[0]); + } + return null; + } + + async completeInbox(sessionId: string, idempotencyKey: string): Promise { + await this.db + .update(interactionInbox) + .set({ status: 'processed', updatedAt: new Date() }) + .where( + and( + eq(interactionInbox.sessionId, sessionId), + eq(interactionInbox.idempotencyKey, idempotencyKey), + eq(interactionInbox.status, 'processing'), + ), + ); + } + + async releaseInbox(sessionId: string, idempotencyKey: string): Promise { + await this.db + .update(interactionInbox) + .set({ status: 'pending', updatedAt: new Date() }) + .where( + and( + eq(interactionInbox.sessionId, sessionId), + eq(interactionInbox.idempotencyKey, idempotencyKey), + eq(interactionInbox.status, 'processing'), + ), + ); + } + + async enqueueOutbox( + input: DurableOutboxInput, + ): Promise> { + const digest = contentDigest(input.content); + const record: DurableOutboxInput = { + ...input, + content: redactSensitiveContent(input.content).content, + }; + const inserted = await this.db + .insert(interactionOutbox) + .values({ + ...record, + content: seal(record.content), + contentDigest: digest, + status: 'pending', + }) + .onConflictDoNothing() + .returning({ status: interactionOutbox.status }); + if (inserted[0]) return { accepted: true, status: inserted[0].status }; + + const existing = await this.db + .select() + .from(interactionOutbox) + .where( + and( + eq(interactionOutbox.sessionId, input.sessionId), + eq(interactionOutbox.idempotencyKey, input.idempotencyKey), + ), + ) + .limit(1); + if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`); + const entry = toOutbox(existing[0]); + if ( + !sameOutbox(entry, record) || + !matchesContentDigest(existing[0].contentDigest, input.content) + ) { + throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`); + } + return { accepted: false, status: entry.status }; + } + + async claimOutbox(sessionId: string): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + const candidate = await this.db + .select() + .from(interactionOutbox) + .where( + and(eq(interactionOutbox.sessionId, sessionId), eq(interactionOutbox.status, 'pending')), + ) + .orderBy(asc(interactionOutbox.createdAt)) + .limit(1); + const entry = candidate[0]; + if (!entry) return null; + const claimed = await this.db + .update(interactionOutbox) + .set({ status: 'processing', updatedAt: new Date() }) + .where(and(eq(interactionOutbox.id, entry.id), eq(interactionOutbox.status, 'pending'))) + .returning(); + if (claimed[0]) return toOutbox(claimed[0]); + } + return null; + } + + async claimOutboxByKey( + sessionId: string, + idempotencyKey: string, + ): Promise { + const claimed = await this.db + .update(interactionOutbox) + .set({ status: 'processing', updatedAt: new Date() }) + .where( + and( + eq(interactionOutbox.sessionId, sessionId), + eq(interactionOutbox.idempotencyKey, idempotencyKey), + eq(interactionOutbox.status, 'pending'), + ), + ) + .returning(); + return claimed[0] ? toOutbox(claimed[0]) : null; + } + + async completeOutbox(sessionId: string, idempotencyKey: string): Promise { + await this.db + .update(interactionOutbox) + .set({ status: 'delivered', updatedAt: new Date() }) + .where( + and( + eq(interactionOutbox.sessionId, sessionId), + eq(interactionOutbox.idempotencyKey, idempotencyKey), + eq(interactionOutbox.status, 'processing'), + ), + ); + } + + async releaseOutbox(sessionId: string, idempotencyKey: string): Promise { + await this.db + .update(interactionOutbox) + .set({ status: 'pending', updatedAt: new Date() }) + .where( + and( + eq(interactionOutbox.sessionId, sessionId), + eq(interactionOutbox.idempotencyKey, idempotencyKey), + eq(interactionOutbox.status, 'processing'), + ), + ); + } + + async checkpoint(input: DurableCheckpointInput): Promise { + // Compute identity before redaction. The persisted digest is keyed so a database + // reader cannot use it as an offline oracle for sensitive cursor/summary values. + const digest = contentDigest(JSON.stringify([input.cursor, input.summary])); + const checkpoint: DurableCheckpointInput = { + ...input, + cursor: redactSensitiveContent(input.cursor).content, + summary: redactSensitiveContent(input.summary).content, + }; + const inserted = await this.db + .insert(interactionCheckpoints) + .values({ + ...checkpoint, + contentDigest: digest, + cursor: seal(checkpoint.cursor), + summary: seal(checkpoint.summary), + }) + .onConflictDoNothing() + .returning({ checkpointId: interactionCheckpoints.checkpointId }); + if (inserted[0]) return; + + const existing = await this.db + .select() + .from(interactionCheckpoints) + .where( + and( + eq(interactionCheckpoints.sessionId, input.sessionId), + eq(interactionCheckpoints.checkpointId, input.checkpointId), + ), + ) + .limit(1); + if ( + !existing[0] || + !sameCheckpoint(toCheckpoint(existing[0]), checkpoint) || + !matchesCheckpointDigest(existing[0].contentDigest, digest) + ) { + throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`); + } + } + + async findCheckpoint(sessionId: string, checkpointId: string): Promise { + const checkpoints = await this.db + .select() + .from(interactionCheckpoints) + .where( + and( + eq(interactionCheckpoints.sessionId, sessionId), + eq(interactionCheckpoints.checkpointId, checkpointId), + ), + ) + .limit(1); + const checkpoint = checkpoints[0]; + return checkpoint ? toCheckpoint(checkpoint) : null; + } + + async handoff(input: DurableHandoffInput): Promise { + const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId); + if (!checkpoint) { + throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`); + } + const inserted = await this.db + .insert(interactionHandoffs) + .values({ ...input }) + .onConflictDoNothing() + .returning({ handoffId: interactionHandoffs.handoffId }); + if (inserted[0]) return; + + const existing = await this.findHandoff(input.handoffId); + if (!existing || !sameHandoff(existing, input)) { + throw new Error(`Durable handoff identity conflict: ${input.handoffId}`); + } + } + + async findHandoff(handoffId: string): Promise { + const handoffs = await this.db + .select() + .from(interactionHandoffs) + .where(eq(interactionHandoffs.handoffId, handoffId)) + .limit(1); + const handoff = handoffs[0]; + return handoff ? toHandoff(handoff) : null; + } + + async requeueInFlight(sessionId: string): Promise { + // Inbox handlers are process-local work. A provider outbox claim may have + // reached an external target before a crash, so it is deliberately not + // replayed by generic recovery. + await this.db + .update(interactionInbox) + .set({ status: 'pending', updatedAt: new Date() }) + .where( + and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'processing')), + ); + } + + private async session(sessionId: string): Promise { + const sessions = await this.db + .select() + .from(interactionSessions) + .where(eq(interactionSessions.id, sessionId)) + .limit(1); + const session = sessions[0]; + return session + ? { + agentName: session.agentName, + sessionId: session.id, + tenantId: session.tenantId, + ownerId: session.ownerId, + providerId: session.providerId, + runtimeSessionId: session.runtimeSessionId, + } + : null; + } +} + +function contentDigest(content: string): string { + const secret = process.env['BETTER_AUTH_SECRET']; + if (!secret) { + throw new Error('BETTER_AUTH_SECRET is required for durable idempotency digests'); + } + return `hmac:v1:${createHmac('sha256', secret).update(content).digest('hex')}`; +} + +function matchesContentDigest(stored: string, content: string): boolean { + return ( + stored === contentDigest(content) || + stored === createHash('sha256').update(content).digest('hex') + ); +} + +function matchesCheckpointDigest(stored: string, digest: string): boolean { + // Legacy rows predate any pre-redaction identity and cannot safely prove equality. + // Reject rather than let redaction collapse distinct sensitive checkpoint payloads. + return stored === digest; +} + +function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean { + return ( + left.agentName === right.agentName && + left.sessionId === right.sessionId && + left.tenantId === right.tenantId && + left.ownerId === right.ownerId + ); +} + +function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean { + return ( + left.sessionId === right.sessionId && + left.idempotencyKey === right.idempotencyKey && + left.correlationId === right.correlationId && + left.content === right.content + ); +} + +function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean { + return ( + left.sessionId === right.sessionId && + left.idempotencyKey === right.idempotencyKey && + left.correlationId === right.correlationId && + left.channelId === right.channelId && + left.kind === right.kind && + left.content === right.content + ); +} + +function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean { + return ( + left.sessionId === right.sessionId && + left.checkpointId === right.checkpointId && + left.compactionEpoch === right.compactionEpoch + ); +} + +function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean { + return ( + left.sessionId === right.sessionId && + left.handoffId === right.handoffId && + left.destination === right.destination && + left.correlationId === right.correlationId && + left.checkpointId === right.checkpointId && + left.status === right.status + ); +} + +function toInbox(row: typeof interactionInbox.$inferSelect): DurableInboxEntry { + return { + sessionId: row.sessionId, + idempotencyKey: row.idempotencyKey, + correlationId: row.correlationId, + content: unseal(row.content), + status: row.status, + }; +} + +function toOutbox(row: typeof interactionOutbox.$inferSelect): DurableOutboxEntry { + return { + sessionId: row.sessionId, + idempotencyKey: row.idempotencyKey, + correlationId: row.correlationId, + channelId: row.channelId, + kind: row.kind, + content: unseal(row.content), + status: row.status, + }; +} + +function toCheckpoint(row: typeof interactionCheckpoints.$inferSelect): DurableCheckpoint { + return { + sessionId: row.sessionId, + checkpointId: row.checkpointId, + cursor: unseal(row.cursor), + summary: unseal(row.summary), + compactionEpoch: row.compactionEpoch, + }; +} + +function toHandoff(row: typeof interactionHandoffs.$inferSelect): DurableHandoff { + return { + sessionId: row.sessionId, + handoffId: row.handoffId, + destination: row.destination, + correlationId: row.correlationId, + checkpointId: row.checkpointId, + status: row.status, + }; +} diff --git a/apps/gateway/src/agent/durable-session.service.ts b/apps/gateway/src/agent/durable-session.service.ts new file mode 100644 index 00000000..a2384fd3 --- /dev/null +++ b/apps/gateway/src/agent/durable-session.service.ts @@ -0,0 +1,123 @@ +import { ForbiddenException, Inject, Injectable } from '@nestjs/common'; +import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent'; +import type { ProviderOutboxDto } from './durable-session.dto.js'; +import { DurableSessionRepository } from './durable-session.repository.js'; +import { + RuntimeProviderService, + type RuntimeProviderRequestContext, +} from './runtime-provider-registry.service.js'; + +/** + * Scoped gateway boundary for the canonical durable session state machine. It deliberately + * uses composition: raw state methods cannot be injected into channel, CLI, or + * MCP adapters without a server-derived actor/tenant/correlation context. + */ +@Injectable() +export class DurableSessionService { + private readonly coordinator: DurableSessionCoordinator; + + constructor( + @Inject(DurableSessionRepository) repository: DurableSessionRepository, + @Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService, + ) { + this.coordinator = new DurableSessionCoordinator(repository); + } + + /** Enroll a verified runtime session under the stable cross-surface conversation handle. */ + async enroll( + identity: DurableSessionIdentity, + context: RuntimeProviderRequestContext, + ): Promise { + if ( + identity.ownerId !== context.actorScope.userId || + identity.tenantId !== context.actorScope.tenantId + ) { + throw new ForbiddenException('Durable session enrollment scope mismatch'); + } + await this.coordinator.create(identity); + } + + async queueProviderSend(input: ProviderOutboxDto): Promise { + const snapshot = await this.coordinator.snapshot(input.sessionId); + this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input); + await this.coordinator.enqueueOutbox({ + sessionId: input.sessionId, + idempotencyKey: input.idempotencyKey, + correlationId: input.correlationId, + channelId: input.context.channelId, + kind: 'provider.send', + content: input.content, + }); + } + + async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise { + if (sessionId !== input.sessionId) { + throw new ForbiddenException('Durable outbox session mismatch'); + } + const snapshot = await this.coordinator.snapshot(sessionId); + this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input); + const pendingEntry = snapshot.outbox.find( + (entry): boolean => entry.idempotencyKey === input.idempotencyKey, + ); + if (!pendingEntry) return; + // Validate immutable routing before claiming. A caller with a mismatched + // correlation/channel must not strand a pending external side effect. + this.assertOutboxScope(pendingEntry, input); + await this.coordinator.dispatchOutboxEntry( + sessionId, + input.idempotencyKey, + async (entry): Promise => { + this.assertOutboxScope(entry, input); + await this.runtimeProviders.sendMessage( + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + { content: entry.content, idempotencyKey: entry.idempotencyKey }, + input.context, + ); + }, + ); + } + + /** Read durable identity/state only after deriving and checking the server-side actor scope. */ + async getSnapshot(sessionId: string, context: RuntimeProviderRequestContext) { + const snapshot = await this.coordinator.snapshot(sessionId); + this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, { + sessionId, + content: '', + idempotencyKey: 'read-only', + correlationId: context.correlationId, + context, + }); + return snapshot; + } + + /** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */ + async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise { + const snapshot = await this.coordinator.snapshot(sessionId); + this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input); + await this.coordinator.recover(sessionId); + } + + private assertOutboxScope( + entry: { kind: string; correlationId: string; channelId: string }, + input: ProviderOutboxDto, + ): void { + if ( + entry.kind !== 'provider.send' || + entry.correlationId !== input.correlationId || + entry.channelId !== input.context.channelId + ) { + throw new ForbiddenException('Durable outbox scope or correlation mismatch'); + } + } + + private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void { + if ( + input.context.actorScope.userId !== ownerId || + input.context.actorScope.tenantId !== tenantId || + input.context.correlationId !== input.correlationId + ) { + throw new ForbiddenException('Durable session scope or correlation mismatch'); + } + } +} diff --git a/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts b/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts new file mode 100644 index 00000000..1b20084f --- /dev/null +++ b/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts @@ -0,0 +1,176 @@ +import 'reflect-metadata'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { Global, Module } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { HermesRuntimeProvider } from '@mosaicstack/agent'; +import { AgentModule } from './agent.module.js'; +import { AUTH } from '../auth/auth.tokens.js'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { BRAIN } from '../brain/brain.tokens.js'; +import { DB } from '../database/database.module.js'; +import { CoordModule } from '../coord/coord.module.js'; +import { McpClientModule } from '../mcp-client/mcp-client.module.js'; +import { SkillsModule } from '../skills/skills.module.js'; +import { GCModule } from '../gc/gc.module.js'; +import { LogModule } from '../log/log.module.js'; +import { CommandsModule } from '../commands/commands.module.js'; +import { + AGENT_RUNTIME_PROVIDER_REGISTRY, + RUNTIME_APPROVAL_VERIFIER, + RUNTIME_PROVIDER_AUDIT_SINK, + RuntimeProviderAuditService, +} from './runtime-provider-registry.service.js'; +import { DurableSessionService } from './durable-session.service.js'; +import { DurableSessionRepository } from './durable-session.repository.js'; +import { AgentService } from './agent.service.js'; +import { ProviderService } from './provider.service.js'; +import { ProviderCredentialsService } from './provider-credentials.service.js'; +import { RoutingService } from './routing.service.js'; +import { RoutingEngineService } from './routing/routing-engine.service.js'; +import { SkillLoaderService } from './skill-loader.service.js'; + +const authenticatedUser = { id: 'operator-1', tenantId: 'tenant-1' }; + +@Module({}) +class EmptyAgentDependencyModule {} + +@Global() +@Module({ + providers: [ + { + provide: AUTH, + useValue: { + api: { + getSession: vi.fn(async ({ headers }: { headers: Headers }) => + headers.get('cookie') === 'session=trusted' + ? { user: authenticatedUser, session: { id: 'session-1' } } + : null, + ), + }, + }, + }, + AuthGuard, + { provide: BRAIN, useValue: {} }, + { provide: DB, useValue: {} }, + ], + exports: [AUTH, AuthGuard, BRAIN, DB], +}) +class AuthenticatedRequestModule {} + +/** + * This is deliberately an HTTP test rather than a controller unit test: it + * exercises AgentModule's actual provider factory, Nest DI, and AuthGuard. + */ +describe('Hermes runtime provider reachability', (): void => { + let app: NestFastifyApplication | undefined; + + beforeAll(async (): Promise => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const moduleRef = await Test.createTestingModule({ + imports: [AuthenticatedRequestModule, AgentModule], + }) + .overrideModule(CoordModule) + .useModule(EmptyAgentDependencyModule) + .overrideModule(McpClientModule) + .useModule(EmptyAgentDependencyModule) + .overrideModule(SkillsModule) + .useModule(EmptyAgentDependencyModule) + .overrideModule(GCModule) + .useModule(EmptyAgentDependencyModule) + .overrideModule(LogModule) + .useModule(EmptyAgentDependencyModule) + .overrideModule(CommandsModule) + .useModule(EmptyAgentDependencyModule) + .overrideProvider(RuntimeProviderAuditService) + .useValue({ record: vi.fn().mockResolvedValue(undefined) }) + .overrideProvider(RUNTIME_PROVIDER_AUDIT_SINK) + .useValue({ record: vi.fn().mockResolvedValue(undefined) }) + .overrideProvider(RUNTIME_APPROVAL_VERIFIER) + .useValue({ consume: vi.fn().mockResolvedValue(false) }) + .overrideProvider(DurableSessionService) + .useValue({}) + .overrideProvider(DurableSessionRepository) + .useValue({}) + .overrideProvider(AgentService) + .useValue({}) + .overrideProvider(ProviderService) + .useValue({}) + .overrideProvider(ProviderCredentialsService) + .useValue({}) + .overrideProvider(RoutingService) + .useValue({}) + .overrideProvider(RoutingEngineService) + .useValue({}) + .overrideProvider(SkillLoaderService) + .useValue({}) + .compile(); + + app = moduleRef.createNestApplication(new FastifyAdapter()); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(async (): Promise => { + await app?.close(); + }); + + it('returns gateway denial responses from the actual guarded interaction routes', async (): Promise => { + if (!app) throw new Error('Nest application did not initialize'); + + const attachDenied = await app.inject({ + method: 'POST', + url: '/api/interaction/Nova/sessions/session-1/attach', + headers: { 'x-correlation-id': 'correlation-1' }, + payload: { mode: 'read' }, + }); + expect(attachDenied.statusCode).toBe(401); + + const sendDenied = await app.inject({ + method: 'POST', + url: '/api/interaction/Nova/sessions/session-1/send', + headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' }, + payload: {}, + }); + expect(sendDenied.statusCode).toBe(403); + expect(sendDenied.json()).toMatchObject({ + message: 'Content and idempotency key are required', + }); + + const stopDenied = await app.inject({ + method: 'POST', + url: '/api/interaction/Nova/sessions/session-1/stop', + headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' }, + payload: {}, + }); + expect(stopDenied.statusCode).toBe(403); + expect(stopDenied.json()).toMatchObject({ message: 'Exact-action approval is required' }); + }); + + it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise => { + if (!app) throw new Error('Nest application did not initialize'); + const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY); + expect(registry.get('runtime.hermes')).toBeInstanceOf(HermesRuntimeProvider); + + const denied = await app.inject({ + method: 'GET', + url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes', + headers: { 'x-correlation-id': 'correlation-1' }, + }); + expect(denied.statusCode).toBe(401); + + const response = await app.inject({ + method: 'GET', + url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes', + headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' }, + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual([ + { capability: 'kanban', status: 'unsupported' }, + { capability: 'skills', status: 'unsupported' }, + { capability: 'memory', status: 'unsupported' }, + { capability: 'tools', status: 'unsupported' }, + { capability: 'cron', status: 'unsupported' }, + ]); + }); +}); diff --git a/apps/gateway/src/agent/hermes-runtime.transport.test.ts b/apps/gateway/src/agent/hermes-runtime.transport.test.ts new file mode 100644 index 00000000..48e7ff4a --- /dev/null +++ b/apps/gateway/src/agent/hermes-runtime.transport.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js'; + +const scope = { + actorId: 'owner-1', + tenantId: 'tenant-1', + channelId: 'cli', + correlationId: 'correlation-1', +}; + +describe('GatewayHermesRuntimeTransport', () => { + it('preserves a configured path prefix and authenticates the concrete runtime request', async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify(['session.list']), { status: 200 })); + const transport = new GatewayHermesRuntimeTransport( + 'https://runtime.example.test/hermes', + 'test-service-token', + fetchFn, + ); + + await expect(transport.capabilities(scope)).resolves.toEqual(['session.list']); + + expect(fetchFn).toHaveBeenCalledWith( + new URL('https://runtime.example.test/hermes/capabilities'), + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: 'Bearer test-service-token', + 'x-mosaic-channel-id': 'cli', + }), + }), + ); + }); + + it('rejects non-loopback HTTP runtime endpoints before sending identity headers', async () => { + const fetchFn = vi.fn(); + const transport = new GatewayHermesRuntimeTransport( + 'http://runtime.example.test/hermes', + 'test-service-token', + fetchFn, + ); + + await expect(transport.capabilities(scope)).rejects.toThrow('requires HTTPS'); + expect(fetchFn).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/gateway/src/agent/hermes-runtime.transport.ts b/apps/gateway/src/agent/hermes-runtime.transport.ts new file mode 100644 index 00000000..c095c350 --- /dev/null +++ b/apps/gateway/src/agent/hermes-runtime.transport.ts @@ -0,0 +1,121 @@ +import type { HermesLegacySession, HermesRuntimeTransport } from '@mosaicstack/agent'; +import type { + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeMessage, + RuntimeScope, + RuntimeStreamEvent, +} from '@mosaicstack/types'; + +/** Concrete HTTP transport for a configured legacy Hermes runtime endpoint. */ +export class GatewayHermesRuntimeTransport implements HermesRuntimeTransport { + constructor( + private readonly baseUrl = process.env['MOSAIC_HERMES_RUNTIME_URL']?.trim(), + private readonly serviceToken = process.env['MOSAIC_HERMES_RUNTIME_TOKEN']?.trim(), + private readonly fetchFn: typeof fetch = fetch, + ) {} + + async capabilities(scope: RuntimeScope): Promise { + return this.request('/capabilities', scope); + } + + async health(scope: RuntimeScope): Promise<{ status: string; detail?: string }> { + return this.request<{ status: string; detail?: string }>('/health', scope); + } + + async sessions(scope: RuntimeScope): Promise { + return this.request('/sessions', scope); + } + + async *stream( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable { + const params = new URLSearchParams(cursor ? { cursor } : {}); + const events = await this.request( + `/sessions/${encodeURIComponent(sessionId)}/stream?${params.toString()}`, + scope, + ); + yield* events; + } + + async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise { + await this.request(`/sessions/${encodeURIComponent(sessionId)}/messages`, scope, { + method: 'POST', + body: message, + }); + } + + async attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise { + return this.request( + `/sessions/${encodeURIComponent(sessionId)}/attach`, + scope, + { + method: 'POST', + body: { mode }, + }, + ); + } + + async detach(attachmentId: string, scope: RuntimeScope): Promise { + await this.request(`/attachments/${encodeURIComponent(attachmentId)}`, scope, { + method: 'DELETE', + }); + } + + async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise { + await this.request(`/sessions/${encodeURIComponent(sessionId)}/terminate`, scope, { + method: 'POST', + body: { approvalRef }, + }); + } + + private async request( + path: string, + scope: RuntimeScope, + init: { method?: string; body?: unknown } = {}, + ): Promise { + if (!this.baseUrl || !this.serviceToken) { + throw new Error( + 'MOSAIC_HERMES_RUNTIME_URL and MOSAIC_HERMES_RUNTIME_TOKEN must configure Hermes transport', + ); + } + const endpoint = new URL(this.baseUrl); + if (endpoint.protocol !== 'https:' && !isLoopbackHttp(endpoint)) { + throw new Error('Hermes runtime transport requires HTTPS outside loopback'); + } + const response = await this.fetchFn( + new URL(path.replace(/^\//, ''), `${endpoint.toString().replace(/\/$/, '')}/`), + { + method: init.method ?? 'GET', + headers: { + accept: 'application/json', + authorization: `Bearer ${this.serviceToken}`, + 'x-mosaic-actor-id': scope.actorId, + 'x-mosaic-tenant-id': scope.tenantId, + 'x-mosaic-channel-id': scope.channelId, + 'x-correlation-id': scope.correlationId, + ...(init.body ? { 'content-type': 'application/json' } : {}), + }, + ...(init.body ? { body: JSON.stringify(init.body) } : {}), + }, + ); + if (!response.ok) throw new Error(`Hermes runtime request failed: ${response.status}`); + if (response.status === 204) return undefined as T; + return (await response.json()) as T; + } +} + +function isLoopbackHttp(endpoint: URL): boolean { + return ( + endpoint.protocol === 'http:' && + (endpoint.hostname === 'localhost' || + endpoint.hostname === '127.0.0.1' || + endpoint.hostname === '::1') + ); +} diff --git a/apps/gateway/src/agent/interaction.controller.test.ts b/apps/gateway/src/agent/interaction.controller.test.ts new file mode 100644 index 00000000..78030a6a --- /dev/null +++ b/apps/gateway/src/agent/interaction.controller.test.ts @@ -0,0 +1,263 @@ +import { createGatewayRuntimeProviderRegistry } from './agent.module.js'; +import { firstValueFrom } from 'rxjs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + RuntimeApprovalDeniedError, + RuntimeProviderService, +} from './runtime-provider-registry.service.js'; +import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js'; +import { InteractionController } from './interaction.controller.js'; + +describe('InteractionController', (): void => { + afterEach(() => vi.restoreAllMocks()); + + it('maps a denied runtime approval to Fastify HTTP 403', () => { + const send = vi.fn(); + const status = vi.fn().mockReturnValue({ send }); + const response = { status }; + const host = { switchToHttp: () => ({ getResponse: () => response }) }; + + new RuntimeApprovalDeniedFilter().catch(new RuntimeApprovalDeniedError(), host as never); + + expect(status).toHaveBeenCalledWith(403); + expect(send).toHaveBeenCalledWith({ + statusCode: 403, + message: 'Runtime termination approval denied', + }); + }); + + it('honors a differently named configured instance without a code change', async () => { + const prior = process.env['MOSAIC_AGENT_NAME']; + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const runtime = { listSessions: vi.fn().mockResolvedValue([]) }; + const controller = new InteractionController(runtime as never, {} as never); + + await expect( + controller.sessions('Nova', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'), + ).resolves.toEqual([]); + await expect( + controller.sessions('Other', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'), + ).rejects.toThrow('Interaction agent is not configured'); + + if (prior === undefined) delete process.env['MOSAIC_AGENT_NAME']; + else process.env['MOSAIC_AGENT_NAME'] = prior; + }); + + it('reaches the registered Hermes provider through the authenticated transitional matrix route', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const registry = createGatewayRuntimeProviderRegistry(); + const runtime = new RuntimeProviderService( + registry, + { record: vi.fn().mockResolvedValue(undefined) }, + { consume: vi.fn().mockResolvedValue(false) }, + ); + const controller = new InteractionController(runtime, {} as never); + + await expect( + controller.transitionalCapabilities( + 'Nova', + 'runtime.hermes', + { id: 'owner', tenantId: 'team' }, + 'corr-1', + ), + ).resolves.toEqual([ + { capability: 'kanban', status: 'unsupported' }, + { capability: 'skills', status: 'unsupported' }, + { capability: 'memory', status: 'unsupported' }, + { capability: 'tools', status: 'unsupported' }, + { capability: 'cron', status: 'unsupported' }, + ]); + }); + + it('rejects a request without the non-simple correlation header', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never); + + await expect(controller.sessions('Nova', 'fleet', { id: 'owner' })).rejects.toThrow( + 'X-Correlation-Id is required', + ); + }); + + it('enrolls a visible runtime session under the cross-surface conversation handle', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const runtime = { + listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]), + }; + const durable = { enroll: vi.fn().mockResolvedValue(undefined) }; + const controller = new InteractionController(runtime as never, durable as never); + + await expect( + controller.enroll( + 'Nova', + 'conversation-1', + { providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + { id: 'owner', tenantId: 'team' }, + 'corr-1', + ), + ).resolves.toEqual({ status: 'enrolled', sessionId: 'conversation-1' }); + expect(durable.enroll).toHaveBeenCalledWith( + { + agentName: 'Nova', + sessionId: 'conversation-1', + tenantId: 'team', + ownerId: 'owner', + providerId: 'fleet', + runtimeSessionId: 'runtime-1', + }, + expect.objectContaining({ correlationId: 'corr-1' }), + ); + }); + + it('rejects an invalid attach mode before invoking a provider', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const controller = new InteractionController({ attach: vi.fn() } as never, {} as never); + + await expect( + controller.attach('Nova', 'durable-1', { mode: 'write' as never }, { id: 'owner' }, 'corr-1'), + ).rejects.toThrow('Interaction attach mode is invalid'); + }); + + it('resumes a durable session by attaching and streaming its runtime events', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const runtimeEvent = { + type: 'message.delta' as const, + sessionId: 'runtime-1', + cursor: 'cursor-1', + occurredAt: '2026-07-13T00:00:00.000Z', + content: 'resumed', + }; + const runtime = { + attach: vi.fn().mockResolvedValue({ attachmentId: 'attach-1', sessionId: 'runtime-1' }), + streamSession: vi.fn(async function* () { + yield runtimeEvent; + }), + }; + const durable = { + getSnapshot: vi.fn().mockResolvedValue({ + identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }), + }; + const controller = new InteractionController(runtime as never, durable as never); + + await controller.attach('Nova', 'conversation-1', { mode: 'read' }, { id: 'owner' }, 'corr-1'); + await expect( + firstValueFrom( + controller.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1'), + ), + ).resolves.toEqual({ data: runtimeEvent }); + + expect(runtime.attach).toHaveBeenCalledWith( + 'fleet', + 'runtime-1', + 'read', + expect.objectContaining({ correlationId: 'corr-1' }), + ); + expect(runtime.streamSession).toHaveBeenCalledWith( + 'fleet', + 'runtime-1', + undefined, + expect.objectContaining({ correlationId: 'corr-1' }), + ); + }); + + it('does not create a runtime stream after the SSE subscriber disconnects during snapshot lookup', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + let resolveSnapshot!: (value: { identity: Record }) => void; + const snapshot = new Promise<{ identity: Record }>((resolve) => { + resolveSnapshot = resolve; + }); + const runtime = { streamSession: vi.fn() }; + const durable = { getSnapshot: vi.fn().mockReturnValue(snapshot) }; + const controller = new InteractionController(runtime as never, durable as never); + + const subscription = controller + .stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1') + .subscribe(); + subscription.unsubscribe(); + resolveSnapshot({ + identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(runtime.streamSession).not.toHaveBeenCalled(); + }); + + it.each([ + ['wrong actor', { getSnapshot: vi.fn().mockRejectedValue(new Error('scope mismatch')) }], + [ + 'session-agent mismatch', + { + getSnapshot: vi.fn().mockResolvedValue({ + identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }), + }, + ], + ])('denies a CLI stop for %s', async (_reason, durable) => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const runtime = { terminate: vi.fn().mockResolvedValue(undefined) }; + const controller = new InteractionController(runtime as never, durable as never); + + await expect( + controller.stop( + 'Nova', + 'durable-1', + { approvalRef: 'approval-1' }, + { id: 'owner' }, + 'corr-1', + ), + ).rejects.toBeDefined(); + expect(runtime.terminate).not.toHaveBeenCalled(); + }); + + it('surfaces a denied runtime approval to the CLI interaction surface', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const runtime = { + terminate: vi.fn().mockRejectedValue(new Error('Runtime termination approval denied')), + }; + const durable = { + getSnapshot: vi.fn().mockResolvedValue({ + identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }), + }; + const controller = new InteractionController(runtime as never, durable as never); + + await expect( + controller.stop( + 'Nova', + 'durable-1', + { approvalRef: 'approval-1' }, + { id: 'owner' }, + 'corr-1', + ), + ).rejects.toThrow('Runtime termination approval denied'); + }); + + it('uses the durable session identity and runtime registry for an approved stop', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const runtime = { terminate: vi.fn().mockResolvedValue(undefined) }; + const durable = { + getSnapshot: vi.fn().mockResolvedValue({ + identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }), + }; + const controller = new InteractionController(runtime as never, durable as never); + + await controller.stop( + 'Nova', + 'durable-1', + { approvalRef: 'approval-1' }, + { id: 'owner' }, + 'corr-1', + ); + + expect(runtime.terminate).toHaveBeenCalledWith( + 'fleet', + 'runtime-1', + 'approval-1', + expect.objectContaining({ + correlationId: 'corr-1', + actorScope: { userId: 'owner', tenantId: 'owner' }, + }), + ); + }); +}); diff --git a/apps/gateway/src/agent/interaction.controller.ts b/apps/gateway/src/agent/interaction.controller.ts new file mode 100644 index 00000000..54a4ec76 --- /dev/null +++ b/apps/gateway/src/agent/interaction.controller.ts @@ -0,0 +1,293 @@ +import { + Body, + Controller, + ForbiddenException, + Get, + Headers, + Sse, + Inject, + Param, + Post, + Query, + UseGuards, + UseFilters, +} from '@nestjs/common'; +import type { RuntimeAttachMode, RuntimeStreamEvent } from '@mosaicstack/types'; +import { Observable } from 'rxjs'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; +import { DurableSessionService } from './durable-session.service.js'; +import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js'; +import { + RuntimeProviderService, + type RuntimeProviderRequestContext, +} from './runtime-provider-registry.service.js'; + +/** + * Authenticated HTTP boundary for operator interaction clients. Identity is + * selected from deployment configuration, never a client-side command name. + */ +@Controller('api/interaction/:agentName') +@UseGuards(AuthGuard) +@UseFilters(RuntimeApprovalDeniedFilter) +export class InteractionController { + constructor( + @Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService, + @Inject(DurableSessionService) private readonly durable: DurableSessionService, + ) {} + + @Get('sessions') + async sessions( + @Param('agentName') agentName: string, + @Query('provider') providerId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + return this.runtime.listSessions( + this.requiredProvider(providerId), + this.context(user, correlationId), + ); + } + + @Get('transitional-capabilities') + async transitionalCapabilities( + @Param('agentName') agentName: string, + @Query('provider') providerId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + return this.runtime.transitionalCapabilityMatrix( + this.requiredProvider(providerId), + this.context(user, correlationId), + ); + } + + @Get('tree') + async tree( + @Param('agentName') agentName: string, + @Query('provider') providerId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + return this.runtime.getSessionTree( + this.requiredProvider(providerId), + this.context(user, correlationId), + ); + } + + /** + * Bind an existing, authorized runtime session to the stable conversation ID. + * This is the lifecycle boundary where both runtime identifiers are known. + */ + @Post('sessions/:sessionId/enroll') + async enroll( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @Body() body: { providerId?: string; runtimeSessionId?: string } = {}, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + const providerId = this.requiredProvider(body.providerId ?? ''); + const runtimeSessionId = body.runtimeSessionId?.trim(); + if (!runtimeSessionId) throw new ForbiddenException('Runtime session identity is required'); + const context = this.context(user, correlationId); + const sessions = await this.runtime.listSessions(providerId, context); + if (!sessions.some((session): boolean => session.id === runtimeSessionId)) { + throw new ForbiddenException('Runtime session is not visible to this actor'); + } + await this.durable.enroll( + { + agentName, + sessionId, + tenantId: context.actorScope.tenantId, + ownerId: context.actorScope.userId, + providerId, + runtimeSessionId, + }, + context, + ); + return { status: 'enrolled', sessionId }; + } + + @Post('sessions/:sessionId/attach') + async attach( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @Body() body: { mode?: RuntimeAttachMode } = {}, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + const context = this.context(user, correlationId); + const mode = body.mode ?? 'read'; + if (mode !== 'read' && mode !== 'control') { + throw new ForbiddenException('Interaction attach mode is invalid'); + } + const snapshot = await this.durable.getSnapshot(sessionId, context); + this.assertSessionAgent(snapshot.identity.agentName, agentName); + return this.runtime.attach( + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + mode, + context, + ); + } + + @Sse('sessions/:sessionId/stream') + stream( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @Query('cursor') cursor: string | undefined, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ): Observable<{ data: RuntimeStreamEvent }> { + this.assertConfiguredAgent(agentName); + const context = this.context(user, correlationId); + return new Observable((subscriber) => { + let iterator: AsyncIterator | undefined; + let cancelled = false; + void (async (): Promise => { + try { + const snapshot = await this.durable.getSnapshot(sessionId, context); + if (cancelled || subscriber.closed) return; + this.assertSessionAgent(snapshot.identity.agentName, agentName); + iterator = this.runtime + .streamSession( + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + cursor?.trim() || undefined, + context, + ) + [Symbol.asyncIterator](); + if (cancelled || subscriber.closed) { + await iterator.return?.(); + return; + } + while (!cancelled && !subscriber.closed) { + const next = await iterator.next(); + if (next.done || cancelled || subscriber.closed) break; + subscriber.next({ data: next.value }); + } + if (!subscriber.closed) subscriber.complete(); + } catch (error: unknown) { + if (!subscriber.closed) subscriber.error(error); + } + })(); + return (): void => { + cancelled = true; + void iterator?.return?.(); + }; + }); + } + + @Post('sessions/:sessionId/send') + async send( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @Body() body: { content?: string; idempotencyKey?: string } = {}, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + if (!body.content?.trim() || !body.idempotencyKey?.trim()) { + throw new ForbiddenException('Content and idempotency key are required'); + } + const context = this.context(user, correlationId); + const snapshot = await this.durable.getSnapshot(sessionId, context); + this.assertSessionAgent(snapshot.identity.agentName, agentName); + const input = { + sessionId, + content: body.content, + idempotencyKey: body.idempotencyKey, + correlationId: context.correlationId, + context, + }; + await this.durable.queueProviderSend(input); + await this.durable.dispatchProviderOutbox(sessionId, input); + return { status: 'queued', sessionId }; + } + + @Post('sessions/:sessionId/stop') + async stop( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @Body() body: { approvalRef?: string } = {}, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + if (!body.approvalRef?.trim()) + throw new ForbiddenException('Exact-action approval is required'); + const context = this.context(user, correlationId); + const snapshot = await this.durable.getSnapshot(sessionId, context); + this.assertSessionAgent(snapshot.identity.agentName, agentName); + await this.runtime.terminate( + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + body.approvalRef, + context, + ); + return { status: 'stopped', sessionId }; + } + + @Post('sessions/:sessionId/recover') + async recover( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + const context = this.context(user, correlationId); + const snapshot = await this.durable.getSnapshot(sessionId, context); + this.assertSessionAgent(snapshot.identity.agentName, agentName); + await this.durable.recoverProviderSession(sessionId, { + sessionId, + content: '', + idempotencyKey: `recovery:${context.correlationId}`, + correlationId: context.correlationId, + context, + }); + return { status: 'recovered', sessionId }; + } + + private context( + user: AuthenticatedUserLike, + correlationId?: string, + ): RuntimeProviderRequestContext { + const requestCorrelationId = correlationId?.trim(); + // This non-simple request header is mandatory for mutations. Browser + // cross-origin requests cannot set it without a CORS preflight, and the + // gateway's allowlist rejects untrusted origins before the handler runs. + if (!requestCorrelationId) { + throw new ForbiddenException('X-Correlation-Id is required'); + } + return { + actorScope: scopeFromUser(user), + channelId: 'cli', + correlationId: requestCorrelationId, + }; + } + + private assertConfiguredAgent(agentName: string): void { + const configured = process.env['MOSAIC_AGENT_NAME']?.trim(); + if (!configured || configured !== agentName) { + throw new ForbiddenException('Interaction agent is not configured for this request'); + } + } + + private assertSessionAgent(sessionAgentName: string, agentName: string): void { + if (sessionAgentName !== agentName) + throw new ForbiddenException('Interaction session identity mismatch'); + } + + private requiredProvider(providerId: string): string { + if (!providerId?.trim()) throw new ForbiddenException('Runtime provider is required'); + return providerId; + } +} diff --git a/apps/gateway/src/agent/provider.service.ts b/apps/gateway/src/agent/provider.service.ts index a1d6dfa4..1cc0b81a 100644 --- a/apps/gateway/src/agent/provider.service.ts +++ b/apps/gateway/src/agent/provider.service.ts @@ -107,8 +107,7 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy { * Interval is configurable via PROVIDER_HEALTH_INTERVAL env (seconds, default 60). */ private startHealthCheckScheduler(): void { - const intervalSecs = - parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS; + const intervalSecs = this.effectiveHealthCheckIntervalSecs(); const intervalMs = intervalSecs * 1000; // Run an initial check immediately (non-blocking) @@ -176,6 +175,28 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy { }); } + /** + * Returns the effective provider operational policy without credentials, + * endpoints, request content, or provider error details. + */ + getEffectivePolicyStatus(): { + healthCheckIntervalSecs: number; + configuredProviders: string[]; + availableModelCount: number; + } { + return { + healthCheckIntervalSecs: this.effectiveHealthCheckIntervalSecs(), + configuredProviders: this.adapters.map((adapter) => adapter.name), + availableModelCount: this.registry?.getAvailable().length ?? 0, + }; + } + + private effectiveHealthCheckIntervalSecs(): number { + return ( + parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS + ); + } + // --------------------------------------------------------------------------- // Adapter-pattern API // --------------------------------------------------------------------------- diff --git a/apps/gateway/src/agent/providers.controller.test.ts b/apps/gateway/src/agent/providers.controller.test.ts new file mode 100644 index 00000000..4ce6266b --- /dev/null +++ b/apps/gateway/src/agent/providers.controller.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ProvidersController } from './providers.controller.js'; + +describe('ProvidersController operational status', (): void => { + it('reports provider latency and effective policy without exposing provider error details', (): void => { + const providerService = { + getProvidersHealth: vi.fn(() => [ + { + name: 'fleet', + status: 'down', + latencyMs: 42, + lastChecked: '2026-07-12T00:00:00.000Z', + modelCount: 0, + error: 'credential-canary=secret-value', + }, + ]), + getEffectivePolicyStatus: vi.fn(() => ({ + healthCheckIntervalSecs: 60, + configuredProviders: ['fleet'], + availableModelCount: 0, + })), + }; + const controller = new ProvidersController(providerService as never, {} as never, {} as never); + + const status = controller.status(); + + expect(status).toEqual({ + providers: [ + { + name: 'fleet', + status: 'down', + latencyMs: 42, + lastChecked: '2026-07-12T00:00:00.000Z', + modelCount: 0, + errorCode: 'provider_unavailable', + }, + ], + effectivePolicy: { + healthCheckIntervalSecs: 60, + configuredProviders: ['fleet'], + availableModelCount: 0, + }, + }); + expect(JSON.stringify(status)).not.toContain('secret-value'); + }); +}); diff --git a/apps/gateway/src/agent/providers.controller.ts b/apps/gateway/src/agent/providers.controller.ts index 60d5bed6..1c7144fc 100644 --- a/apps/gateway/src/agent/providers.controller.ts +++ b/apps/gateway/src/agent/providers.controller.ts @@ -33,7 +33,20 @@ export class ProvidersController { @Get('health') health() { - return { providers: this.providerService.getProvidersHealth() }; + return { providers: this.safeProviderHealth() }; + } + + /** + * Safe operational status for troubleshooting and readiness checks. Provider + * errors are reduced to a stable code so credentials and remote responses + * cannot leak through this endpoint. + */ + @Get('status') + status() { + return { + providers: this.safeProviderHealth(), + effectivePolicy: this.providerService.getEffectivePolicyStatus(), + }; } @Post('test') @@ -51,6 +64,13 @@ export class ProvidersController { return this.routingService.rank(criteria); } + private safeProviderHealth() { + return this.providerService.getProvidersHealth().map(({ error, ...provider }) => ({ + ...provider, + ...(error ? { errorCode: 'provider_unavailable' } : {}), + })); + } + // ── Credential CRUD ────────────────────────────────────────────────────── /** diff --git a/apps/gateway/src/agent/runtime-approval-denied.filter.ts b/apps/gateway/src/agent/runtime-approval-denied.filter.ts new file mode 100644 index 00000000..22aae385 --- /dev/null +++ b/apps/gateway/src/agent/runtime-approval-denied.filter.ts @@ -0,0 +1,13 @@ +import { Catch, type ArgumentsHost, type ExceptionFilter } from '@nestjs/common'; +import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js'; + +/** Maps a consumed/missing runtime approval to a stable HTTP authorization response. */ +@Catch(RuntimeApprovalDeniedError) +export class RuntimeApprovalDeniedFilter implements ExceptionFilter { + catch(_exception: RuntimeApprovalDeniedError, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse<{ + status(code: number): { send(body: { statusCode: number; message: string }): void }; + }>(); + response.status(403).send({ statusCode: 403, message: 'Runtime termination approval denied' }); + } +} diff --git a/apps/gateway/src/agent/runtime-provider-registry.service.ts b/apps/gateway/src/agent/runtime-provider-registry.service.ts new file mode 100644 index 00000000..e62953d9 --- /dev/null +++ b/apps/gateway/src/agent/runtime-provider-registry.service.ts @@ -0,0 +1,500 @@ +import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; +import { + createRuntimeAuditLogEntry, + type LogService, + type RuntimeAuditErrorCode, +} from '@mosaicstack/log'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapability, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, + TransitionalCapabilityInventoryEntry, + TransitionalCapabilityInventoryProvider, +} from '@mosaicstack/types'; +import type { ActorTenantScope } from '../auth/session-scope.js'; +import { LOG_SERVICE } from '../log/log.tokens.js'; + +export const AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY'); +export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK'); +export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER'); + +export type RuntimeProviderOperation = + | RuntimeCapability + | 'runtime.capabilities' + | 'runtime.health' + | 'runtime.transitional-capabilities'; +export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed'; + +/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */ +export interface RuntimeProviderRequestContext { + actorScope: ActorTenantScope; + channelId: string; + correlationId: string; +} + +/** Metadata-only audit record. Message bodies, idempotency keys, and approval refs are excluded. */ +export interface RuntimeAuditEvent { + providerId: string; + operation: RuntimeProviderOperation; + outcome: RuntimeProviderAuditOutcome; + actorId: string; + tenantId: string; + channelId: string; + correlationId: string; + resourceId?: string; + durationMs?: number; + errorCode?: RuntimeAuditErrorCode; +} + +export interface RuntimeAuditSink { + record(event: RuntimeAuditEvent): Promise; +} + +/** Exact action shape that a durable approval implementation must consume once. */ +export interface RuntimeTerminationAction { + providerId: string; + sessionId: string; + actorId: string; + tenantId: string; + channelId: string; + correlationId: string; + agentName: string; +} + +export interface RuntimeApprovalVerifier { + consume(approvalRef: string, action: RuntimeTerminationAction): Promise; +} + +function isTransitionalInventoryProvider( + provider: AgentRuntimeProvider, +): provider is AgentRuntimeProvider & TransitionalCapabilityInventoryProvider { + return ( + typeof (provider as Partial) + .transitionalCapabilityMatrix === 'function' + ); +} + +function configuredAgentName(): string { + const agentName = process.env['MOSAIC_AGENT_NAME']?.trim(); + if (!agentName) throw new RuntimeApprovalDeniedError(); + return agentName; +} + +export class RuntimeApprovalDeniedError extends Error { + constructor() { + super('Runtime termination approval denied'); + } +} + +/** + * The default denies all runtime termination until a durable, exact-action + * approval implementation is configured. This is safer than a permissive stub. + */ +@Injectable() +export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier { + async consume(_approvalRef: string, _action: RuntimeTerminationAction): Promise { + return false; + } +} + +/** + * Temporary metadata-only audit sink. M1 observability can replace this token + * with a durable audit writer without changing provider call sites. + */ +@Injectable() +export class RuntimeProviderAuditService implements RuntimeAuditSink { + private readonly logger = new Logger(RuntimeProviderAuditService.name); + + constructor(@Inject(LOG_SERVICE) private readonly logService: LogService) {} + + async record(event: RuntimeAuditEvent): Promise { + const entry = createRuntimeAuditLogEntry(event); + await this.logService.logs.ingest(entry); + this.logger.log(JSON.stringify({ event: entry.content, metadata: entry.metadata })); + } +} + +@Injectable() +export class RuntimeProviderService { + private readonly logger = new Logger(RuntimeProviderService.name); + + constructor( + @Inject(AGENT_RUNTIME_PROVIDER_REGISTRY) + private readonly registry: AgentRuntimeProviderRegistry, + @Inject(RUNTIME_PROVIDER_AUDIT_SINK) + private readonly audit: RuntimeAuditSink, + @Inject(RUNTIME_APPROVAL_VERIFIER) + private readonly approvals: RuntimeApprovalVerifier, + ) {} + + async capabilities( + providerId: string, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'runtime.capabilities', + undefined, + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.capabilities(scope), + ); + } + + async health(providerId: string, context: RuntimeProviderRequestContext): Promise { + return this.execute( + providerId, + 'runtime.health', + undefined, + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.health(scope), + ); + } + + async transitionalCapabilityMatrix( + providerId: string, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'runtime.transitional-capabilities', + undefined, + undefined, + context, + async (provider: AgentRuntimeProvider, scope: RuntimeScope) => { + if (!isTransitionalInventoryProvider(provider)) { + throw new NotFoundException('Runtime provider has no transitional capability inventory'); + } + return provider.transitionalCapabilityMatrix(scope); + }, + ); + } + + async listSessions( + providerId: string, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'session.list', + 'session.list', + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.listSessions(scope), + ); + } + + async getSessionTree( + providerId: string, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'session.tree', + 'session.tree', + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.getSessionTree(scope), + ); + } + + streamSession( + providerId: string, + sessionId: string, + cursor: string | undefined, + context: RuntimeProviderRequestContext, + ): AsyncIterable { + return this.stream( + providerId, + 'session.stream', + 'session.stream', + sessionId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): AsyncIterable => + provider.streamSession(sessionId, cursor, scope), + ); + } + + async sendMessage( + providerId: string, + sessionId: string, + message: RuntimeMessage, + context: RuntimeProviderRequestContext, + ): Promise { + await this.execute( + providerId, + 'session.send', + 'session.send', + sessionId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.sendMessage(sessionId, message, scope), + ); + } + + async attach( + providerId: string, + sessionId: string, + mode: RuntimeAttachMode, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'session.attach', + 'session.attach', + sessionId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.attach(sessionId, mode, scope), + ); + } + + async detach( + providerId: string, + attachmentId: string, + context: RuntimeProviderRequestContext, + ): Promise { + await this.execute( + providerId, + 'session.attach', + 'session.attach', + attachmentId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.detach(attachmentId, scope), + ); + } + + async terminate( + providerId: string, + sessionId: string, + approvalRef: string, + context: RuntimeProviderRequestContext, + ): Promise { + await this.execute( + providerId, + 'session.terminate', + 'session.terminate', + sessionId, + context, + async (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => { + const approved = await this.approvals.consume(approvalRef, { + providerId, + sessionId, + actorId: scope.actorId, + tenantId: scope.tenantId, + channelId: scope.channelId, + correlationId: scope.correlationId, + agentName: configuredAgentName(), + }); + if (!approved) { + throw new RuntimeApprovalDeniedError(); + } + await provider.terminate(sessionId, approvalRef, scope); + }, + ); + } + + private async execute( + providerId: string, + operation: RuntimeProviderOperation, + requiredCapability: RuntimeCapability | undefined, + resourceId: string | undefined, + context: RuntimeProviderRequestContext, + invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise, + ): Promise { + const scope = this.deriveScope(context); + const startedAt = Date.now(); + await this.record(providerId, operation, 'requested', scope, resourceId); + let invocationStarted = false; + try { + const provider = this.provider(providerId); + if (requiredCapability) { + await this.assertCapability(provider, requiredCapability, scope); + } + invocationStarted = true; + const result = await invoke(provider, scope); + await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt); + return result; + } catch (error: unknown) { + const durationMs = Date.now() - startedAt; + if (invocationStarted && !this.isAuthorizationDenied(error)) { + await this.recordFailure(providerId, operation, scope, resourceId, durationMs); + } else { + await this.record( + providerId, + operation, + 'denied', + scope, + resourceId, + durationMs, + 'policy_denied', + ); + } + throw error; + } + } + + private async *stream( + providerId: string, + operation: RuntimeProviderOperation, + requiredCapability: RuntimeCapability, + resourceId: string, + context: RuntimeProviderRequestContext, + invoke: ( + provider: AgentRuntimeProvider, + scope: RuntimeScope, + ) => AsyncIterable, + ): AsyncIterable { + const scope = this.deriveScope(context); + const startedAt = Date.now(); + await this.record(providerId, operation, 'requested', scope, resourceId); + let invocationStarted = false; + try { + const provider = this.provider(providerId); + await this.assertCapability(provider, requiredCapability, scope); + invocationStarted = true; + for await (const event of invoke(provider, scope)) { + yield event; + } + await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt); + } catch (error: unknown) { + const durationMs = Date.now() - startedAt; + if (invocationStarted) { + await this.recordFailure(providerId, operation, scope, resourceId, durationMs); + } else { + await this.record( + providerId, + operation, + 'denied', + scope, + resourceId, + durationMs, + 'policy_denied', + ); + } + throw error; + } + } + + private isAuthorizationDenied(error: unknown): boolean { + return ( + error instanceof RuntimeApprovalDeniedError || + error instanceof ForbiddenException || + (typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'forbidden') + ); + } + + private provider(providerId: string): AgentRuntimeProvider { + try { + return this.registry.require(providerId); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Runtime provider is not registered'; + throw new NotFoundException(message); + } + } + + private async assertCapability( + provider: AgentRuntimeProvider, + requiredCapability: RuntimeCapability, + scope: RuntimeScope, + ): Promise { + const capabilities = await provider.capabilities(scope); + if (!capabilities.supported.includes(requiredCapability)) { + throw new ForbiddenException(`Runtime provider capability denied: ${requiredCapability}`); + } + } + + private deriveScope(context: RuntimeProviderRequestContext): RuntimeScope { + const actorId = context.actorScope.userId.trim(); + const tenantId = context.actorScope.tenantId.trim(); + const channelId = context.channelId.trim(); + const correlationId = context.correlationId.trim(); + if (!actorId || !tenantId || !channelId || !correlationId) { + throw new ForbiddenException( + 'Authenticated runtime actor scope and correlation are required', + ); + } + return Object.freeze({ actorId, tenantId, channelId, correlationId }); + } + + private async recordFailure( + providerId: string, + operation: RuntimeProviderOperation, + scope: RuntimeScope, + resourceId: string | undefined, + durationMs: number, + ): Promise { + try { + await this.record( + providerId, + operation, + 'failed', + scope, + resourceId, + durationMs, + 'provider_error', + ); + } catch { + this.logger.error( + `Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`, + ); + } + } + + private async recordCompletion( + providerId: string, + operation: RuntimeProviderOperation, + scope: RuntimeScope, + resourceId: string | undefined, + durationMs: number, + ): Promise { + try { + await this.record(providerId, operation, 'succeeded', scope, resourceId, durationMs); + } catch { + this.logger.error( + `Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`, + ); + } + } + + private async record( + providerId: string, + operation: RuntimeProviderOperation, + outcome: RuntimeProviderAuditOutcome, + scope: RuntimeScope, + resourceId: string | undefined, + durationMs?: number, + errorCode?: RuntimeAuditErrorCode, + ): Promise { + await this.audit.record({ + providerId, + operation, + outcome, + actorId: scope.actorId, + tenantId: scope.tenantId, + channelId: scope.channelId, + correlationId: scope.correlationId, + ...(resourceId ? { resourceId } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + ...(errorCode ? { errorCode } : {}), + }); + } +} diff --git a/apps/gateway/src/agent/sessions.controller.ts b/apps/gateway/src/agent/sessions.controller.ts index be6fd379..efd1fe86 100644 --- a/apps/gateway/src/agent/sessions.controller.ts +++ b/apps/gateway/src/agent/sessions.controller.ts @@ -10,6 +10,8 @@ import { UseGuards, } from '@nestjs/common'; import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; import { AgentService } from './agent.service.js'; @Controller('api/sessions') @@ -18,23 +20,24 @@ export class SessionsController { constructor(@Inject(AgentService) private readonly agentService: AgentService) {} @Get() - list() { - const sessions = this.agentService.listSessions(); + list(@CurrentUser() user: AuthenticatedUserLike) { + const sessions = this.agentService.listSessions(scopeFromUser(user)); return { sessions, total: sessions.length }; } @Get(':id') - findOne(@Param('id') id: string) { - const info = this.agentService.getSessionInfo(id); + findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) { + const info = this.agentService.getSessionInfo(id, scopeFromUser(user)); if (!info) throw new NotFoundException('Session not found'); return info; } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - async destroy(@Param('id') id: string) { - const info = this.agentService.getSessionInfo(id); + async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) { + const scope = scopeFromUser(user); + const info = this.agentService.getSessionInfo(id, scope); if (!info) throw new NotFoundException('Session not found'); - await this.agentService.destroySession(id); + await this.agentService.destroySession(id, scope); } } diff --git a/apps/gateway/src/agent/tools/memory-tools.test.ts b/apps/gateway/src/agent/tools/memory-tools.test.ts new file mode 100644 index 00000000..d90e7bf0 --- /dev/null +++ b/apps/gateway/src/agent/tools/memory-tools.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createMemoryTools } from './memory-tools.js'; + +describe('createMemoryTools operator retrieval binding', () => { + const memory = { + insights: { searchByEmbedding: vi.fn(), create: vi.fn() }, + preferences: { findByUserAndCategory: vi.fn(), findByUser: vi.fn(), upsert: vi.fn() }, + }; + const scope = { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' }; + + it('uses the configured plugin with the server-derived scope for retrieval and capture', async () => { + const plugin = { + search: vi.fn(async () => []), + capture: vi.fn(async () => ({ id: 'insight-1' })), + }; + const tools = createMemoryTools(memory as never, null, 'owner-a', { + plugin: plugin as never, + scope, + }); + + await tools + .find((tool) => tool.name === 'memory_search')! + .execute('call-1', { query: 'plans' }, undefined, undefined, {} as never); + await tools + .find((tool) => tool.name === 'memory_save_insight')! + .execute( + 'call-2', + { content: 'secret', category: 'decision' }, + undefined, + undefined, + {} as never, + ); + + expect(plugin.search).toHaveBeenCalledWith(scope, 'plans', 5); + expect(plugin.capture).toHaveBeenCalledWith(scope, { + content: 'secret', + source: 'agent', + category: 'decision', + }); + }); +}); diff --git a/apps/gateway/src/agent/tools/memory-tools.ts b/apps/gateway/src/agent/tools/memory-tools.ts index ab1809ac..ec9b744d 100644 --- a/apps/gateway/src/agent/tools/memory-tools.ts +++ b/apps/gateway/src/agent/tools/memory-tools.ts @@ -1,7 +1,11 @@ import { Type } from '@sinclair/typebox'; import type { ToolDefinition } from '@mariozechner/pi-coding-agent'; -import type { Memory } from '@mosaicstack/memory'; -import type { EmbeddingProvider } from '@mosaicstack/memory'; +import type { + EmbeddingProvider, + Memory, + OperatorMemoryPlugin, + OperatorMemoryScope, +} from '@mosaicstack/memory'; /** * Create memory tools bound to the session's authenticated userId. @@ -13,8 +17,10 @@ import type { EmbeddingProvider } from '@mosaicstack/memory'; export function createMemoryTools( memory: Memory, embeddingProvider: EmbeddingProvider | null, - /** Authenticated user ID from the session. All memory operations are scoped to this user. */ + /** Authenticated user ID from the session. All preference operations are scoped to this user. */ sessionUserId: string | undefined, + /** Optional configured retrieval plugin, bound to a server-derived session scope. */ + operatorMemory?: { plugin: OperatorMemoryPlugin; scope: OperatorMemoryScope }, ): ToolDefinition[] { /** Return an error result when no session user is bound. */ function noUserError() { @@ -46,6 +52,14 @@ export function createMemoryTools( limit?: number; }; + if (operatorMemory) { + const results = await operatorMemory.plugin.search(operatorMemory.scope, query, limit ?? 5); + return { + content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }], + details: undefined, + }; + } + if (!embeddingProvider) { return { content: [ @@ -158,6 +172,18 @@ export function createMemoryTools( }; type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general'; + if (operatorMemory) { + const insight = await operatorMemory.plugin.capture(operatorMemory.scope, { + content, + source: 'agent', + category: category ?? 'learning', + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }], + details: undefined, + }; + } + let embedding: number[] | null = null; if (embeddingProvider) { embedding = await embeddingProvider.embed(content); diff --git a/apps/gateway/src/auth/session-scope.ts b/apps/gateway/src/auth/session-scope.ts new file mode 100644 index 00000000..9e975d97 --- /dev/null +++ b/apps/gateway/src/auth/session-scope.ts @@ -0,0 +1,24 @@ +export interface AuthenticatedUserLike { + id: string; + tenantId?: string | null; + teamId?: string | null; + organizationId?: string | null; + orgId?: string | null; +} + +export interface ActorTenantScope { + userId: string; + tenantId: string; +} + +/** + * Build the immutable server-derived scope used for Tess session operations. + * Current Mosaic auth is user-scoped; future org/team claims can populate one + * of the tenant fields without allowing clients to choose another tenant. + */ +export function scopeFromUser(user: AuthenticatedUserLike): ActorTenantScope { + return { + userId: user.id, + tenantId: user.tenantId ?? user.teamId ?? user.organizationId ?? user.orgId ?? user.id, + }; +} diff --git a/apps/gateway/src/chat/__tests__/chat-security.test.ts b/apps/gateway/src/chat/__tests__/chat-security.test.ts index 08710007..45bd1f71 100644 --- a/apps/gateway/src/chat/__tests__/chat-security.test.ts +++ b/apps/gateway/src/chat/__tests__/chat-security.test.ts @@ -12,7 +12,8 @@ describe('Chat controller source hardening', () => { const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8'); expect(source).toContain('@UseGuards(AuthGuard)'); - expect(source).toContain('@CurrentUser() user: { id: string }'); + expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike'); + expect(source).toContain('const scope = scopeFromUser(user);'); }); }); diff --git a/apps/gateway/src/chat/chat.controller.ts b/apps/gateway/src/chat/chat.controller.ts index bcfe9154..7cd0baba 100644 --- a/apps/gateway/src/chat/chat.controller.ts +++ b/apps/gateway/src/chat/chat.controller.ts @@ -3,8 +3,10 @@ import { Post, Body, Logger, + ForbiddenException, HttpException, HttpStatus, + NotFoundException, Inject, UseGuards, } from '@nestjs/common'; @@ -13,6 +15,7 @@ import { Throttle } from '@nestjs/throttler'; import { AgentService } from '../agent/agent.service.js'; import { AuthGuard } from '../auth/auth.guard.js'; import { CurrentUser } from '../auth/current-user.decorator.js'; +import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; import { v4 as uuid } from 'uuid'; import { ChatRequestDto } from './chat.dto.js'; @@ -32,16 +35,23 @@ export class ChatController { @Throttle({ default: { limit: 10, ttl: 60_000 } }) async chat( @Body() body: ChatRequestDto, - @CurrentUser() user: { id: string }, + @CurrentUser() user: AuthenticatedUserLike, ): Promise { const conversationId = body.conversationId ?? uuid(); + const scope = scopeFromUser(user); try { - let agentSession = this.agentService.getSession(conversationId); + let agentSession = this.agentService.getSession(conversationId, scope); if (!agentSession) { - agentSession = await this.agentService.createSession(conversationId); + agentSession = await this.agentService.createSession(conversationId, { + userId: scope.userId, + tenantId: scope.tenantId, + }); } } catch (err) { + if (err instanceof ForbiddenException) { + throw new NotFoundException('Session not found'); + } this.logger.error( `Session creation failed for conversation=${conversationId}`, err instanceof Error ? err.stack : String(err), @@ -60,20 +70,27 @@ export class ChatController { reject(new Error('Agent response timed out')); }, 120_000); - const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => { - if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') { - responseText += event.assistantMessageEvent.delta; - } - if (event.type === 'agent_end') { - clearTimeout(timer); - cleanup(); - resolve(); - } - }); + const cleanup = this.agentService.onEvent( + conversationId, + (event: AgentSessionEvent) => { + if ( + event.type === 'message_update' && + event.assistantMessageEvent.type === 'text_delta' + ) { + responseText += event.assistantMessageEvent.delta; + } + if (event.type === 'agent_end') { + clearTimeout(timer); + cleanup(); + resolve(); + } + }, + scope, + ); }); try { - await this.agentService.prompt(conversationId, body.content); + await this.agentService.prompt(conversationId, body.content, scope); await done; } catch (err) { if (err instanceof HttpException) throw err; diff --git a/apps/gateway/src/chat/chat.dto.ts b/apps/gateway/src/chat/chat.dto.ts index 8e90297b..9bd35867 100644 --- a/apps/gateway/src/chat/chat.dto.ts +++ b/apps/gateway/src/chat/chat.dto.ts @@ -1,3 +1,4 @@ +import type { ChannelAttachmentDto } from '@mosaicstack/types'; import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; export class ChatRequestDto { @@ -32,4 +33,7 @@ export class ChatSocketMessageDto { @IsOptional() @IsUUID() agentId?: string; + + /** Validated channel attachment references; binary content is not embedded. */ + attachments?: readonly ChannelAttachmentDto[]; } diff --git a/apps/gateway/src/chat/chat.gateway-auth.ts b/apps/gateway/src/chat/chat.gateway-auth.ts index 16d034c4..b1bf5649 100644 --- a/apps/gateway/src/chat/chat.gateway-auth.ts +++ b/apps/gateway/src/chat/chat.gateway-auth.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from 'node:crypto'; import type { IncomingHttpHeaders } from 'node:http'; import { fromNodeHeaders } from 'better-auth/node'; @@ -12,6 +13,19 @@ export interface SessionAuth { }; } +export function validateDiscordServiceToken( + candidate: unknown, + expected: string | undefined, +): boolean { + if (typeof candidate !== 'string' || !expected) return false; + const candidateBuffer = Buffer.from(candidate); + const expectedBuffer = Buffer.from(expected); + return ( + candidateBuffer.length === expectedBuffer.length && + timingSafeEqual(candidateBuffer, expectedBuffer) + ); +} + export async function validateSocketSession( headers: IncomingHttpHeaders, auth: SessionAuth, diff --git a/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts b/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts new file mode 100644 index 00000000..ac297590 --- /dev/null +++ b/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SlashCommandPayload } from '@mosaicstack/types'; +import { ChatGateway } from './chat.gateway.js'; + +const payload: SlashCommandPayload = { + command: 'gc', + conversationId: 'conversation-1', + approvalId: 'approval-1', +}; + +function buildGateway(commandExecutor: { + execute: ReturnType; + createApproval: ReturnType; +}): ChatGateway { + return new ChatGateway( + {} as never, + {} as never, + {} as never, + {} as never, + commandExecutor as never, + {} as never, + ); +} + +describe('ChatGateway command approval ingress', () => { + it('passes the client approval ID through to command execution while deriving the actor server-side', async (): Promise => { + const commandExecutor = { + execute: vi.fn().mockResolvedValue({ ...payload, success: true }), + createApproval: vi.fn(), + }; + const gateway = buildGateway(commandExecutor); + const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() }; + + await gateway.handleCommandExecute(client as never, payload); + + expect(commandExecutor.execute).toHaveBeenCalledWith(payload, { + userId: 'admin-1', + tenantId: 'admin-1', + }); + expect(client.emit).toHaveBeenCalledWith( + 'command:result', + expect.objectContaining({ success: true }), + ); + }); + + it('issues a durable approval only for the authenticated actor', async (): Promise => { + const commandExecutor = { + execute: vi.fn(), + createApproval: vi.fn().mockResolvedValue({ + approvalId: 'approval-1', + expiresAt: '2026-07-12T00:05:00.000Z', + }), + }; + const gateway = buildGateway(commandExecutor); + const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() }; + + await gateway.handleCommandApproval(client as never, { + command: 'gc', + conversationId: 'conversation-1', + }); + + expect(commandExecutor.createApproval).toHaveBeenCalledWith( + { command: 'gc', conversationId: 'conversation-1' }, + { userId: 'admin-1', tenantId: 'admin-1' }, + ); + expect(client.emit).toHaveBeenCalledWith('command:approval', { + command: 'gc', + conversationId: 'conversation-1', + success: true, + approvalId: 'approval-1', + expiresAt: '2026-07-12T00:05:00.000Z', + }); + }); +}); diff --git a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts new file mode 100644 index 00000000..f43dce34 --- /dev/null +++ b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts @@ -0,0 +1,221 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ChatGateway } from './chat.gateway.js'; + +const CONVERSATION_ID = 'conversation-1'; +const CANARY = 'sk_canary12345678'; + +function clientConversationKey(clientId: string, conversationId: string): string { + return `${clientId}\u0000${conversationId}`; +} + +type GatewayInternals = { + clientSessions: Map; + relayEvent(client: unknown, conversationId: string, event: unknown): void; +}; + +function buildGateway() { + const brain = { + conversations: { + addMessage: vi.fn().mockResolvedValue(undefined), + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + }; + const gateway = new ChatGateway( + agentService as never, + {} as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + + return { gateway: gateway as unknown as GatewayInternals, brain }; +} + +describe('ChatGateway redaction boundary', (): void => { + it('redacts a secret split across assistant deltas before egress and persistence', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + const session = { + clientId: client.id, + conversationId: CONVERSATION_ID, + cleanup: vi.fn(), + assistantText: '', + toolCalls: [], + pendingToolCalls: new Map(), + scope: { userId: 'user-1', tenantId: 'tenant-1' }, + }; + gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), session); + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'sk_canary' }, + }); + + expect(JSON.stringify(client.emit.mock.calls)).not.toContain('sk_canary'); + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '12345678 ' }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_SECRET] ', + }); + expect(session.assistantText).toBe(`${CANARY} `); + expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY); + }); + + it('retains a split secret label until its value can be redacted', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'token ' }, + }); + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '=canaryvalue123 ' }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_SECRET] ', + }); + expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canaryvalue123'); + }); + + it('holds a streamed private key until it can be redacted', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '-----BEGIN PRIVATE KEY-----\ncanary' }, + }); + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '\n-----END PRIVATE KEY-----' }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_SECRET]', + }); + expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canary'); + }); + + it('drops an oversized unterminated stream fragment rather than retaining it', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'x'.repeat(8_193) }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_STREAM_OVERFLOW]', + }); + }); + + it('isolates concurrent conversation streams sharing one Discord socket', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'discord-client', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + const firstConversation = 'Nova:discord:thread-1'; + const secondConversation = 'Nova:discord:thread-2'; + const createSession = (conversationId: string) => ({ + clientId: client.id, + conversationId, + cleanup: vi.fn(), + assistantText: '', + toolCalls: [], + pendingToolCalls: new Map(), + scope: { userId: 'user-1', tenantId: 'tenant-1' }, + }); + const firstSession = createSession(firstConversation); + const secondSession = createSession(secondConversation); + gateway.clientSessions.set(clientConversationKey(client.id, firstConversation), firstSession); + gateway.clientSessions.set(clientConversationKey(client.id, secondConversation), secondSession); + + gateway.relayEvent(client, firstConversation, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'first response ' }, + }); + gateway.relayEvent(client, secondConversation, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'second response ' }, + }); + + expect(firstSession.assistantText).toBe('first response '); + expect(secondSession.assistantText).toBe('second response '); + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: firstConversation, + text: 'first response ', + }); + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: secondConversation, + text: 'second response ', + }); + }); + + it('persists only redacted assistant content with classifications', (): void => { + const { gateway, brain } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), { + clientId: client.id, + conversationId: CONVERSATION_ID, + cleanup: vi.fn(), + assistantText: CANARY, + toolCalls: [], + pendingToolCalls: new Map(), + scope: { userId: 'user-1', tenantId: 'tenant-1' }, + }); + + gateway.relayEvent(client, CONVERSATION_ID, { type: 'agent_end' }); + + expect(brain.conversations.addMessage).toHaveBeenCalledWith( + expect.objectContaining({ + content: '[REDACTED_SECRET]', + metadata: expect.objectContaining({ classifications: ['secret'] }), + }), + 'user-1', + ); + expect(JSON.stringify(brain.conversations.addMessage.mock.calls)).not.toContain(CANARY); + }); +}); diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index fe0758c1..188f6cd0 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -1,4 +1,5 @@ -import { Inject, Logger } from '@nestjs/common'; +import { createHash } from 'node:crypto'; +import { Inject, Logger, Optional } from '@nestjs/common'; import { WebSocketGateway, WebSocketServer, @@ -11,27 +12,53 @@ import { } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; +import { + verifyDiscordIngressEnvelope, + parseDiscordInteractionBindings, + resolveDiscordInteractionActorId, + resolveDiscordInteractionBinding, + type DiscordAttachment, + type DiscordIngressEnvelope, + type DiscordIngressPayload, +} from '@mosaicstack/discord-plugin'; import type { Auth } from '@mosaicstack/auth'; import type { Brain } from '@mosaicstack/brain'; +import { redactSensitiveContent } from '@mosaicstack/log'; import type { SetThinkingPayload, + SlashCommandApprovalResultPayload, SlashCommandPayload, SystemReloadPayload, RoutingDecisionInfo, AbortPayload, + ChannelAttachmentDto, } from '@mosaicstack/types'; import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; +import { + RUNTIME_PROVIDER_AUDIT_SINK, + RuntimeProviderService, + type RuntimeAuditSink, +} from '../agent/runtime-provider-registry.service.js'; +import { DurableSessionService } from '../agent/durable-session.service.js'; import { AUTH } from '../auth/auth.tokens.js'; +import { + scopeFromUser, + type ActorTenantScope, + type AuthenticatedUserLike, +} from '../auth/session-scope.js'; import { BRAIN } from '../brain/brain.tokens.js'; import { CommandRegistryService } from '../commands/command-registry.service.js'; import { CommandExecutorService } from '../commands/command-executor.service.js'; +import { CommandAuthorizationService } from '../commands/command-authorization.service.js'; import { RoutingEngineService } from '../agent/routing/routing-engine.service.js'; import { v4 as uuid } from 'uuid'; import { ChatSocketMessageDto } from './chat.dto.js'; -import { validateSocketSession } from './chat.gateway-auth.js'; +import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js'; +import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js'; /** Per-client state tracking streaming accumulation for persistence. */ interface ClientSession { + clientId: string; conversationId: string; cleanup: () => void; /** Accumulated assistant response text for the current turn. */ @@ -40,6 +67,8 @@ interface ClientSession { toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>; /** Tool calls in-flight (started but not ended yet). */ pendingToolCalls: Map; + /** Server-derived owner/tenant scope for this socket's conversation attachment. */ + scope: ActorTenantScope; /** Last routing decision made for this session (M4-008) */ lastRoutingDecision?: RoutingDecisionInfo; } @@ -49,6 +78,126 @@ interface ClientSession { * Keyed by conversationId, value is the model name to use. */ const modelOverrides = new Map(); +const MAX_REDACTION_BUFFER_LENGTH = 8_192; +const MAX_CHANNEL_ATTACHMENTS = 10; +const MAX_ATTACHMENT_METADATA_BYTES = 16_384; +const MAX_ATTACHMENT_ID_LENGTH = 128; +const MAX_ATTACHMENT_NAME_LENGTH = 255; +const MAX_ATTACHMENT_URL_LENGTH = 2_048; +const MAX_ATTACHMENT_MIME_LENGTH = 255; + +function isSafeAttachmentUrl(value: string): boolean { + if (value.length === 0 || value.length > MAX_ATTACHMENT_URL_LENGTH) return false; + try { + const url = new URL(value); + return ( + url.protocol === 'https:' && + !url.username && + !url.password && + !url.hash && + url.search.length === 0 + ); + } catch { + return false; + } +} + +function hasValidAttachmentBounds(value: { + id: string; + name: string; + url: string; + sizeBytes?: number; +}): boolean { + return ( + value.id.length > 0 && + value.id.length <= MAX_ATTACHMENT_ID_LENGTH && + value.name.length > 0 && + value.name.length <= MAX_ATTACHMENT_NAME_LENGTH && + isSafeAttachmentUrl(value.url) && + (value.sizeBytes === undefined || (Number.isFinite(value.sizeBytes) && value.sizeBytes >= 0)) + ); +} + +function isDiscordAttachment(value: unknown): value is DiscordAttachment { + if (typeof value !== 'object' || value === null) return false; + const attachment = value as Partial; + return ( + typeof attachment.id === 'string' && + typeof attachment.name === 'string' && + typeof attachment.url === 'string' && + (attachment.contentType === null || + (typeof attachment.contentType === 'string' && + attachment.contentType.length <= MAX_ATTACHMENT_MIME_LENGTH)) && + (attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') && + hasValidAttachmentBounds(attachment as DiscordAttachment) + ); +} + +function hasValidAttachmentArray(value: unknown, guard: (attachment: unknown) => boolean): boolean { + return ( + Array.isArray(value) && + value.length <= MAX_CHANNEL_ATTACHMENTS && + JSON.stringify(value).length <= MAX_ATTACHMENT_METADATA_BYTES && + value.every(guard) + ); +} + +function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope { + if (typeof value !== 'object' || value === null) return false; + const envelope = value as { payload?: unknown; signature?: unknown }; + if ( + typeof envelope.signature !== 'string' || + typeof envelope.payload !== 'object' || + envelope.payload === null + ) { + return false; + } + const payload = envelope.payload as Record; + return ( + [ + payload['correlationId'], + payload['messageId'], + payload['guildId'], + payload['channelId'], + payload['userId'], + payload['conversationId'], + payload['content'], + ].every((field: unknown): boolean => typeof field === 'string') && + (payload['threadId'] === undefined || typeof payload['threadId'] === 'string') && + (payload['attachments'] === undefined || + hasValidAttachmentArray(payload['attachments'], isDiscordAttachment)) + ); +} + +function isChannelAttachment(value: unknown): value is ChannelAttachmentDto { + if (typeof value !== 'object' || value === null) return false; + const attachment = value as Partial; + return ( + typeof attachment.id === 'string' && + typeof attachment.name === 'string' && + typeof attachment.url === 'string' && + (attachment.mimeType === null || + (typeof attachment.mimeType === 'string' && + attachment.mimeType.length <= MAX_ATTACHMENT_MIME_LENGTH)) && + (attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') && + hasValidAttachmentBounds(attachment as ChannelAttachmentDto) + ); +} + +function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto { + if (typeof value !== 'object' || value === null) return false; + const payload = value as { + content?: unknown; + conversationId?: unknown; + attachments?: unknown; + }; + return ( + typeof payload.content === 'string' && + (payload.conversationId === undefined || typeof payload.conversationId === 'string') && + (payload.attachments === undefined || + hasValidAttachmentArray(payload.attachments, isChannelAttachment)) + ); +} @WebSocketGateway({ cors: { @@ -62,6 +211,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa private readonly logger = new Logger(ChatGateway.name); private readonly clientSessions = new Map(); + /** Raw stream fragments are kept in memory only until they are safe to redact and emit. */ + private readonly textEgressBuffers = new Map(); + private readonly thinkingEgressBuffers = new Map(); + private readonly overflowedEgress = new Set(); + private readonly discordReplayProtector = new DiscordReplayProtector(); constructor( @Inject(AgentService) private readonly agentService: AgentService, @@ -70,6 +224,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService, @Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService, @Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService, + @Optional() + @Inject(CommandAuthorizationService) + private readonly commandAuthorization: CommandAuthorizationService | null = null, + @Optional() + @Inject(RuntimeProviderService) + private readonly runtimeRegistry: RuntimeProviderService | null = null, + @Optional() + @Inject(DurableSessionService) + private readonly durableSessions: DurableSessionService | null = null, + @Optional() + @Inject(RUNTIME_PROVIDER_AUDIT_SINK) + private readonly runtimeAudit: RuntimeAuditSink | null = null, ) {} afterInit(): void { @@ -77,6 +243,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } async handleConnection(client: Socket): Promise { + const serviceToken = client.handshake.auth['discordServiceToken']; + if (validateDiscordServiceToken(serviceToken, process.env['DISCORD_SERVICE_TOKEN'])) { + client.data.discordService = true; + this.logger.log(`Authenticated Discord service connected: ${client.id}`); + return; + } + const session = await validateSocketSession(client.handshake.headers, this.auth); if (!session) { this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`); @@ -87,35 +260,115 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa client.data.user = session.user; client.data.session = session.session; this.logger.log(`Client connected: ${client.id}`); - - // Broadcast command manifest to the newly connected client client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() }); } handleDisconnect(client: Socket): void { this.logger.log(`Client disconnected: ${client.id}`); - const session = this.clientSessions.get(client.id); - if (session) { + for (const [key, session] of this.clientSessions) { + if (session.clientId !== client.id) continue; session.cleanup(); - this.agentService.removeChannel(session.conversationId, `websocket:${client.id}`); - this.clientSessions.delete(client.id); + this.agentService.removeChannel( + session.conversationId, + `websocket:${client.id}`, + session.scope, + ); + this.clientSessions.delete(key); + this.textEgressBuffers.delete(key); + this.thinkingEgressBuffers.delete(key); + this.overflowedEgress.delete(`${key}:agent:text`); + this.overflowedEgress.delete(`${key}:agent:thinking`); } } + private clientConversationKey(client: Pick, conversationId: string): string { + return `${client.id}\u0000${conversationId}`; + } + + private getClientScope(client: Socket): ActorTenantScope | null { + const user = client.data.user as AuthenticatedUserLike | undefined; + if (!user?.id) return null; + return scopeFromUser(user); + } + + private modelOverrideKey(conversationId: string, scope: ActorTenantScope): string { + return `${scope.tenantId}:${scope.userId}:${conversationId}`; + } + + private scopesEqual(a: ActorTenantScope, b: ActorTenantScope): boolean { + return a.userId === b.userId && a.tenantId === b.tenantId; + } + @SubscribeMessage('message') async handleMessage( @ConnectedSocket() client: Socket, - @MessageBody() data: ChatSocketMessageDto, + @MessageBody() rawData: unknown, ): Promise { + let discordIngress: DiscordIngressPayload | null = null; + let data: ChatSocketMessageDto; + if (client.data.discordService) { + if (!isDiscordIngressEnvelope(rawData)) { + this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`); + return; + } + discordIngress = this.resolveDiscordIngress(client, rawData); + if (!discordIngress) return; + data = { + conversationId: discordIngress.conversationId, + content: discordIngress.content, + ...(discordIngress.attachments + ? { + attachments: discordIngress.attachments.map( + (attachment): ChannelAttachmentDto => ({ + id: attachment.id, + name: attachment.name, + url: attachment.url, + mimeType: attachment.contentType, + ...(attachment.sizeBytes !== undefined + ? { sizeBytes: attachment.sizeBytes } + : {}), + }), + ), + } + : {}), + }; + } else { + if (!isChatSocketMessage(rawData)) { + this.logger.warn(`Rejected malformed chat message from ${client.id}`); + return; + } + data = rawData; + } const conversationId = data.conversationId ?? uuid(); - const userId = (client.data.user as { id: string } | undefined)?.id; + const clientConversationKey = this.clientConversationKey(client, conversationId); + const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; + if (discordIngress && !discordServiceUserId) { + this.logger.warn( + `Rejected Discord ingress without configured service owner from ${client.id}`, + ); + return; + } + const scope = discordIngress + ? { + userId: discordServiceUserId!, + tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? discordServiceUserId!, + } + : this.getClientScope(client); + if (!scope) { + client.emit('error', { conversationId, error: 'Authenticated user scope is required.' }); + return; + } + const userId = scope.userId; + const correlationId = discordIngress?.correlationId; - this.logger.log(`Message from ${client.id} in conversation ${conversationId}`); + this.logger.log( + `Message from ${client.id} in conversation ${conversationId}${correlationId ? ` correlation=${correlationId}` : ''}`, + ); // Ensure agent session exists for this conversation let sessionRoutingDecision: RoutingDecisionInfo | undefined; try { - let agentSession = this.agentService.getSession(conversationId); + let agentSession = this.agentService.getSession(conversationId, scope); if (!agentSession) { // When resuming an existing conversation, load prior messages to inject as context (M1-004) const conversationHistory = await this.loadConversationHistory(conversationId, userId); @@ -135,14 +388,14 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa let resolvedProvider = data.provider; let resolvedModelId = data.modelId; - const modelOverride = modelOverrides.get(conversationId); + const modelOverride = modelOverrides.get(this.modelOverrideKey(conversationId, scope)); if (modelOverride) { // /model override bypasses routing engine (M4-007) resolvedModelId = modelOverride; this.logger.log( `Using /model override "${modelOverride}" for conversation=${conversationId}`, ); - } else if (!resolvedProvider && !resolvedModelId) { + } else if (!resolvedProvider && !resolvedModelId && !discordIngress) { // No explicit provider/model from client — use routing engine (M4-012) try { const routingDecision = await this.routingEngine.resolve(data.content, userId); @@ -165,13 +418,26 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } + let resolvedAgentConfigId = data.agentId; + if (discordIngress) { + const binding = this.discordBindingFor(discordIngress, 'send'); + const agentConfig = binding + ? await this.brain.agents.findById(binding.agentConfigId) + : undefined; + if (!binding || !agentConfig || agentConfig.name !== binding.instanceId) { + throw new Error('Configured Discord logical agent is not provisioned'); + } + resolvedAgentConfigId = agentConfig.id; + } + // M5-004: Use existingSessionId as sessionId when available (session reuse) const sessionIdToCreate = existingSessionId ?? conversationId; agentSession = await this.agentService.createSession(sessionIdToCreate, { provider: resolvedProvider, modelId: resolvedModelId, - agentConfigId: data.agentId, + agentConfigId: resolvedAgentConfigId, userId, + tenantId: scope.tenantId, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, }); @@ -210,9 +476,28 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa { conversationId, role: 'user', - content: data.content, + content: redactSensitiveContent(data.content).content, metadata: { timestamp: new Date().toISOString(), + ...(correlationId + ? { + correlationId, + discordMessageId: discordIngress?.messageId, + discordUserId: discordIngress?.userId, + } + : {}), + ...(data.attachments && data.attachments.length > 0 + ? { + channelAttachments: data.attachments.map( + (attachment): ChannelAttachmentDto => ({ + ...attachment, + name: redactSensitiveContent(attachment.name).content, + url: redactSensitiveContent(attachment.url).content, + }), + ), + } + : {}), + classifications: redactSensitiveContent(data.content).classifications, }, }, userId, @@ -226,36 +511,42 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // Always clean up previous listener to prevent leak - const existing = this.clientSessions.get(client.id); + const existing = this.clientSessions.get(clientConversationKey); if (existing) { existing.cleanup(); } // Subscribe to agent events and relay to client - const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => { - this.relayEvent(client, conversationId, event); - }); + const cleanup = this.agentService.onEvent( + conversationId, + (event: AgentSessionEvent) => { + this.relayEvent(client, conversationId, event); + }, + scope, + ); // Preserve routing decision from the existing client session if we didn't get a new one - const prevClientSession = this.clientSessions.get(client.id); + const prevClientSession = this.clientSessions.get(clientConversationKey); const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision; - this.clientSessions.set(client.id, { + this.clientSessions.set(clientConversationKey, { + clientId: client.id, conversationId, cleanup, assistantText: '', toolCalls: [], pendingToolCalls: new Map(), + scope, lastRoutingDecision: routingDecisionToStore, }); // Track channel connection - this.agentService.addChannel(conversationId, `websocket:${client.id}`); + this.agentService.addChannel(conversationId, `websocket:${client.id}`, scope); // Send session info so the client knows the model/provider (M4-008: include routing decision) // Include agentName when a named agent config is active (M5-001) { - const agentSession = this.agentService.getSession(conversationId); + const agentSession = this.agentService.getSession(conversationId, scope); if (agentSession) { const piSession = agentSession.piSession; client.emit('session:info', { @@ -271,11 +562,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // Send acknowledgment - client.emit('message:ack', { conversationId, messageId: uuid() }); + client.emit('message:ack', { + conversationId, + messageId: uuid(), + ...(correlationId + ? { + correlationId, + discordMessageId: discordIngress?.messageId, + discordUserId: discordIngress?.userId, + } + : {}), + }); // Dispatch to agent try { - await this.agentService.prompt(conversationId, data.content); + await this.agentService.prompt(conversationId, data.content, scope, data.attachments); } catch (err) { this.logger.error( `Agent prompt failed for client=${client.id}, conversation=${conversationId}`, @@ -293,7 +594,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @ConnectedSocket() client: Socket, @MessageBody() data: SetThinkingPayload, ): void { - const session = this.agentService.getSession(data.conversationId); + const scope = this.getClientScope(client); + if (!scope) { + client.emit('error', { + conversationId: data.conversationId, + error: 'Authenticated user scope is required.', + }); + return; + } + + const session = this.agentService.getSession(data.conversationId, scope); if (!session) { client.emit('error', { conversationId: data.conversationId, @@ -334,7 +644,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const conversationId = data.conversationId; this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`); - const session = this.agentService.getSession(conversationId); + const scope = this.getClientScope(client); + if (!scope) { + client.emit('error', { conversationId, error: 'Authenticated user scope is required.' }); + return; + } + + const session = this.agentService.getSession(conversationId, scope); if (!session) { client.emit('error', { conversationId, @@ -363,11 +679,45 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @ConnectedSocket() client: Socket, @MessageBody() payload: SlashCommandPayload, ): Promise { - const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown'; - const result = await this.commandExecutor.execute(payload, userId); + const scope = this.getClientScope(client); + if (!scope) { + client.emit('command:result', { + command: payload.command, + conversationId: payload.conversationId, + success: false, + message: 'Authenticated user scope is required.', + }); + return; + } + + const result = await this.commandExecutor.execute(payload, scope); client.emit('command:result', result); } + @SubscribeMessage('command:approve') + async handleCommandApproval( + @ConnectedSocket() client: Socket, + @MessageBody() payload: SlashCommandPayload, + ): Promise { + const scope = this.getClientScope(client); + const approval = scope ? await this.commandExecutor.createApproval(payload, scope) : null; + const result: SlashCommandApprovalResultPayload = approval + ? { + command: payload.command, + conversationId: payload.conversationId, + success: true, + approvalId: approval.approvalId, + expiresAt: approval.expiresAt, + } + : { + command: payload.command, + conversationId: payload.conversationId, + success: false, + message: 'Not authorized to approve this command.', + }; + client.emit('command:approval', result); + } + broadcastReload(payload: SystemReloadPayload): void { this.server.emit('system:reload', payload); this.logger.log('Broadcasted system:reload to all connected clients'); @@ -380,18 +730,23 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa * M5-005: Emits session:info to clients subscribed to this conversation when a model is set. * M5-007: Records a model switch in session metrics. */ - setModelOverride(conversationId: string, modelName: string | null): void { + setModelOverride( + conversationId: string, + modelName: string | null, + scope: ActorTenantScope, + ): void { + const key = this.modelOverrideKey(conversationId, scope); if (modelName) { - modelOverrides.set(conversationId, modelName); + modelOverrides.set(key, modelName); this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`); // M5-002: Update the live session's modelId so session:info reflects the new model immediately - this.agentService.updateSessionModel(conversationId, modelName); + this.agentService.updateSessionModel(conversationId, modelName, scope); // M5-005: Broadcast session:info to all clients subscribed to this conversation - this.broadcastSessionInfo(conversationId); + this.broadcastSessionInfo(conversationId, scope); } else { - modelOverrides.delete(conversationId); + modelOverrides.delete(key); this.logger.log(`Model override cleared: conversation=${conversationId}`); } } @@ -399,8 +754,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa /** * Return the active model override for a conversation, or undefined if none. */ - getModelOverride(conversationId: string): string | undefined { - return modelOverrides.get(conversationId); + getModelOverride(conversationId: string, scope: ActorTenantScope): string | undefined { + return modelOverrides.get(this.modelOverrideKey(conversationId, scope)); } /** @@ -409,9 +764,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa */ broadcastSessionInfo( conversationId: string, + scope: ActorTenantScope, extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo }, ): void { - const agentSession = this.agentService.getSession(conversationId); + const agentSession = this.agentService.getSession(conversationId, scope); if (!agentSession) return; const piSession = agentSession.piSession; @@ -427,9 +783,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa }; // Emit to all clients currently subscribed to this conversation - for (const [clientId, session] of this.clientSessions) { - if (session.conversationId === conversationId) { - const socket = this.server.sockets.sockets.get(clientId); + for (const session of this.clientSessions.values()) { + if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) { + const socket = this.server.sockets.sockets.get(session.clientId); if (socket?.connected) { socket.emit('session:info', payload); } @@ -442,6 +798,235 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa * Creates it if absent — safe to call concurrently since a duplicate insert * would fail on the PK constraint and be caught here. */ + @SubscribeMessage('discord:approve') + async handleDiscordApproval( + @ConnectedSocket() client: Socket, + @MessageBody() envelope: DiscordIngressEnvelope, + ): Promise { + if (!client.data.discordService) return; + const ingress = this.resolveDiscordIngress(client, envelope, 'approve'); + const isApprovalCommand = /^\/approve\s*$/i.test(ingress?.content ?? ''); + const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim(); + if ( + !ingress || + !isApprovalCommand || + !tenantId || + !this.commandAuthorization || + !this.durableSessions + ) + return; + const binding = this.discordBindingFor(ingress, 'approve'); + const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); + const agentName = binding?.instanceId; + if (!actorId || !agentName) { + this.logger.warn( + `Rejected Discord approval without a matching runtime agent from ${client.id}`, + ); + client.emit('discord:approval', { + correlationId: ingress.correlationId, + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + return; + } + let snapshot; + try { + snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, { + actorScope: { userId: actorId, tenantId }, + channelId: ingress.channelId, + correlationId: ingress.correlationId, + }); + } catch { + client.emit('discord:approval', { + correlationId: ingress.correlationId, + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + return; + } + if (snapshot.identity.agentName !== agentName) { + client.emit('discord:approval', { + correlationId: ingress.correlationId, + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + return; + } + const approval = await this.commandAuthorization.createRuntimeTerminationApproval({ + providerId: snapshot.identity.providerId, + sessionId: snapshot.identity.runtimeSessionId, + actorId, + tenantId, + channelId: ingress.channelId, + correlationId: this.discordRuntimeActionCorrelation( + binding.instanceId, + ingress, + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + ), + agentName, + }); + if (!approval) { + await this.runtimeAudit?.record({ + providerId: snapshot.identity.providerId, + operation: 'session.terminate', + outcome: 'denied', + actorId, + tenantId, + channelId: ingress.channelId, + correlationId: this.discordRuntimeActionCorrelation( + binding.instanceId, + ingress, + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + ), + resourceId: snapshot.identity.runtimeSessionId, + errorCode: 'policy_denied', + }); + } + client.emit('discord:approval', { + correlationId: ingress.correlationId, + success: approval !== null, + approvalId: approval?.approvalId, + expiresAt: approval?.expiresAt, + }); + } + + @SubscribeMessage('discord:stop') + async handleDiscordStop( + @ConnectedSocket() client: Socket, + @MessageBody() envelope: DiscordIngressEnvelope, + ): Promise { + if (!client.data.discordService) return; + const ingress = this.resolveDiscordIngress(client, envelope, 'stop'); + const approvalRef = /^\/stop\s+([^\s]+)$/i.exec(ingress?.content ?? '')?.[1]; + const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim(); + if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions) + return; + const binding = this.discordBindingFor(ingress, 'stop'); + const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); + if (!actorId) return; + + try { + const context = { + actorScope: { userId: actorId, tenantId }, + channelId: ingress.channelId, + correlationId: ingress.correlationId, + }; + const snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, context); + if (snapshot.identity.agentName !== binding.instanceId) throw new Error('agent mismatch'); + // RuntimeProviderService consumes the durable approval exactly once using the + // provisioned approving-admin identity, never the Discord service account. + await this.runtimeRegistry.terminate( + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + approvalRef, + { + ...context, + correlationId: this.discordRuntimeActionCorrelation( + binding.instanceId, + ingress, + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + ), + }, + ); + client.emit('discord:stop', { correlationId: ingress.correlationId, success: true }); + } catch { + client.emit('discord:stop', { correlationId: ingress.correlationId, success: false }); + } + } + + /** + * Correlates the immutable termination target rather than either Discord message. + * Approval and stop are distinct ingress events, but must consume the same seven-field action. + */ + private discordRuntimeActionCorrelation( + instanceId: string, + ingress: DiscordIngressPayload, + providerId: string, + sessionId: string, + ): string { + const target = [ + instanceId, + ingress.guildId, + ingress.channelId, + ingress.conversationId, + providerId, + sessionId, + ]; + return `discord-action:v1:${createHash('sha256').update(JSON.stringify(target)).digest('hex')}`; + } + + private resolveDiscordIngress( + client: Socket, + envelope: DiscordIngressEnvelope, + operation: 'send' | 'approve' | 'stop' = 'send', + ): DiscordIngressPayload | null { + const payload = verifyDiscordIngressEnvelope( + envelope, + process.env['DISCORD_SERVICE_TOKEN'] ?? '', + { + guildIds: this.readDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), + channelIds: this.readDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), + userIds: this.readDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), + }, + ); + if (!payload) { + this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`); + return null; + } + try { + const binding = this.discordBindingFor(payload, operation); + if (!binding) { + this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`); + return null; + } + const expectedConversationId = `${binding.instanceId}:discord:${payload.threadId ?? payload.channelId}`; + if (payload.conversationId !== expectedConversationId) { + this.logger.warn( + `Rejected Discord ingress for a different logical agent from ${client.id}`, + ); + return null; + } + } catch { + this.logger.warn( + `Rejected Discord ingress without valid binding configuration from ${client.id}`, + ); + return null; + } + if (!this.discordReplayProtector.claim(payload.messageId)) { + this.logger.warn( + `Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`, + ); + return null; + } + return payload; + } + + private discordBindingFor( + payload: DiscordIngressPayload, + operation: 'send' | 'approve' | 'stop', + ) { + return resolveDiscordInteractionBinding( + parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), + payload.guildId, + payload.channelId, + payload.userId, + operation, + ); + } + + private readDiscordAllowlist(name: string): string[] { + return (process.env[name] ?? '') + .split(',') + .map((id: string): string => id.trim()) + .filter((id: string): boolean => id.length > 0); + } + private async ensureConversation(conversationId: string, userId: string): Promise { try { const existing = await this.brain.conversations.findById(conversationId, userId); @@ -513,11 +1098,15 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const messages = await this.brain.conversations.findMessages(conversationId, userId); if (messages.length === 0) return []; - return messages.map((msg) => ({ - role: msg.role as 'user' | 'assistant' | 'system', - content: msg.content, - createdAt: msg.createdAt, - })); + return messages.map((msg) => { + const attachments = this.persistedChannelAttachments(msg.metadata); + return { + role: msg.role as 'user' | 'assistant' | 'system', + content: msg.content, + createdAt: msg.createdAt, + ...(attachments ? { attachments } : {}), + }; + }); } catch (err) { this.logger.error( `Failed to load conversation history for conversation=${conversationId}`, @@ -527,6 +1116,141 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } + private persistedChannelAttachments(metadata: unknown): readonly ChannelAttachmentDto[] | null { + if (typeof metadata !== 'object' || metadata === null) return null; + const attachments = (metadata as { channelAttachments?: unknown }).channelAttachments; + return hasValidAttachmentArray(attachments, isChannelAttachment) + ? (attachments as readonly ChannelAttachmentDto[]) + : null; + } + + private appendAndFlushRedactedEgress( + client: Socket, + conversationId: string, + eventName: 'agent:text' | 'agent:thinking', + buffers: Map, + delta: string, + ): void { + const sessionKey = this.clientConversationKey(client, conversationId); + const key = this.egressKey(client, conversationId, eventName); + if (this.overflowedEgress.has(key)) return; + + const buffered = `${buffers.get(sessionKey) ?? ''}${delta}`; + if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) { + buffers.delete(sessionKey); + this.overflowedEgress.add(key); + client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' }); + return; + } + + buffers.set(sessionKey, buffered); + this.flushRedactedEgress(client, conversationId, eventName, buffers, false); + } + + /** + * Holds any suffix that could become a secret, email, or phone number after a + * later stream chunk. This avoids relying on downstream redaction after data + * has already reached the socket. + */ + private flushRedactedEgress( + client: Socket, + conversationId: string, + eventName: 'agent:text' | 'agent:thinking', + buffers: Map, + final: boolean, + ): void { + const sessionKey = this.clientConversationKey(client, conversationId); + const key = this.egressKey(client, conversationId, eventName); + if (this.overflowedEgress.has(key)) { + if (final) this.overflowedEgress.delete(key); + return; + } + + const buffered = buffers.get(sessionKey) ?? ''; + const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered); + const released = buffered.slice(0, releaseLength); + const pending = buffered.slice(releaseLength); + + if (pending) { + buffers.set(sessionKey, pending); + } else { + buffers.delete(sessionKey); + } + + if (released) { + client.emit(eventName, { + conversationId, + text: redactSensitiveContent(released).content, + }); + } + } + + private safeRedactionPrefixLength(content: string): number { + let retainedFrom = content.length; + + // Retain the current token because it may become a split secret or email. + const token = /(?:^|\s)(\S*)$/.exec(content); + if (token) { + const matched = token[0] ?? ''; + const trailingToken = token[1] ?? ''; + retainedFrom = token.index + matched.length - trailingToken.length; + } + + // The secret classifier accepts whitespace around ':' and '=', so preserve + // a pending label until its value and delimiter are both complete. + const pendingSecretLabel = + /(?:^|[^A-Za-z0-9_])((?:api[_-]?key|token|password|secret|bearer|authorization)\s*)$/i.exec( + content, + ); + if (pendingSecretLabel) { + const label = pendingSecretLabel[1] ?? ''; + retainedFrom = Math.min( + retainedFrom, + pendingSecretLabel.index + pendingSecretLabel[0].length - label.length, + ); + } + + const secretLabel = /(?:api[_-]?key|token|password|secret|authorization)\s*[:=]\s*$/i.exec( + content, + ); + if (secretLabel) { + retainedFrom = Math.min(retainedFrom, secretLabel.index); + } + + // Phone numbers can contain whitespace and punctuation; preserve the full + // trailing numeric candidate until a non-phone character establishes a boundary. + const phone = /(?:^|[^A-Za-z0-9_])(\+?\d[\d(). -]*)$/.exec(content); + if (phone) { + const matched = phone[0] ?? ''; + const trailingPhoneCandidate = phone[1] ?? ''; + retainedFrom = Math.min( + retainedFrom, + phone.index + matched.length - trailingPhoneCandidate.length, + ); + } + + const privateKeyStart = content.lastIndexOf('-----BEGIN'); + if (privateKeyStart >= 0) { + const privateKey = content.slice(privateKeyStart); + if (/-----END(?: [A-Z]+)* KEY-----/.test(privateKey)) { + // Release the complete block in one pass so the full-block classifier can redact it. + retainedFrom = content.length; + } else { + retainedFrom = Math.min(retainedFrom, privateKeyStart); + } + } + + return retainedFrom; + } + + private egressKey( + client: Socket, + conversationId: string, + eventName: 'agent:text' | 'agent:thinking', + ): string { + return `${this.clientConversationKey(client, conversationId)}:${eventName}`; + } + private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void { if (!client.connected) { this.logger.warn( @@ -535,22 +1259,30 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return; } + const sessionKey = this.clientConversationKey(client, conversationId); switch (event.type) { case 'agent_start': { // Reset accumulation buffers for the new turn - const cs = this.clientSessions.get(client.id); + const cs = this.clientSessions.get(sessionKey); if (cs) { cs.assistantText = ''; cs.toolCalls = []; cs.pendingToolCalls.clear(); } + this.textEgressBuffers.set(sessionKey, ''); + this.thinkingEgressBuffers.set(sessionKey, ''); + this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:text')); + this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:thinking')); client.emit('agent:start', { conversationId }); break; } case 'agent_end': { // Gather usage stats from the Pi session - const agentSession = this.agentService.getSession(conversationId); + const activeClientSession = this.clientSessions.get(sessionKey); + const agentSession = activeClientSession + ? this.agentService.getSession(conversationId, activeClientSession.scope) + : undefined; const piSession = agentSession?.piSession; const stats = piSession?.getSessionStats(); const contextUsage = piSession?.getContextUsage(); @@ -569,6 +1301,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } : undefined; + this.flushRedactedEgress( + client, + conversationId, + 'agent:text', + this.textEgressBuffers, + true, + ); + this.flushRedactedEgress( + client, + conversationId, + 'agent:thinking', + this.thinkingEgressBuffers, + true, + ); client.emit('agent:end', { conversationId, usage: usagePayload, @@ -586,7 +1332,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // Persist the assistant message with metadata - const cs = this.clientSessions.get(client.id); + const cs = this.clientSessions.get(sessionKey); const userId = (client.data.user as { id: string } | undefined)?.id; if (cs && userId && cs.assistantText.trim().length > 0) { const metadata: Record = { @@ -611,8 +1357,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa { conversationId, role: 'assistant', - content: cs.assistantText, - metadata, + content: redactSensitiveContent(cs.assistantText).content, + metadata: { + ...metadata, + classifications: redactSensitiveContent(cs.assistantText).classifications, + }, }, userId, ) @@ -634,27 +1383,33 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa case 'message_update': { const assistantEvent = event.assistantMessageEvent; if (assistantEvent.type === 'text_delta') { - // Accumulate assistant text for persistence - const cs = this.clientSessions.get(client.id); + // Keep raw stream material in memory only; persist and emit only redacted text. + const cs = this.clientSessions.get(sessionKey); if (cs) { cs.assistantText += assistantEvent.delta; } - client.emit('agent:text', { + this.appendAndFlushRedactedEgress( + client, conversationId, - text: assistantEvent.delta, - }); + 'agent:text', + this.textEgressBuffers, + assistantEvent.delta, + ); } else if (assistantEvent.type === 'thinking_delta') { - client.emit('agent:thinking', { + this.appendAndFlushRedactedEgress( + client, conversationId, - text: assistantEvent.delta, - }); + 'agent:thinking', + this.thinkingEgressBuffers, + assistantEvent.delta, + ); } break; } case 'tool_execution_start': { // Track pending tool call for later recording - const cs = this.clientSessions.get(client.id); + const cs = this.clientSessions.get(sessionKey); if (cs) { cs.pendingToolCalls.set(event.toolCallId, { toolName: event.toolName, @@ -671,7 +1426,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa case 'tool_execution_end': { // Finalise tool call record - const cs = this.clientSessions.get(client.id); + const cs = this.clientSessions.get(sessionKey); if (cs) { const pending = cs.pendingToolCalls.get(event.toolCallId); cs.toolCalls.push({ diff --git a/apps/gateway/src/commands/command-authorization.service.spec.ts b/apps/gateway/src/commands/command-authorization.service.spec.ts new file mode 100644 index 00000000..315d902b --- /dev/null +++ b/apps/gateway/src/commands/command-authorization.service.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types'; +import { CommandAuthorizationService } from './command-authorization.service.js'; + +const adminCommand: CommandDef = { + name: 'gc', + description: 'GC', + aliases: [], + scope: 'admin', + execution: 'socket', + available: true, +}; +const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' }; + +function createService( + role: string, + entries: Map = new Map(), +): CommandAuthorizationService { + const db = { + select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }), + }; + const redis = { + get: async (key: string) => entries.get(key) ?? null, + set: async (key: string, value: string) => { + entries.set(key, value); + }, + del: async (key: string) => Number(entries.delete(key)), + }; + return new CommandAuthorizationService(db as never, redis); +} + +describe('CommandAuthorizationService', () => { + it('consumes one exact actor-bound approval once', async (): Promise => { + const service = createService('admin'); + const approval = await service.createApproval(adminCommand, payload, 'admin-1'); + expect(approval).not.toBeNull(); + expect( + (await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed, + ).toBe(true); + expect( + (await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed, + ).toBe(false); + }); + + it('rejects an approval when the structured action is mutated', async (): Promise => { + const service = createService('admin'); + const approval = await service.createApproval(adminCommand, payload, 'admin-1'); + const mutated = { ...payload, conversationId: 'other-conversation' }; + expect(approval).not.toBeNull(); + expect( + (await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed, + ).toBe(false); + }); + + it('denies an admin command to a member before approval is considered', async (): Promise => { + const service = createService('member'); + const approval = await service.createApproval(adminCommand, payload, 'member-1'); + expect(approval).toBeNull(); + expect( + (await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed, + ).toBe(false); + }); + + it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise => { + const entries = new Map(); + const action = { + providerId: 'fleet', + sessionId: 'nova', + actorId: 'admin-1', + tenantId: 'tenant-1', + channelId: 'discord:operator', + correlationId: 'correlation-malformed-expiry', + agentName: 'Nova', + }; + const service = createService('admin', entries); + const approval = await service.createRuntimeTerminationApproval(action); + expect(approval).not.toBeNull(); + const key = `agent:Nova:command-approval:${approval!.approvalId}`; + const stored = entries.get(key); + expect(stored).toBeDefined(); + entries.set(key, JSON.stringify({ ...JSON.parse(stored!), expiresAt: 'not-a-date' })); + + expect(await service.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe( + false, + ); + }); + + it('persists and consumes one exact runtime termination approval across a service restart', async (): Promise => { + const entries = new Map(); + const action = { + providerId: 'fleet', + sessionId: 'nova', + actorId: 'admin-1', + tenantId: 'tenant-1', + channelId: 'discord:operator', + correlationId: 'correlation-1', + agentName: 'Nova', + }; + const beforeRestart = createService('admin', entries); + const approval = await beforeRestart.createRuntimeTerminationApproval(action); + + const afterRestart = createService('admin', entries); + expect( + await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, { + ...action, + sessionId: 'forged-session', + }), + ).toBe(false); + expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe( + true, + ); + expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe( + false, + ); + }); +}); diff --git a/apps/gateway/src/commands/command-authorization.service.ts b/apps/gateway/src/commands/command-authorization.service.ts new file mode 100644 index 00000000..a9f829e7 --- /dev/null +++ b/apps/gateway/src/commands/command-authorization.service.ts @@ -0,0 +1,268 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { eq, users as usersTable, type Db } from '@mosaicstack/db'; +import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types'; +import { DB } from '../database/database.module.js'; +import { COMMANDS_REDIS } from './commands.tokens.js'; + +export type CommandRole = 'admin' | 'member' | 'viewer'; + +export interface CommandApproval { + approvalId: string; + actionDigest: string; + actorId: string; + command: string; + expiresAt: string; +} + +/** Exact immutable binding for a privileged runtime termination. */ +export interface RuntimeTerminationApprovalAction { + providerId: string; + sessionId: string; + actorId: string; + tenantId: string; + channelId: string; + correlationId: string; + /** Provisioned roster identity; isolates approvals between interaction agents. */ + agentName: string; +} + +export interface RuntimeTerminationApproval extends RuntimeTerminationApprovalAction { + approvalId: string; + actionDigest: string; + expiresAt: string; +} + +export interface CommandAuthorizationResult { + allowed: boolean; + reason?: string; +} + +@Injectable() +export class CommandAuthorizationService { + constructor( + @Inject(DB) private readonly db: Db, + @Inject(COMMANDS_REDIS) + private readonly redis: { + get(key: string): Promise; + set(key: string, value: string, ...args: string[]): Promise; + del(key: string): Promise; + }, + ) {} + + async authorize( + command: CommandDef, + payload: SlashCommandPayload, + actorId: string, + approvalId?: string, + ): Promise { + const role = await this.resolveRole(actorId); + if (!role || !this.hasScope(role, command.scope)) { + return { allowed: false, reason: 'not authorized for this command scope' }; + } + if (command.scope !== 'admin') return { allowed: true }; + if (!approvalId) return { allowed: false, reason: 'durable approval is required' }; + const actionDigest = this.actionDigest(command.name, payload); + const approved = await this.consumeApproval(approvalId, actorId, actionDigest); + return approved + ? { allowed: true } + : { + allowed: false, + reason: 'approval is invalid, expired, replayed, or does not match this action', + }; + } + + async createApproval( + command: CommandDef, + payload: SlashCommandPayload, + actorId: string, + ): Promise { + const role = await this.resolveRole(actorId); + if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null; + + const approvalId = randomUUID(); + const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString(); + const approval: CommandApproval = { + approvalId, + actionDigest: this.actionDigest(command.name, payload), + actorId, + command: command.name, + expiresAt, + }; + await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300'); + return approval; + } + + /** + * Uses the same `interaction:command-approval:*` store and one-time deletion rule as + * command approvals. This deliberately avoids a parallel approval database. + */ + async createRuntimeTerminationApproval( + action: RuntimeTerminationApprovalAction, + ): Promise { + if (!this.hasRuntimeTerminationAction(action)) return null; + const role = await this.resolveRole(action.actorId); + if (role !== 'admin') return null; + + const approval: RuntimeTerminationApproval = { + approvalId: randomUUID(), + actionDigest: this.runtimeActionDigest(action), + ...action, + expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), + }; + await this.redis.set( + this.runtimeKey(action.agentName, approval.approvalId), + JSON.stringify(approval), + 'EX', + '300', + ); + return approval; + } + + async consumeRuntimeTerminationApproval( + approvalId: string, + action: RuntimeTerminationApprovalAction, + ): Promise { + const encoded = await this.redis.get(this.runtimeKey(action.agentName, approvalId)); + if (!encoded) return false; + let approval: unknown; + try { + approval = JSON.parse(encoded); + } catch { + return false; + } + if ( + !this.isRuntimeTerminationApproval(approval) || + approval.actionDigest !== this.runtimeActionDigest(action) || + approval.actorId !== action.actorId || + approval.tenantId !== action.tenantId || + !this.isUnexpired(approval.expiresAt) + ) { + return false; + } + if ((await this.resolveRole(approval.actorId)) !== 'admin') return false; + return (await this.redis.del(this.runtimeKey(action.agentName, approvalId))) === 1; + } + + private async resolveRole(actorId: string): Promise { + const [user] = await this.db + .select({ role: usersTable.role }) + .from(usersTable) + .where(eq(usersTable.id, actorId)) + .limit(1); + const role = user?.role; + return role === 'admin' || role === 'member' || role === 'viewer' ? role : null; + } + + private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean { + if (role === 'admin') return true; + return role === 'member' && (scope === 'core' || scope === 'agent'); + } + + private async consumeApproval( + approvalId: string, + actorId: string, + actionDigest: string, + ): Promise { + const key = this.key(approvalId); + const encoded = await this.redis.get(key); + if (!encoded) return false; + let parsed: unknown; + try { + parsed = JSON.parse(encoded); + } catch { + return false; + } + if ( + !this.isCommandApproval(parsed) || + parsed.actorId !== actorId || + parsed.actionDigest !== actionDigest || + !this.isUnexpired(parsed.expiresAt) + ) + return false; + return (await this.redis.del(key)) === 1; + } + + private actionDigest(command: string, payload: SlashCommandPayload): string { + return createHash('sha256') + .update( + JSON.stringify({ + command, + args: payload.args?.trim() ?? '', + conversationId: payload.conversationId, + }), + ) + .digest('hex'); + } + + private hasRuntimeTerminationAction(action: RuntimeTerminationApprovalAction): boolean { + return [ + action.providerId, + action.sessionId, + action.actorId, + action.tenantId, + action.channelId, + action.correlationId, + action.agentName, + ].every((value: string): boolean => value.trim().length > 0); + } + + private runtimeActionDigest(action: RuntimeTerminationApprovalAction): string { + return createHash('sha256') + .update( + JSON.stringify({ + providerId: action.providerId, + sessionId: action.sessionId, + actorId: action.actorId, + tenantId: action.tenantId, + channelId: action.channelId, + correlationId: action.correlationId, + agentName: action.agentName, + }), + ) + .digest('hex'); + } + + private isUnexpired(expiresAt: unknown): expiresAt is string { + if (typeof expiresAt !== 'string') return false; + const expiresAtMs = Date.parse(expiresAt); + return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now(); + } + + private isCommandApproval(value: unknown): value is CommandApproval { + return ( + typeof value === 'object' && + value !== null && + 'approvalId' in value && + 'actionDigest' in value && + 'actorId' in value && + 'expiresAt' in value && + 'command' in value + ); + } + + private isRuntimeTerminationApproval(value: unknown): value is RuntimeTerminationApproval { + return ( + typeof value === 'object' && + value !== null && + 'approvalId' in value && + 'actionDigest' in value && + 'actorId' in value && + 'tenantId' in value && + 'providerId' in value && + 'sessionId' in value && + 'channelId' in value && + 'correlationId' in value && + 'agentName' in value && + 'expiresAt' in value + ); + } + + private key(approvalId: string): string { + return `interaction:command-approval:${approvalId}`; + } + + private runtimeKey(agentName: string, approvalId: string): string { + return `agent:${encodeURIComponent(agentName)}:command-approval:${approvalId}`; + } +} diff --git a/apps/gateway/src/commands/command-executor-p8012.spec.ts b/apps/gateway/src/commands/command-executor-p8012.spec.ts index d098682b..b22ed05a 100644 --- a/apps/gateway/src/commands/command-executor-p8012.spec.ts +++ b/apps/gateway/src/commands/command-executor-p8012.spec.ts @@ -72,13 +72,13 @@ const mockChatGateway = { broadcastSessionInfo: vi.fn(), }; -function buildService(): CommandExecutorService { +function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService { return new CommandExecutorService( mockRegistry as never, mockAgentService as never, mockSystemOverride as never, mockSessionGC as never, - mockRedis as never, + redis as never, mockBrain as never, null, mockChatGateway as never, @@ -89,6 +89,7 @@ function buildService(): CommandExecutorService { describe('CommandExecutorService — P8-012 commands', () => { let service: CommandExecutorService; const userId = 'user-123'; + const userScope = { userId, tenantId: userId }; const conversationId = 'conv-456'; beforeEach(() => { @@ -99,31 +100,26 @@ describe('CommandExecutorService — P8-012 commands', () => { // /provider login — missing provider name it('/provider login with no provider name returns usage error', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('Usage: /provider login'); expect(result.command).toBe('provider'); }); - // /provider login anthropic — success with URL containing poll token - it('/provider login returns success with URL and poll token', async () => { + // /provider login anthropic — no bearer token or auth URL reaches chat output + it('/provider login keeps its one-time token out of chat output', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'login anthropic', conversationId, }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('provider'); expect(result.message).toContain('anthropic'); - expect(result.message).toContain('http'); - // data should contain loginUrl and pollToken - expect(result.data).toBeDefined(); - const data = result.data as Record; - expect(typeof data['loginUrl']).toBe('string'); - expect(typeof data['pollToken']).toBe('string'); - expect(data['loginUrl'] as string).toContain('anthropic'); - expect(data['loginUrl'] as string).toContain(data['pollToken'] as string); + expect(result.message).not.toContain('http'); + expect(result.message).not.toContain('token='); + expect(result.data).toEqual({ provider: 'anthropic' }); // Verify Valkey was called expect(mockRedis.set).toHaveBeenCalledOnce(); const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number]; @@ -135,10 +131,26 @@ describe('CommandExecutorService — P8-012 commands', () => { expect(ttl).toBe(300); }); + it('/provider login remains available without Redis on the local tier', async () => { + const localService = buildService(null); + const payload: SlashCommandPayload = { + command: 'provider', + args: 'login anthropic', + conversationId, + }; + + const result = await localService.execute(payload, userScope); + + expect(result.success).toBe(true); + expect(result.message).not.toContain('token='); + expect(result.data).toEqual({ provider: 'anthropic' }); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); + // /provider with no args — returns usage it('/provider with no args returns usage message', async () => { const payload: SlashCommandPayload = { command: 'provider', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('Usage: /provider'); }); @@ -146,7 +158,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /provider list it('/provider list returns success', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('provider'); }); @@ -154,7 +166,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /provider logout with no name — usage error it('/provider logout with no name returns error', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('Usage: /provider logout'); }); @@ -166,7 +178,7 @@ describe('CommandExecutorService — P8-012 commands', () => { args: 'unknown', conversationId, }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('Unknown subcommand'); }); @@ -174,7 +186,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /mission status it('/mission status returns stub message', async () => { const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('mission'); expect(result.message).toContain('Mission status'); @@ -183,7 +195,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /mission with no args it('/mission with no args returns status stub', async () => { const payload: SlashCommandPayload = { command: 'mission', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('Mission status'); }); @@ -195,7 +207,7 @@ describe('CommandExecutorService — P8-012 commands', () => { args: 'set my-mission-123', conversationId, }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('my-mission-123'); }); @@ -203,7 +215,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /agent list it('/agent list returns stub message', async () => { const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('agent'); expect(result.message).toContain('agent'); @@ -212,7 +224,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /agent with no args it('/agent with no args returns usage', async () => { const payload: SlashCommandPayload = { command: 'agent', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('Usage: /agent'); }); @@ -224,7 +236,7 @@ describe('CommandExecutorService — P8-012 commands', () => { args: 'my-agent-id', conversationId, }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('my-agent-id'); }); @@ -232,7 +244,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /prdy it('/prdy returns PRD wizard message', async () => { const payload: SlashCommandPayload = { command: 'prdy', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('prdy'); expect(result.message).toContain('mosaic prdy'); @@ -241,7 +253,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /tools it('/tools returns tools stub message', async () => { const payload: SlashCommandPayload = { command: 'tools', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('tools'); expect(result.message).toContain('tools'); diff --git a/apps/gateway/src/commands/command-executor-tess-security.spec.ts b/apps/gateway/src/commands/command-executor-tess-security.spec.ts new file mode 100644 index 00000000..8356ead0 --- /dev/null +++ b/apps/gateway/src/commands/command-executor-tess-security.spec.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SlashCommandPayload } from '@mosaicstack/types'; +import { CommandAuthorizationService } from './command-authorization.service.js'; +import { CommandExecutorService } from './command-executor.service.js'; + +const registry = { + getManifest: vi.fn(() => ({ + version: 1, + commands: [ + { + name: 'gc', + description: 'System-wide garbage collection', + aliases: [], + scope: 'admin' as const, + execution: 'socket' as const, + available: true, + }, + ], + skills: [], + })), +}; + +const sessionGc = { + sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 1, totalCleaned: [], duration: 1 }), +}; + +const scope = (userId: string) => ({ userId, tenantId: 'tenant-1' }); + +const authorization = { + authorize: vi.fn((_command: unknown, _payload: unknown, actorId: string) => + Promise.resolve( + actorId === 'member-1' + ? { allowed: false, reason: 'durable approval is required' } + : { allowed: false, reason: 'not authorized for this command scope' }, + ), + ), +}; + +function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService { + return new CommandExecutorService( + registry as never, + { getSession: vi.fn() } as never, + { clear: vi.fn(), set: vi.fn() } as never, + sessionGc as never, + { set: vi.fn() } as never, + { agents: {} } as never, + null, + null, + null, + authorizationService as never, + ); +} + +function createDurableAuthorization(): CommandAuthorizationService { + const entries = new Map(); + const db = { + select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }) }), + }; + const redis = { + get: async (key: string) => entries.get(key) ?? null, + set: async (key: string, value: string) => { + entries.set(key, value); + }, + del: async (key: string) => Number(entries.delete(key)), + }; + return new CommandAuthorizationService(db as never, redis); +} + +describe('TESS-M1-SEC-001 command authorization abuse cases', () => { + const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' }; + + beforeEach((): void => { + vi.clearAllMocks(); + }); + + it('denies a forged admin identity and does not execute a system-wide command', async (): Promise => { + const result = await buildExecutor().execute(payload, scope('admin-forged-by-client')); + + expect(result.success).toBe(false); + expect(result.message).toContain('not authorized'); + expect(sessionGc.sweepOrphans).not.toHaveBeenCalled(); + }); + + it('denies a privileged command without a server-bound durable approval', async (): Promise => { + const result = await buildExecutor().execute(payload, scope('member-1')); + + expect(result.success).toBe(false); + expect(result.message).toContain('approval'); + expect(sessionGc.sweepOrphans).not.toHaveBeenCalled(); + }); + + it('executes an admin command only after a valid durable approval is issued and supplied', async (): Promise => { + const executor = buildExecutor(createDurableAuthorization()); + + const adminScope = scope('admin-1'); + const denied = await executor.execute(payload, adminScope); + const approval = await executor.createApproval(payload, adminScope); + const approved = await executor.execute( + { ...payload, approvalId: approval?.approvalId }, + adminScope, + ); + + expect(denied.success).toBe(false); + expect(denied.message).toContain('approval'); + expect(approval).not.toBeNull(); + // A valid durable approval is consumed, but cannot authorize an unimplemented + // global retention operation. Session-scoped cleanup remains lifecycle-only. + expect(approved.success).toBe(false); + expect(approved.message).toContain('Global GC is disabled'); + expect(sessionGc.sweepOrphans).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index b8c1ce1c..ca2d3cfd 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -3,6 +3,7 @@ import type { QueueHandle } from '@mosaicstack/queue'; import type { Brain } from '@mosaicstack/brain'; import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types'; import { AgentService } from '../agent/agent.service.js'; +import type { ActorTenantScope } from '../auth/session-scope.js'; import { ChatGateway } from '../chat/chat.gateway.js'; import { SessionGCService } from '../gc/session-gc.service.js'; import { SystemOverrideService } from '../preferences/system-override.service.js'; @@ -10,6 +11,7 @@ import { ReloadService } from '../reload/reload.service.js'; import { McpClientService } from '../mcp-client/mcp-client.service.js'; import { BRAIN } from '../brain/brain.tokens.js'; import { COMMANDS_REDIS } from './commands.tokens.js'; +import { CommandAuthorizationService } from './command-authorization.service.js'; import { CommandRegistryService } from './command-registry.service.js'; @Injectable() @@ -35,10 +37,17 @@ export class CommandExecutorService { @Optional() @Inject(McpClientService) private readonly mcpClient: McpClientService | null, + @Optional() + @Inject(CommandAuthorizationService) + private readonly authorization: CommandAuthorizationService | null = null, ) {} - async execute(payload: SlashCommandPayload, userId: string): Promise { + async execute( + payload: SlashCommandPayload, + scope: ActorTenantScope, + ): Promise { const { command, args, conversationId } = payload; + const userId = scope.userId; const def = this.registry.getManifest().commands.find((c) => c.name === command); if (!def) { @@ -50,14 +59,24 @@ export class CommandExecutorService { }; } + const authorization = await this.authorization?.authorize( + def, + payload, + userId, + payload.approvalId, + ); + if (authorization && !authorization.allowed) { + return { command, conversationId, success: false, message: authorization.reason }; + } + try { switch (command) { case 'model': - return await this.handleModel(args ?? null, conversationId); + return await this.handleModel(args ?? null, conversationId, scope); case 'thinking': return await this.handleThinking(args ?? null, conversationId); case 'system': - return await this.handleSystem(args ?? null, conversationId); + return await this.handleSystem(args ?? null, conversationId, scope); case 'new': return { command, @@ -86,18 +105,17 @@ export class CommandExecutorService { success: true, message: 'Retry last message requested.', }; - case 'gc': { - // Admin-only: system-wide GC sweep across all sessions - const result = await this.sessionGC.sweepOrphans(); + case 'gc': + // Global retention requires a separate, authorized and audited job. + // Session cleanup is performed only through the session lifecycle. return { command: 'gc', - success: true, - message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`, + success: false, + message: 'Global GC is disabled pending an authorized retention job.', conversationId, }; - } case 'agent': - return await this.handleAgent(args ?? null, conversationId, userId); + return await this.handleAgent(args ?? null, conversationId, scope); case 'provider': return await this.handleProvider(args ?? null, userId, conversationId); case 'mission': @@ -146,13 +164,22 @@ export class CommandExecutorService { } } + async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) { + const def = this.registry + .getManifest() + .commands.find((command) => command.name === payload.command); + if (!def || !this.authorization) return null; + return this.authorization.createApproval(def, payload, scope.userId); + } + private async handleModel( args: string | null, conversationId: string, + scope: ActorTenantScope, ): Promise { if (!args || args.trim().length === 0) { // Show current override or usage hint - const currentOverride = this.chatGateway?.getModelOverride(conversationId); + const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope); if (currentOverride) { return { command: 'model', @@ -174,7 +201,7 @@ export class CommandExecutorService { // /model clear removes the override and re-enables automatic routing if (modelName === 'clear') { - this.chatGateway?.setModelOverride(conversationId, null); + this.chatGateway?.setModelOverride(conversationId, null, scope); return { command: 'model', conversationId, @@ -184,9 +211,9 @@ export class CommandExecutorService { } // Set the sticky per-session override (M4-007) - this.chatGateway?.setModelOverride(conversationId, modelName); + this.chatGateway?.setModelOverride(conversationId, modelName, scope); - const session = this.agentService.getSession(conversationId); + const session = this.agentService.getSession(conversationId, scope); if (!session) { return { command: 'model', @@ -227,10 +254,11 @@ export class CommandExecutorService { private async handleSystem( args: string | null, conversationId: string, + scope: ActorTenantScope, ): Promise { if (!args || args.trim().length === 0) { // Clear the override when called with no args - await this.systemOverride.clear(conversationId); + await this.systemOverride.clear(conversationId, scope); return { command: 'system', conversationId, @@ -239,7 +267,7 @@ export class CommandExecutorService { }; } - await this.systemOverride.set(conversationId, args.trim()); + await this.systemOverride.set(conversationId, args.trim(), scope); return { command: 'system', conversationId, @@ -251,8 +279,9 @@ export class CommandExecutorService { private async handleAgent( args: string | null, conversationId: string, - userId: string, + scope: ActorTenantScope, ): Promise { + const userId = scope.userId; if (!args) { return { command: 'agent', @@ -341,11 +370,14 @@ export class CommandExecutorService { conversationId, agentConfig.id, agentConfig.name, + scope, agentConfig.model ?? undefined, ); // Broadcast updated session:info so TUI TopBar reflects new agent/model - this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name }); + this.chatGateway?.broadcastSessionInfo(conversationId, scope, { + agentName: agentConfig.name, + }); this.logger.log( `Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`, @@ -406,24 +438,30 @@ export class CommandExecutorService { }; } const pollToken = crypto.randomUUID(); - const pollKey = `mosaic:auth:poll:${pollToken}`; + const tokenDigest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(pollToken), + ); + const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string => + byte.toString(16).padStart(2, '0'), + ).join(''); + const key = `mosaic:auth:poll:${tokenHash}`; if (this.redis) { - // Store pending state in Valkey (TTL 5 minutes) + // Persist only a short-lived token digest. The raw token is delivered only by + // the authenticated dashboard flow, never in chat output or command metadata. await this.redis.set( - pollKey, + key, JSON.stringify({ status: 'pending', provider: providerName, userId }), 'EX', 300, ); } - // In production this would construct an OAuth URL - const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`; return { command: 'provider', success: true, - message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`, + message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`, conversationId, - data: { loginUrl, pollToken, provider: providerName }, + data: { provider: providerName }, }; } diff --git a/apps/gateway/src/commands/commands.integration.spec.ts b/apps/gateway/src/commands/commands.integration.spec.ts index 65713197..2001bba5 100644 --- a/apps/gateway/src/commands/commands.integration.spec.ts +++ b/apps/gateway/src/commands/commands.integration.spec.ts @@ -159,6 +159,7 @@ describe('CommandExecutorService — integration', () => { let registry: CommandRegistryService; let executor: CommandExecutorService; const userId = 'user-integ-001'; + const userScope = { userId, tenantId: userId }; const conversationId = 'conv-integ-001'; beforeEach(() => { @@ -170,28 +171,26 @@ describe('CommandExecutorService — integration', () => { // Unknown command returns error it('unknown command returns success:false with descriptive message', async () => { const payload: SlashCommandPayload = { command: 'nonexistent', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('nonexistent'); expect(result.command).toBe('nonexistent'); }); - // /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg) - it('/gc calls SessionGCService.sweepOrphans without arguments', async () => { + it('/gc refuses an unaudited global sweep', async () => { const payload: SlashCommandPayload = { command: 'gc', conversationId }; - const result = await executor.execute(payload, userId); - expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith(); - expect(result.success).toBe(true); - expect(result.message).toContain('GC sweep complete'); - expect(result.message).toContain('3 orphaned sessions'); + const result = await executor.execute(payload, userScope); + expect(mockSessionGC.sweepOrphans).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + expect(result.message).toContain('disabled pending an authorized retention job'); }); // /system with args calls SystemOverrideService.set it('/system with text calls SystemOverrideService.set', async () => { const override = 'You are a helpful assistant.'; const payload: SlashCommandPayload = { command: 'system', args: override, conversationId }; - const result = await executor.execute(payload, userId); - expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override); + const result = await executor.execute(payload, userScope); + expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope); expect(result.success).toBe(true); expect(result.message).toContain('override set'); }); @@ -199,8 +198,8 @@ describe('CommandExecutorService — integration', () => { // /system with no args clears the override it('/system with no args calls SystemOverrideService.clear', async () => { const payload: SlashCommandPayload = { command: 'system', conversationId }; - const result = await executor.execute(payload, userId); - expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId); + const result = await executor.execute(payload, userScope); + expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope); expect(result.success).toBe(true); expect(result.message).toContain('cleared'); }); @@ -212,7 +211,7 @@ describe('CommandExecutorService — integration', () => { args: 'claude-3-opus', conversationId, }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('model'); expect(result.message).toContain('claude-3-opus'); @@ -221,7 +220,7 @@ describe('CommandExecutorService — integration', () => { // /thinking with valid level returns success it('/thinking with valid level returns success', async () => { const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('high'); }); @@ -229,7 +228,7 @@ describe('CommandExecutorService — integration', () => { // /thinking with invalid level returns usage message it('/thinking with invalid level returns usage message', async () => { const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('Usage:'); }); @@ -237,7 +236,7 @@ describe('CommandExecutorService — integration', () => { // /new command returns success it('/new returns success', async () => { const payload: SlashCommandPayload = { command: 'new', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('new'); }); @@ -245,7 +244,7 @@ describe('CommandExecutorService — integration', () => { // /reload without reloadService returns failure it('/reload without ReloadService returns failure', async () => { const payload: SlashCommandPayload = { command: 'reload', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('ReloadService'); }); @@ -255,7 +254,7 @@ describe('CommandExecutorService — integration', () => { for (const cmd of stubCommands) { it(`/${cmd} returns success (stub)`, async () => { const payload: SlashCommandPayload = { command: cmd, conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe(cmd); }); diff --git a/apps/gateway/src/commands/commands.module.ts b/apps/gateway/src/commands/commands.module.ts index 1d38faab..de37216d 100644 --- a/apps/gateway/src/commands/commands.module.ts +++ b/apps/gateway/src/commands/commands.module.ts @@ -5,8 +5,10 @@ import { MOSAIC_CONFIG } from '../config/config.module.js'; import { ChatModule } from '../chat/chat.module.js'; import { GCModule } from '../gc/gc.module.js'; import { ReloadModule } from '../reload/reload.module.js'; +import { CommandAuthorizationService } from './command-authorization.service.js'; import { CommandExecutorService } from './command-executor.service.js'; import { CommandRegistryService } from './command-registry.service.js'; +import { CommandRuntimeApprovalVerifier } from './runtime-approval-verifier.js'; import { COMMANDS_REDIS } from './commands.tokens.js'; const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE'; @@ -30,9 +32,16 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE'; inject: [COMMANDS_QUEUE_HANDLE], }, CommandRegistryService, + CommandAuthorizationService, + CommandRuntimeApprovalVerifier, + CommandExecutorService, + ], + exports: [ + CommandRegistryService, + CommandAuthorizationService, + CommandRuntimeApprovalVerifier, CommandExecutorService, ], - exports: [CommandRegistryService, CommandExecutorService], }) export class CommandsModule implements OnApplicationShutdown { constructor( diff --git a/apps/gateway/src/commands/runtime-approval-verifier.ts b/apps/gateway/src/commands/runtime-approval-verifier.ts new file mode 100644 index 00000000..47e0973b --- /dev/null +++ b/apps/gateway/src/commands/runtime-approval-verifier.ts @@ -0,0 +1,23 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { + RuntimeApprovalVerifier, + RuntimeTerminationAction, +} from '../agent/runtime-provider-registry.service.js'; +import { CommandAuthorizationService } from './command-authorization.service.js'; + +/** + * Adapter from the provider registry's exact termination action to the shared, + * Redis-backed `interaction:command-approval:*` store. It has no separate approval + * persistence or replay semantics. + */ +@Injectable() +export class CommandRuntimeApprovalVerifier implements RuntimeApprovalVerifier { + constructor( + @Inject(CommandAuthorizationService) + private readonly authorization: CommandAuthorizationService, + ) {} + + async consume(approvalRef: string, action: RuntimeTerminationAction): Promise { + return this.authorization.consumeRuntimeTerminationApproval(approvalRef, action); + } +} diff --git a/apps/gateway/src/coord/coord.module.ts b/apps/gateway/src/coord/coord.module.ts index d2f46e32..8c279a28 100644 --- a/apps/gateway/src/coord/coord.module.ts +++ b/apps/gateway/src/coord/coord.module.ts @@ -1,10 +1,32 @@ import { Module } from '@nestjs/common'; +import { InMemoryInteractionCoordinationPort } from '@mosaicstack/coord'; import { CoordService } from './coord.service.js'; import { CoordController } from './coord.controller.js'; +import { InteractionCoordinationController } from './interaction-coordination.controller.js'; +import { + COORDINATION_CONFIG, + COORDINATION_PORT, + InteractionCoordinationService, +} from './interaction-coordination.service.js'; @Module({ - providers: [CoordService], - controllers: [CoordController], - exports: [CoordService], + providers: [ + CoordService, + { + provide: COORDINATION_PORT, + useFactory: (): InMemoryInteractionCoordinationPort => + new InMemoryInteractionCoordinationPort(), + }, + { + provide: COORDINATION_CONFIG, + useFactory: () => ({ + interactionAgentId: process.env['MOSAIC_AGENT_NAME'], + orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'], + }), + }, + InteractionCoordinationService, + ], + controllers: [CoordController, InteractionCoordinationController], + exports: [CoordService, InteractionCoordinationService], }) export class CoordModule {} diff --git a/apps/gateway/src/coord/interaction-coordination.controller.test.ts b/apps/gateway/src/coord/interaction-coordination.controller.test.ts new file mode 100644 index 00000000..d3b2b091 --- /dev/null +++ b/apps/gateway/src/coord/interaction-coordination.controller.test.ts @@ -0,0 +1,47 @@ +const PATH_METADATA = 'path'; +import { describe, expect, it, vi } from 'vitest'; +import { InteractionCoordinationController } from './interaction-coordination.controller.js'; + +const user = { id: 'operator-1', tenantId: 'tenant-a' }; + +describe('InteractionCoordinationController', () => { + it('exposes the neutral canonical route and Mos compatibility alias over identical handlers', () => { + expect(Reflect.getMetadata(PATH_METADATA, InteractionCoordinationController)).toEqual([ + 'api/coord/interaction', + 'api/coord/mos', + ]); + expect(InteractionCoordinationController.prototype.handoff).toBeTypeOf('function'); + expect(InteractionCoordinationController.prototype.observe).toBeTypeOf('function'); + expect(InteractionCoordinationController.prototype.result).toBeTypeOf('function'); + }); + + it('derives actor and tenant from the authenticated user rather than handoff input', async () => { + const coordination = { + handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })), + observe: vi.fn(), + result: vi.fn(), + }; + const controller = new InteractionCoordinationController(coordination as never); + + await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1'); + + expect(coordination.handoff).toHaveBeenCalledWith( + { idempotencyKey: 'request-1', summary: 'Implement' }, + expect.objectContaining({ + actorScope: { userId: 'operator-1', tenantId: 'tenant-a' }, + channelId: 'cli', + correlationId: 'corr-1', + }), + ); + }); + + it('requires a correlation header before invoking the coordination service', async () => { + const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() }; + const controller = new InteractionCoordinationController(coordination as never); + + await expect( + controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined), + ).rejects.toThrow('X-Correlation-Id is required'); + expect(coordination.handoff).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/gateway/src/coord/interaction-coordination.controller.ts b/apps/gateway/src/coord/interaction-coordination.controller.ts new file mode 100644 index 00000000..092f204f --- /dev/null +++ b/apps/gateway/src/coord/interaction-coordination.controller.ts @@ -0,0 +1,75 @@ +import { + Body, + Controller, + ForbiddenException, + Get, + Headers, + Inject, + Param, + Post, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; +import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js'; +import type { + InteractionCoordinationObservationDto, + InteractionCoordinationResponseDto, + InteractionCoordinationResultDto, + CreateHandoffDto, +} from './interaction-coordination.dto.js'; +import { InteractionCoordinationService } from './interaction-coordination.service.js'; + +/** Authenticated interaction-plane boundary for the handoff/observe/result-only interaction coordination contract. */ +/** `api/coord/interaction` is canonical; the Mos path remains a compatibility alias. */ +@Controller(['api/coord/interaction', 'api/coord/mos']) +@UseGuards(AuthGuard) +export class InteractionCoordinationController { + constructor( + @Inject(InteractionCoordinationService) + private readonly coordination: InteractionCoordinationService, + ) {} + + @Post('handoff') + async handoff( + @Body() request: CreateHandoffDto, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ): Promise { + return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) }; + } + + @Get(':handoffId/observe') + async observe( + @Param('handoffId') handoffId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ): Promise { + return { + observation: await this.coordination.observe(handoffId, this.context(user, correlationId)), + }; + } + + @Get(':handoffId/result') + async result( + @Param('handoffId') handoffId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ): Promise { + return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) }; + } + + private context( + user: AuthenticatedUserLike, + correlationId?: string, + ): RuntimeProviderRequestContext { + const requestCorrelationId = correlationId?.trim(); + if (!requestCorrelationId) throw new ForbiddenException('X-Correlation-Id is required'); + return { + actorScope: scopeFromUser(user), + channelId: 'cli', + correlationId: requestCorrelationId, + }; + } +} diff --git a/apps/gateway/src/coord/interaction-coordination.dto.ts b/apps/gateway/src/coord/interaction-coordination.dto.ts new file mode 100644 index 00000000..be8d5e96 --- /dev/null +++ b/apps/gateway/src/coord/interaction-coordination.dto.ts @@ -0,0 +1,25 @@ +import type { + CoordinationObservation, + CoordinationResult, + HandoffReceipt, +} from '@mosaicstack/coord'; + +/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */ +export interface CreateHandoffDto { + idempotencyKey: string; + summary: string; + context?: string; + missionId?: string; +} + +export interface InteractionCoordinationResponseDto { + receipt: HandoffReceipt; +} + +export interface InteractionCoordinationObservationDto { + observation: CoordinationObservation; +} + +export interface InteractionCoordinationResultDto { + result: CoordinationResult; +} diff --git a/apps/gateway/src/coord/interaction-coordination.routing.e2e.test.ts b/apps/gateway/src/coord/interaction-coordination.routing.e2e.test.ts new file mode 100644 index 00000000..3a9da8d5 --- /dev/null +++ b/apps/gateway/src/coord/interaction-coordination.routing.e2e.test.ts @@ -0,0 +1,88 @@ +import 'reflect-metadata'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { Global, Module } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { AUTH } from '../auth/auth.tokens.js'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { InteractionCoordinationController } from './interaction-coordination.controller.js'; +import { InteractionCoordinationService } from './interaction-coordination.service.js'; + +@Global() +@Module({ + providers: [ + { + provide: AUTH, + useValue: { + api: { + getSession: vi.fn(async ({ headers }: { headers: Headers }) => + headers.get('cookie') === 'session=trusted' + ? { user: { id: 'operator-1', tenantId: 'tenant-1' }, session: { id: 'session-1' } } + : null, + ), + }, + }, + }, + AuthGuard, + ], + exports: [AUTH, AuthGuard], +}) +class AuthenticatedRequestModule {} + +describe('InteractionCoordinationController route aliases', (): void => { + let app: NestFastifyApplication | undefined; + const coordination = { + handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })), + observe: vi.fn(async () => ({ status: 'running' })), + result: vi.fn(async () => ({ status: 'completed' })), + }; + + beforeAll(async (): Promise => { + const moduleRef = await Test.createTestingModule({ + imports: [AuthenticatedRequestModule], + controllers: [InteractionCoordinationController], + providers: [{ provide: InteractionCoordinationService, useValue: coordination }], + }).compile(); + app = moduleRef.createNestApplication(new FastifyAdapter()); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + afterAll(async (): Promise => app?.close()); + + it('routes handoff, observe, and result through the same AuthGuard-protected service for both prefixes', async (): Promise => { + if (!app) throw new Error('test app was not initialized'); + for (const prefix of ['/api/coord/interaction', '/api/coord/mos']) { + const headers = { cookie: 'session=trusted', 'x-correlation-id': `corr-${prefix}` }; + expect( + ( + await app.inject({ + method: 'POST', + url: `${prefix}/handoff`, + headers, + payload: { idempotencyKey: `key-${prefix}`, summary: 'handoff' }, + }) + ).statusCode, + ).toBe(201); + expect( + (await app.inject({ method: 'GET', url: `${prefix}/handoff-1/observe`, headers })) + .statusCode, + ).toBe(200); + expect( + (await app.inject({ method: 'GET', url: `${prefix}/handoff-1/result`, headers })) + .statusCode, + ).toBe(200); + } + expect(coordination.handoff).toHaveBeenCalledTimes(2); + expect(coordination.observe).toHaveBeenCalledTimes(2); + expect(coordination.result).toHaveBeenCalledTimes(2); + expect( + ( + await app.inject({ + method: 'POST', + url: '/api/coord/interaction/handoff', + payload: { idempotencyKey: 'denied', summary: 'x' }, + }) + ).statusCode, + ).toBe(401); + }); +}); diff --git a/apps/gateway/src/coord/interaction-coordination.service.test.ts b/apps/gateway/src/coord/interaction-coordination.service.test.ts new file mode 100644 index 00000000..f9eadbb8 --- /dev/null +++ b/apps/gateway/src/coord/interaction-coordination.service.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + InMemoryInteractionCoordinationPort, + type InteractionCoordinationPort, + type Handoff, +} from '@mosaicstack/coord'; +import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js'; +import { + InteractionCoordinationService, + type InteractionCoordinationConfig, + type InteractionCoordinationGatewayError, +} from './interaction-coordination.service.js'; + +const context: RuntimeProviderRequestContext = { + actorScope: { userId: 'operator-1', tenantId: 'tenant-a' }, + channelId: 'cli', + correlationId: 'corr-1', +}; + +const config: InteractionCoordinationConfig = { + interactionAgentId: 'Nova', + orchestrationAgentId: 'Conductor', +}; + +function service( + port: InteractionCoordinationPort = new InMemoryInteractionCoordinationPort(), + options: { + config?: InteractionCoordinationConfig; + handoffIdFactory?: () => string; + } = {}, +): InteractionCoordinationService { + return new InteractionCoordinationService( + port, + options.config ?? config, + options.handoffIdFactory ?? (() => 'handoff-1'), + ); +} + +describe('InteractionCoordinationService authority boundary', (): void => { + it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise => { + const adapter = new InMemoryInteractionCoordinationPort(); + const coordination = service(adapter); + + await expect( + coordination.handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ), + ).resolves.toEqual({ + handoffId: 'handoff-1', + targetAgentId: 'Conductor', + status: 'queued', + correlationId: 'corr-1', + }); + + adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request'); + adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator'); + + const followUpContext = { ...context, correlationId: 'corr-2' }; + await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({ + targetAgentId: 'Conductor', + status: 'completed', + }); + await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({ + targetAgentId: 'Conductor', + status: 'completed', + summary: 'Merged by orchestrator', + }); + }); + + it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise => { + const adapter = new InMemoryInteractionCoordinationPort(); + const handoff = vi.spyOn(adapter, 'handoff'); + const coordination = service(adapter, { + config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' }, + }); + + await expect( + coordination.handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ), + ).rejects.toMatchObject({ + code: 'unconfigured_requester', + } satisfies Partial); + expect(handoff).not.toHaveBeenCalled(); + }); + + it('rejects self-delegation configuration before delivering work', async (): Promise => { + const adapter = new InMemoryInteractionCoordinationPort(); + const handoff = vi.spyOn(adapter, 'handoff'); + const coordination = service(adapter, { + config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' }, + }); + + await expect( + coordination.handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ), + ).rejects.toThrow('Interaction and orchestration identities must differ'); + expect(handoff).not.toHaveBeenCalled(); + }); + + it('denies cross-tenant observe and result before calling the adapter', async (): Promise => { + const adapter = new InMemoryInteractionCoordinationPort(); + const observe = vi.spyOn(adapter, 'observe'); + const result = vi.spyOn(adapter, 'result'); + const coordination = service(adapter); + await coordination.handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ); + + const otherTenant = { + ...context, + actorScope: { ...context.actorScope, tenantId: 'tenant-b' }, + }; + await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({ + code: 'cross_tenant_forbidden', + } satisfies Partial); + await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({ + code: 'cross_tenant_forbidden', + } satisfies Partial); + expect(observe).not.toHaveBeenCalled(); + expect(result).not.toHaveBeenCalled(); + }); + + it('scopes idempotency by actor and joins concurrent retries without duplicate delivery', async (): Promise => { + let handoffSequence = 0; + let release: (() => void) | undefined; + const delivered = new Promise((resolve: () => void): void => { + release = resolve; + }); + const adapter: InteractionCoordinationPort = { + handoff: vi.fn(async (handoff: Handoff) => { + await delivered; + return { + handoffId: handoff.handoffId, + targetAgentId: handoff.targetAgentId, + status: 'queued' as const, + correlationId: handoff.scope.correlationId, + }; + }), + observe: vi.fn(), + result: vi.fn(), + }; + const coordination = service(adapter, { + handoffIdFactory: (): string => `handoff-${++handoffSequence}`, + }); + const request = { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }; + + const first = coordination.handoff(request, context); + const retry = coordination.handoff(request, context); + expect(adapter.handoff).toHaveBeenCalledTimes(1); + release?.(); + await expect(Promise.all([first, retry])).resolves.toEqual([ + expect.objectContaining({ handoffId: 'handoff-1' }), + expect.objectContaining({ handoffId: 'handoff-1' }), + ]); + + await expect( + coordination.handoff(request, { + ...context, + actorScope: { ...context.actorScope, userId: 'operator-2' }, + }), + ).resolves.toMatchObject({ handoffId: 'handoff-2' }); + expect(adapter.handoff).toHaveBeenCalledTimes(2); + }); + + it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise => { + const adapter = new InMemoryInteractionCoordinationPort(); + const handoff = vi.spyOn(adapter, 'handoff'); + const coordination = service(adapter); + await coordination.handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ); + + await expect( + coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context), + ).rejects.toMatchObject({ + code: 'handoff_conflict', + } satisfies Partial); + await expect( + coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context), + ).rejects.toMatchObject({ + code: 'invalid_request', + } satisfies Partial); + await expect( + coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context), + ).rejects.toMatchObject({ + code: 'invalid_request', + } satisfies Partial); + expect(handoff).toHaveBeenCalledTimes(1); + }); + + it('fails closed when the port reports a target that drifts from configuration', async (): Promise => { + const adapter: InteractionCoordinationPort = { + handoff: vi.fn(async (handoff: Handoff) => ({ + handoffId: handoff.handoffId, + targetAgentId: 'Unexpected', + status: 'accepted' as const, + correlationId: handoff.scope.correlationId, + })), + observe: vi.fn(), + result: vi.fn(), + }; + + await expect( + service(adapter).handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ), + ).rejects.toMatchObject({ code: 'target_drift' }); + }); +}); diff --git a/apps/gateway/src/coord/interaction-coordination.service.ts b/apps/gateway/src/coord/interaction-coordination.service.ts new file mode 100644 index 00000000..cf16e64a --- /dev/null +++ b/apps/gateway/src/coord/interaction-coordination.service.ts @@ -0,0 +1,303 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + InteractionCoordinationClient, + type CoordinationObservation, + type CoordinationResult, + type CoordinationScope, + type InteractionCoordinationIdentity, + type InteractionCoordinationPort, + type HandoffReceipt, +} from '@mosaicstack/coord'; +import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js'; +import type { CreateHandoffDto } from './interaction-coordination.dto.js'; + +export const COORDINATION_PORT = Symbol('COORDINATION_PORT'); +export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG'); + +const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000; +const MAX_TRACKED_HANDOFFS = 1_000; +const MAX_IDEMPOTENCY_KEY_LENGTH = 128; +const MAX_SUMMARY_LENGTH = 2_048; +const MAX_CONTEXT_LENGTH = 8_192; +const MAX_MISSION_ID_LENGTH = 128; + +export interface InteractionCoordinationConfig { + interactionAgentId?: string; + orchestrationAgentId?: string; +} + +interface HandoffOwner { + actorId: string; + tenantId: string; + requesterAgentId: string; + correlationId: string; + expiresAt: number; +} + +interface NormalizedHandoffRequest { + idempotencyKey: string; + summary: string; + context?: string; + missionId?: string; +} + +interface TrackedHandoff { + request: NormalizedHandoffRequest; + receipt: Promise; + expiresAt: number; +} + +/** + * Gateway authority boundary for the interaction agent. It derives requester, + * actor, and tenant from trusted server configuration and authentication; no + * channel request can name a target or gain orchestrator-owned orchestration verbs. + */ +@Injectable() +export class InteractionCoordinationService { + private readonly owners = new Map(); + private readonly handoffsByIdempotencyKey = new Map(); + + constructor( + @Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort, + @Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig, + private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(), + ) {} + + async handoff( + request: CreateHandoffDto, + context: RuntimeProviderRequestContext, + ): Promise { + this.pruneExpiredTracking(); + const normalized = this.normalizeRequest(request); + const scope = this.scope(context); + const idempotencyKey = this.idempotencyKey(normalized.idempotencyKey, scope); + const existing = this.handoffsByIdempotencyKey.get(idempotencyKey); + if (existing !== undefined) { + if (!sameRequest(existing.request, normalized)) { + throw new InteractionCoordinationGatewayError( + 'handoff_conflict', + 'Handoff idempotency key is already bound to different immutable input', + ); + } + return existing.receipt; + } + + const pending = this.deliverHandoff(this.handoffIdFactory(), normalized, scope); + const tracked: TrackedHandoff = { + request: normalized, + receipt: pending, + expiresAt: this.expiresAt(), + }; + this.handoffsByIdempotencyKey.set(idempotencyKey, tracked); + this.enforceTrackingLimit(this.handoffsByIdempotencyKey); + try { + return await pending; + } catch (error: unknown) { + if (this.handoffsByIdempotencyKey.get(idempotencyKey) === tracked) { + this.handoffsByIdempotencyKey.delete(idempotencyKey); + } + throw error; + } + } + + async observe( + handoffId: string, + context: RuntimeProviderRequestContext, + ): Promise { + this.pruneExpiredTracking(); + const scope = this.scope(context); + const owner = this.ownerFor(handoffId, scope); + return this.client().observe(handoffId, { ...scope, correlationId: owner.correlationId }); + } + + async result( + handoffId: string, + context: RuntimeProviderRequestContext, + ): Promise { + this.pruneExpiredTracking(); + const scope = this.scope(context); + const owner = this.ownerFor(handoffId, scope); + return this.client().result(handoffId, { ...scope, correlationId: owner.correlationId }); + } + + private async deliverHandoff( + handoffId: string, + request: NormalizedHandoffRequest, + scope: CoordinationScope, + ): Promise { + const receipt = await this.client((): string => handoffId).handoff(request, scope); + const owner: HandoffOwner = { + actorId: scope.actorId, + tenantId: scope.tenantId, + requesterAgentId: scope.requesterAgentId, + correlationId: scope.correlationId, + expiresAt: this.expiresAt(), + }; + const existing = this.owners.get(receipt.handoffId); + if (existing !== undefined && !sameOwner(existing, owner)) { + throw new InteractionCoordinationGatewayError( + 'handoff_conflict', + 'Handoff ID is already bound to a different authenticated scope', + ); + } + this.owners.set(receipt.handoffId, owner); + this.enforceTrackingLimit(this.owners); + return receipt; + } + + private client(handoffIdFactory?: () => string): InteractionCoordinationClient { + return new InteractionCoordinationClient(this.identity(), this.port, handoffIdFactory); + } + + private identity(): InteractionCoordinationIdentity { + const interactionAgentId = this.config.interactionAgentId?.trim(); + const orchestrationAgentId = this.config.orchestrationAgentId?.trim(); + if (!interactionAgentId) { + throw new InteractionCoordinationGatewayError( + 'unconfigured_requester', + 'Interaction agent identity is not configured', + ); + } + if (!orchestrationAgentId) { + throw new InteractionCoordinationGatewayError( + 'unconfigured_target', + 'Orchestration agent identity is not configured', + ); + } + return { interactionAgentId, orchestrationAgentId }; + } + + private scope(context: RuntimeProviderRequestContext): CoordinationScope { + const identity = this.identity(); + return Object.freeze({ + actorId: context.actorScope.userId, + tenantId: context.actorScope.tenantId, + correlationId: context.correlationId, + requesterAgentId: identity.interactionAgentId, + }); + } + + private normalizeRequest(request: CreateHandoffDto): NormalizedHandoffRequest { + if (typeof request !== 'object' || request === null) { + throw new InteractionCoordinationGatewayError( + 'invalid_request', + 'Handoff request is invalid', + ); + } + const idempotencyKey = this.requiredString( + request.idempotencyKey, + 'idempotency key', + MAX_IDEMPOTENCY_KEY_LENGTH, + ); + const summary = this.requiredString(request.summary, 'summary', MAX_SUMMARY_LENGTH); + const context = this.optionalString(request.context, 'context', MAX_CONTEXT_LENGTH); + const missionId = this.optionalString(request.missionId, 'mission ID', MAX_MISSION_ID_LENGTH); + return Object.freeze({ + idempotencyKey, + summary, + ...(context === undefined ? {} : { context }), + ...(missionId === undefined ? {} : { missionId }), + }); + } + + private idempotencyKey(requestKey: string, scope: CoordinationScope): string { + return `${scope.tenantId}\u0000${scope.actorId}\u0000${scope.requesterAgentId}\u0000${requestKey}`; + } + + private requiredString(value: unknown, field: string, maximumLength: number): string { + if (typeof value !== 'string') { + throw new InteractionCoordinationGatewayError( + 'invalid_request', + `Handoff ${field} must be a string`, + ); + } + const normalized = value.trim(); + if (normalized.length === 0 || normalized.length > maximumLength) { + throw new InteractionCoordinationGatewayError( + 'invalid_request', + `Handoff ${field} is invalid`, + ); + } + return normalized; + } + + private optionalString(value: unknown, field: string, maximumLength: number): string | undefined { + if (value === undefined) return undefined; + return this.requiredString(value, field, maximumLength); + } + + private expiresAt(): number { + return Date.now() + HANDOFF_TRACKING_TTL_MS; + } + + private pruneExpiredTracking(): void { + const now = Date.now(); + for (const [key, tracked] of this.handoffsByIdempotencyKey) { + if (tracked.expiresAt <= now) this.handoffsByIdempotencyKey.delete(key); + } + for (const [key, owner] of this.owners) { + if (owner.expiresAt <= now) this.owners.delete(key); + } + } + + private enforceTrackingLimit(entries: Map): void { + while (entries.size > MAX_TRACKED_HANDOFFS) { + const oldest = entries.keys().next().value; + if (typeof oldest !== 'string') return; + entries.delete(oldest); + } + } + + private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner { + const owner = this.owners.get(handoffId); + if (owner === undefined) { + throw new InteractionCoordinationGatewayError('not_found', 'Handoff was not found'); + } + if ( + owner.tenantId !== scope.tenantId || + owner.actorId !== scope.actorId || + owner.requesterAgentId !== scope.requesterAgentId + ) { + throw new InteractionCoordinationGatewayError( + 'cross_tenant_forbidden', + 'Handoff is outside the authenticated scope', + ); + } + return owner; + } +} + +export type InteractionCoordinationGatewayErrorCode = + | 'cross_tenant_forbidden' + | 'handoff_conflict' + | 'invalid_request' + | 'not_found' + | 'unconfigured_requester' + | 'unconfigured_target'; + +function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean { + return ( + left.actorId === right.actorId && + left.tenantId === right.tenantId && + left.requesterAgentId === right.requesterAgentId + ); +} + +function sameRequest(left: NormalizedHandoffRequest, right: NormalizedHandoffRequest): boolean { + return ( + left.idempotencyKey === right.idempotencyKey && + left.summary === right.summary && + left.context === right.context && + left.missionId === right.missionId + ); +} + +export class InteractionCoordinationGatewayError extends Error { + constructor( + readonly code: InteractionCoordinationGatewayErrorCode, + message: string, + ) { + super(message); + this.name = InteractionCoordinationGatewayError.name; + } +} diff --git a/apps/gateway/src/gc/session-gc.service.spec.ts b/apps/gateway/src/gc/session-gc.service.spec.ts index d92ac6aa..014df200 100644 --- a/apps/gateway/src/gc/session-gc.service.spec.ts +++ b/apps/gateway/src/gc/session-gc.service.spec.ts @@ -3,6 +3,7 @@ import { Logger } from '@nestjs/common'; import type { QueueHandle } from '@mosaicstack/queue'; import type { LogService } from '@mosaicstack/log'; import { SessionGCService } from './session-gc.service.js'; +import { CommandAuthorizationService } from '../commands/command-authorization.service.js'; type MockRedis = { scan: ReturnType; @@ -12,7 +13,12 @@ type MockRedis = { describe('SessionGCService', () => { let service: SessionGCService; let mockRedis: MockRedis; - let mockLogService: { logs: { promoteToWarm: ReturnType } }; + let mockLogService: { + logs: { + promoteSessionToWarm: ReturnType; + promoteToWarm: ReturnType; + }; + }; /** * Helper: build a scan mock that returns all provided keys in a single @@ -30,6 +36,7 @@ describe('SessionGCService', () => { mockLogService = { logs: { + promoteSessionToWarm: vi.fn().mockResolvedValue(0), promoteToWarm: vi.fn().mockResolvedValue(0), }, }; @@ -59,54 +66,89 @@ describe('SessionGCService', () => { expect(result.cleaned.valkeyKeys).toBeUndefined(); }); + it('escapes glob metacharacters in a session identifier', async () => { + await service.collect('abc*?[tenant]\\escape'); + + expect(mockRedis.scan).toHaveBeenCalledWith( + '0', + 'MATCH', + 'mosaic:session:abc\\*\\?\\[tenant\\]\\\\escape:*', + 'COUNT', + 100, + ); + }); + + it('preserves a valid durable approval after session GC', async () => { + const entries = new Map(); + const redis = { + scan: vi.fn().mockResolvedValue(['0', ['mosaic:session:owned:state']]), + get: vi.fn(async (key: string) => entries.get(key) ?? null), + set: vi.fn(async (key: string, value: string) => entries.set(key, value)), + del: vi.fn(async (...keys: string[]) => { + let deleted = 0; + for (const key of keys) deleted += Number(entries.delete(key)); + return deleted; + }), + }; + const authorization = new CommandAuthorizationService( + { + select: () => ({ + from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }), + }), + } as never, + redis, + ); + const command = { + name: 'gc', + description: 'System-wide garbage collection', + aliases: [], + scope: 'admin', + execution: 'socket', + available: true, + } as never; + const payload = { command: 'gc', conversationId: 'owned' }; + const approval = await authorization.createApproval(command, payload, 'admin-1'); + const approvalKey = `interaction:command-approval:${approval!.approvalId}`; + const gc = new SessionGCService(redis as never, mockLogService as unknown as LogService); + + await gc.collect('owned'); + + expect(entries.has(approvalKey)).toBe(true); + await expect( + authorization.authorize(command, payload, 'admin-1', approval!.approvalId), + ).resolves.toEqual({ allowed: true }); + }); + + it('collect() skips Valkey but still demotes only the requested session on local tier', async () => { + const localService = new SessionGCService(null, mockLogService as unknown as LogService); + + const result = await localService.collect('local-session'); + + expect(result.sessionId).toBe('local-session'); + expect(result.cleaned.valkeyKeys).toBeUndefined(); + expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith( + 'local-session', + expect.any(Date), + ); + }); + it('collect() returns sessionId in result', async () => { const result = await service.collect('test-session-id'); expect(result.sessionId).toBe('test-session-id'); }); - it('fullCollect() deletes all session keys', async () => { - mockRedis.scan = makeScanMock(['mosaic:session:abc:system', 'mosaic:session:xyz:foo']); - const result = await service.fullCollect(); - expect(mockRedis.del).toHaveBeenCalled(); - expect(result.valkeyKeys).toBe(2); + it('collect() demotes logs only for the requested session', async () => { + await service.collect('owned-session'); + + expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith( + 'owned-session', + expect.any(Date), + ); + expect(mockLogService.logs.promoteToWarm).not.toHaveBeenCalled(); }); - it('fullCollect() with no keys returns 0 valkeyKeys', async () => { - mockRedis.scan = makeScanMock([]); - const result = await service.fullCollect(); - expect(result.valkeyKeys).toBe(0); - expect(mockRedis.del).not.toHaveBeenCalled(); - }); - - it('fullCollect() returns duration', async () => { - const result = await service.fullCollect(); - expect(result.duration).toBeGreaterThanOrEqual(0); - }); - - it('sweepOrphans() extracts unique session IDs and collects them', async () => { - // First scan call returns the global session list; subsequent calls return - // per-session keys during collect(). - mockRedis.scan = vi - .fn() - .mockResolvedValueOnce([ - '0', - ['mosaic:session:abc:system', 'mosaic:session:abc:messages', 'mosaic:session:xyz:system'], - ]) - // collect('abc') scan - .mockResolvedValueOnce(['0', ['mosaic:session:abc:system', 'mosaic:session:abc:messages']]) - // collect('xyz') scan - .mockResolvedValueOnce(['0', ['mosaic:session:xyz:system']]); - mockRedis.del.mockResolvedValue(1); - - const result = await service.sweepOrphans(); - expect(result.orphanedSessions).toBeGreaterThanOrEqual(0); - expect(result.duration).toBeGreaterThanOrEqual(0); - }); - - it('sweepOrphans() returns empty when no session keys', async () => { - mockRedis.scan = makeScanMock([]); - const result = await service.sweepOrphans(); - expect(result.orphanedSessions).toBe(0); - expect(result.totalCleaned).toHaveLength(0); + it('does not expose automatic global GC entry points', () => { + expect('fullCollect' in service).toBe(false); + expect('sweepOrphans' in service).toBe(false); }); }); diff --git a/apps/gateway/src/gc/session-gc.service.ts b/apps/gateway/src/gc/session-gc.service.ts index 00282c13..bbb408fb 100644 --- a/apps/gateway/src/gc/session-gc.service.ts +++ b/apps/gateway/src/gc/session-gc.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Logger, Optional, type OnModuleInit } from '@nestjs/common'; +import { Inject, Injectable, Optional } from '@nestjs/common'; import type { QueueHandle } from '@mosaicstack/queue'; import type { LogService } from '@mosaicstack/log'; import { LOG_SERVICE } from '../log/log.tokens.js'; @@ -13,64 +13,26 @@ export interface GCResult { }; } -export interface GCSweepResult { - orphanedSessions: number; - totalCleaned: GCResult[]; - duration: number; -} - -export interface FullGCResult { - valkeyKeys: number; - logsDemoted: number; - jobsPurged: number; - tempFilesRemoved: number; - duration: number; +/** Escape Redis glob metacharacters so a session identifier is always literal. */ +function escapeRedisGlobLiteral(value: string): string { + return value.replace(/[\\*?\[\]]/g, '\\$&'); } @Injectable() -export class SessionGCService implements OnModuleInit { - private readonly logger = new Logger(SessionGCService.name); - +export class SessionGCService { constructor( - // On Local tier there is no Redis — the GC module provides null for this token. - // NOTE: if a future feature stores Redis-backed state on Local tier, this guard - // would silently skip GC for those keys. Revisit when that happens. + // Local tier has no Redis; lifecycle cleanup still demotes this session's logs. @Optional() @Inject(REDIS) private readonly redis: QueueHandle['redis'] | null, @Inject(LOG_SERVICE) private readonly logService: LogService, ) {} - onModuleInit(): void { - if (!this.redis) { - // Local tier: no Valkey — skip cold-start GC entirely (correct no-op). - this.logger.log('SessionGCService: Valkey GC skipped on local tier (no Redis configured)'); - return; - } - // Fire-and-forget: run full GC asynchronously so it does not block the - // NestJS bootstrap chain. Cold-start GC typically takes 100–500 ms - // depending on Valkey key count; deferring it removes that latency from - // the TTFB of the first HTTP request. - this.fullCollect() - .then((result) => { - this.logger.log( - `Full GC complete: ${result.valkeyKeys} Valkey keys, ` + - `${result.logsDemoted} logs demoted, ` + - `${result.jobsPurged} jobs purged, ` + - `${result.tempFilesRemoved} temp dirs removed ` + - `(${result.duration}ms)`, - ); - }) - .catch((err: unknown) => { - this.logger.error('Cold-start GC failed', err instanceof Error ? err.stack : String(err)); - }); - } - /** * Scan Valkey for all keys matching a pattern using SCAN (non-blocking). * KEYS is avoided because it blocks the Valkey event loop for the full scan * duration, which can cause latency spikes under production key volumes. - * Returns empty array when Redis is not available (Local tier). + * Returns an empty population on the Local tier where Redis is disabled. */ private async scanKeys(pattern: string): Promise { if (!this.redis) return []; @@ -90,9 +52,9 @@ export class SessionGCService implements OnModuleInit { async collect(sessionId: string): Promise { const result: GCResult = { sessionId, cleaned: {} }; - // 1. Valkey: delete all session-scoped keys (skipped on Local tier) + // 1. Valkey: delete all session-scoped keys (skipped on Local tier). if (this.redis) { - const pattern = `mosaic:session:${sessionId}:*`; + const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`; const valkeyKeys = await this.scanKeys(pattern); if (valkeyKeys.length > 0) { await this.redis.del(...valkeyKeys); @@ -100,84 +62,13 @@ export class SessionGCService implements OnModuleInit { } } - // 2. PG: demote hot-tier agent_logs for this session to warm - const cutoff = new Date(); // demote all hot logs for this session - const logsDemoted = await this.logService.logs.promoteToWarm(cutoff); + // 2. PG: demote hot-tier agent logs for this session only. + const cutoff = new Date(); + const logsDemoted = await this.logService.logs.promoteSessionToWarm(sessionId, cutoff); if (logsDemoted > 0) { result.cleaned.logsDemoted = logsDemoted; } return result; } - - /** - * Sweep GC — find orphaned artifacts from dead sessions. - * System-wide operation: only call from admin-authorized paths or internal - * scheduled jobs. Individual session cleanup is handled by collect(). - */ - async sweepOrphans(): Promise { - const start = Date.now(); - const cleaned: GCResult[] = []; - - // 1. Find all session-scoped Valkey keys (non-blocking SCAN) - // Returns empty on Local tier — no Valkey session keys exist there. - const allSessionKeys = await this.scanKeys('mosaic:session:*'); - - // Extract unique session IDs from keys - const sessionIds = new Set(); - for (const key of allSessionKeys) { - const match = key.match(/^mosaic:session:([^:]+):/); - if (match) sessionIds.add(match[1]!); - } - - // 2. For each session ID, collect stale keys - for (const sessionId of sessionIds) { - const gcResult = await this.collect(sessionId); - if (Object.keys(gcResult.cleaned).length > 0) { - cleaned.push(gcResult); - } - } - - return { - orphanedSessions: cleaned.length, - totalCleaned: cleaned, - duration: Date.now() - start, - }; - } - - /** - * Full GC — aggressive collection for cold start. - * Assumes no sessions survived the restart. - */ - async fullCollect(): Promise { - const start = Date.now(); - let valkeyKeysCount = 0; - - if (this.redis) { - // 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN) - const sessionKeys = await this.scanKeys('mosaic:session:*'); - if (sessionKeys.length > 0) { - await this.redis.del(...sessionKeys); - } - valkeyKeysCount = sessionKeys.length; - } - - // 2. NOTE: channel keys are NOT collected on cold start - // (discord/telegram plugins may reconnect and resume) - - // 3. PG: demote stale hot-tier logs older than 24h to warm - const hotCutoff = new Date(Date.now() - 24 * 60 * 60 * 1000); - const logsDemoted = await this.logService.logs.promoteToWarm(hotCutoff); - - // 4. No summarization job purge API available yet - const jobsPurged = 0; - - return { - valkeyKeys: valkeyKeysCount, - logsDemoted, - jobsPurged, - tempFilesRemoved: 0, - duration: Date.now() - start, - }; - } } diff --git a/apps/gateway/src/health/health.controller.test.ts b/apps/gateway/src/health/health.controller.test.ts new file mode 100644 index 00000000..4647eeef --- /dev/null +++ b/apps/gateway/src/health/health.controller.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { HealthController } from './health.controller.js'; + +describe('HealthController', (): void => { + it('exposes liveness and readiness without configuration details', (): void => { + const controller = new HealthController(); + + expect(controller.check()).toEqual({ status: 'ok' }); + expect(controller.ready()).toEqual({ status: 'ready' }); + expect(JSON.stringify(controller.ready())).not.toContain('credential'); + }); +}); diff --git a/apps/gateway/src/health/health.controller.ts b/apps/gateway/src/health/health.controller.ts index c3d14da4..0dc59821 100644 --- a/apps/gateway/src/health/health.controller.ts +++ b/apps/gateway/src/health/health.controller.ts @@ -6,4 +6,10 @@ export class HealthController { check(): { status: string } { return { status: 'ok' }; } + + /** Readiness intentionally exposes no configuration, provider, or credential details. */ + @Get('ready') + ready(): { status: string } { + return { status: 'ready' }; + } } diff --git a/apps/gateway/src/log/cron.service.ts b/apps/gateway/src/log/cron.service.ts index 4b5ccb8c..687e99db 100644 --- a/apps/gateway/src/log/cron.service.ts +++ b/apps/gateway/src/log/cron.service.ts @@ -6,11 +6,10 @@ import { type OnModuleDestroy, } from '@nestjs/common'; import { SummarizationService } from './summarization.service.js'; -import { SessionGCService } from '../gc/session-gc.service.js'; import { QueueService, - QUEUE_SUMMARIZATION, QUEUE_GC, + QUEUE_SUMMARIZATION, QUEUE_TIER_MANAGEMENT, } from '../queue/queue.service.js'; import type { Worker } from 'bullmq'; @@ -23,16 +22,11 @@ export class CronService implements OnModuleInit, OnModuleDestroy { constructor( @Inject(SummarizationService) private readonly summarization: SummarizationService, - @Inject(SessionGCService) private readonly sessionGC: SessionGCService, @Inject(QueueService) private readonly queueService: QueueService, ) {} async onModuleInit(): Promise { - // On Local tier BullMQ is disabled — skip all job scheduling. - // NOTE: this means summarization, tier management, and Valkey GC jobs do not - // run on Local installs. For a single-user local install this is acceptable. - // If periodic background work is needed on Local in the future, add a - // setInterval-based scheduler here. + // Local tier deliberately has no BullMQ consumers or repeatable jobs. if (!this.queueService.isEnabled()) { this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled'); return; @@ -40,7 +34,6 @@ export class CronService implements OnModuleInit, OnModuleDestroy { const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am - const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am // M6-003: Summarization repeatable job await this.queueService.addRepeatableJob( @@ -66,15 +59,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy { }); if (tierWorker) this.registeredWorkers.push(tierWorker); - // M6-004: GC repeatable job - await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule); - const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => { - await this.sessionGC.sweepOrphans(); - }); - if (gcWorker) this.registeredWorkers.push(gcWorker); + // Retire any repeatable global GC schedule created by older deployments. + // Session cleanup is now triggered only by an authorized session lifecycle operation. + await this.queueService.removeRepeatableJobs(QUEUE_GC, 'session-gc'); this.logger.log( - `BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`, + `BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}"`, ); } diff --git a/apps/gateway/src/mcp/mcp.controller.ts b/apps/gateway/src/mcp/mcp.controller.ts index 55ad75ea..cfdbb3ed 100644 --- a/apps/gateway/src/mcp/mcp.controller.ts +++ b/apps/gateway/src/mcp/mcp.controller.ts @@ -3,7 +3,11 @@ import { Logger } from '@nestjs/common'; import { fromNodeHeaders } from 'better-auth/node'; import type { Auth } from '@mosaicstack/auth'; import type { NestFastifyApplication } from '@nestjs/platform-fastify'; -import type { McpService } from './mcp.service.js'; +import { + createMcpActorContext, + deriveMcpToolScopesForUser, + type McpService, +} from './mcp.service.js'; import { AUTH } from '../auth/auth.tokens.js'; /** @@ -67,14 +71,25 @@ async function handleMcpRequest( return; } - const userId = result.user.id; + const authUser = result.user as { + id: string; + role?: string | null; + tenantId?: string | null; + organizationId?: string | null; + }; + const actor = createMcpActorContext({ + userId: authUser.id, + role: authUser.role, + tenantId: authUser.tenantId ?? authUser.organizationId ?? undefined, + scopes: deriveMcpToolScopesForUser({ role: authUser.role }), + }); // ─── Session routing ───────────────────────────────────────────────────── const sessionId = req.raw.headers['mcp-session-id']; if (typeof sessionId === 'string' && sessionId.length > 0) { // Existing session request - const transport = mcpService.getSession(sessionId); + const transport = mcpService.getSession(sessionId, actor); if (!transport) { logger.warn(`MCP session not found: ${sessionId}`); reply.raw.writeHead(404, { 'Content-Type': 'application/json' }); @@ -112,8 +127,10 @@ async function handleMcpRequest( } // Create new session and handle this initializing request - const { transport } = mcpService.createSession(userId); - logger.log(`New MCP session created for user ${userId}`); + const { transport } = mcpService.createSession(actor); + logger.log( + `New MCP session created for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`, + ); await transport.handleRequest(req.raw, reply.raw, body); } diff --git a/apps/gateway/src/mcp/mcp.service.spec.ts b/apps/gateway/src/mcp/mcp.service.spec.ts new file mode 100644 index 00000000..9842feea --- /dev/null +++ b/apps/gateway/src/mcp/mcp.service.spec.ts @@ -0,0 +1,461 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Brain } from '@mosaicstack/brain'; +import type { Memory } from '@mosaicstack/memory'; +import type { EmbeddingService } from '../memory/embedding.service.js'; +import type { CoordService } from '../coord/coord.service.js'; +import { + assertMcpToolAuthorized, + createMcpActorContext, + deriveMcpToolScopesForUser, + MCP_TOOL_SCOPES, + McpService, + type McpToolName, +} from './mcp.service.js'; + +type ToolResult = { content: Array<{ type: 'text'; text: string }> }; +type ToolHandler = (params: Record) => Promise; + +interface CapturedTool { + inputSchema: z.ZodType; + handler: ToolHandler; +} + +function makeCapturingServer(): { server: McpServer; tools: Map } { + const tools = new Map(); + const server = { + registerTool(name: string, config: { inputSchema: z.ZodType }, handler: ToolHandler): void { + tools.set(name, { inputSchema: config.inputSchema, handler }); + }, + }; + return { server: server as unknown as McpServer, tools }; +} + +function makeService(opts?: { + projects?: Array & { id: string; ownerId?: string | null }>; + missions?: Array< + Record & { id: string; projectId?: string | null; userId?: string | null } + >; + tasks?: Array< + Record & { + id: string; + projectId?: string | null; + missionId?: string | null; + status?: string; + } + >; +}) { + const projects = opts?.projects ?? []; + const missions = opts?.missions ?? []; + const tasks = opts?.tasks ?? []; + const brain = { + projects: { + findAll: vi.fn(async () => projects), + findById: vi.fn(async (id: string) => projects.find((project) => project.id === id) ?? null), + }, + tasks: { + findAll: vi.fn(async () => tasks), + findById: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null), + findByProject: vi.fn(async (projectId: string) => + tasks.filter((task) => task.projectId === projectId), + ), + findByMission: vi.fn(async (missionId: string) => + tasks.filter((task) => task.missionId === missionId), + ), + findByStatus: vi.fn(async (status: string) => tasks.filter((task) => task.status === status)), + create: vi.fn(async (task: Record) => ({ id: 'task-1', ...task })), + update: vi.fn(async (id: string, updates: Record) => ({ id, ...updates })), + }, + missions: { + findAll: vi.fn(async () => missions), + findById: vi.fn(async (id: string) => missions.find((mission) => mission.id === id) ?? null), + findByProject: vi.fn(async (projectId: string) => + missions.filter((mission) => mission.projectId === projectId), + ), + }, + conversations: { + findAll: vi.fn(async (userId: string) => [{ id: 'conversation-1', userId }]), + }, + } as unknown as Brain; + + const memory = { + insights: { + searchByEmbedding: vi.fn(async (userId: string) => [{ id: 'insight-1', userId }]), + create: vi.fn(async (insight: Record) => ({ id: 'insight-2', ...insight })), + }, + preferences: { + findByUser: vi.fn(async (userId: string) => [{ key: 'theme', userId }]), + findByUserAndCategory: vi.fn(async (userId: string, category: string) => [ + { key: 'theme', userId, category }, + ]), + upsert: vi.fn(async (preference: Record) => ({ + id: 'pref-1', + ...preference, + })), + }, + } as unknown as Memory; + + const embeddings = { + available: true, + embed: vi.fn(async () => [0.1, 0.2, 0.3]), + } as unknown as EmbeddingService; + + const coord = { + getMissionStatus: vi.fn(async () => null), + listTasks: vi.fn(async () => []), + getTaskStatus: vi.fn(async () => null), + } as unknown as CoordService; + + return { + service: new McpService(brain, memory, embeddings, coord), + brain: brain as unknown as { + conversations: { findAll: ReturnType }; + tasks: { + create: ReturnType; + update: ReturnType; + }; + }, + memory: memory as unknown as { + insights: { searchByEmbedding: ReturnType }; + }, + coord: coord as unknown as { + listTasks: ReturnType; + }, + }; +} + +function getTool(tools: Map, name: McpToolName): CapturedTool { + const tool = tools.get(name); + if (!tool) throw new Error(`Missing captured tool ${name}`); + return tool; +} + +function makeMemberActor(userId = 'authenticated-user') { + return createMcpActorContext({ + userId, + role: 'member', + scopes: deriveMcpToolScopesForUser({ role: 'member' }), + }); +} + +function makeAdminActor(userId = 'admin-user', tenantId?: string) { + return createMcpActorContext({ + userId, + tenantId, + role: 'admin', + scopes: deriveMcpToolScopesForUser({ role: 'admin' }), + }); +} + +function makePlatformAdminActor(userId = 'platform-admin-user') { + return createMcpActorContext({ + userId, + role: 'platform-admin', + scopes: deriveMcpToolScopesForUser({ role: 'platform-admin' }), + }); +} + +describe('MCP actor identity and tool scope enforcement', () => { + it('derives immutable actor, tenant, channel, correlation, and explicit tool scopes server-side', () => { + const actor = createMcpActorContext({ + userId: ' user-authenticated ', + role: 'member', + scopes: deriveMcpToolScopesForUser({ role: 'member' }), + }); + + expect(actor.userId).toBe('user-authenticated'); + expect(actor.tenantId).toBe('user:user-authenticated'); + expect(actor.role).toBe('member'); + expect(actor.channel).toBe('mcp'); + expect(actor.correlationId).toMatch(/[0-9a-f-]{36}/i); + expect(actor.scopes.has(MCP_TOOL_SCOPES.memory_search)).toBe(true); + expect(actor.scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false); + expect( + deriveMcpToolScopesForUser({ role: 'admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks), + ).toBe(false); + expect( + deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks), + ).toBe(true); + }); + + it('fails closed when scopes are not supplied by the authenticated context policy', () => { + const actor = createMcpActorContext({ userId: 'user-authenticated' }); + + expect(actor.scopes.size).toBe(0); + expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow( + 'MCP tool scope denied: memory:insight:read', + ); + }); + + it('fails closed when a tool caller supplies actor or tenant identity fields', () => { + const actor = createMcpActorContext({ userId: 'user-authenticated' }); + + expect(() => + assertMcpToolAuthorized(actor, 'memory_search', { + userId: 'victim-user', + query: 'private data', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: userId'); + + expect(() => + assertMcpToolAuthorized(actor, 'coord_list_tasks', { + tenantId: 'victim-tenant', + projectPath: '/tmp/project', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: tenantId'); + + expect(() => + assertMcpToolAuthorized(actor, 'brain_create_task', { + title: 'forged org', + organizationId: 'victim-org', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: organizationId'); + + expect(() => + assertMcpToolAuthorized(actor, 'brain_update_task', { + title: 'forged team', + teamId: 'victim-team', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: teamId'); + }); + + it('fails closed when the server-derived actor lacks the required per-tool scope', () => { + const actor = createMcpActorContext({ userId: 'user-authenticated', scopes: [] }); + + expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow( + 'MCP tool scope denied: memory:insight:read', + ); + }); + + it('removes caller-controlled userId from memory schemas and never queries victim memory', async () => { + const { service, memory } = makeService(); + const { server, tools } = makeCapturingServer(); + const actor = makeMemberActor('authenticated-user'); + + service.registerTools(server, actor); + const tool = getTool(tools, 'memory_search'); + + expect(tool.inputSchema.safeParse({ userId: 'victim-user', query: 'anything' }).success).toBe( + false, + ); + await expect(tool.handler({ userId: 'victim-user', query: 'anything' })).rejects.toThrow( + 'MCP caller-controlled identity field is forbidden: userId', + ); + expect(memory.insights.searchByEmbedding).not.toHaveBeenCalled(); + + await tool.handler({ query: 'only my notes' }); + expect(memory.insights.searchByEmbedding).toHaveBeenCalledWith( + 'authenticated-user', + [0.1, 0.2, 0.3], + 5, + ); + }); + + it('binds conversation listing to the authenticated actor instead of a caller-supplied userId', async () => { + const { service, brain } = makeService(); + const { server, tools } = makeCapturingServer(); + const actor = makeMemberActor('authenticated-user'); + + service.registerTools(server, actor); + const tool = getTool(tools, 'brain_list_conversations'); + + expect(tool.inputSchema.safeParse({ userId: 'victim-user' }).success).toBe(false); + await expect(tool.handler({ userId: 'victim-user' })).rejects.toThrow( + 'MCP caller-controlled identity field is forbidden: userId', + ); + expect(brain.conversations.findAll).not.toHaveBeenCalled(); + + await tool.handler({}); + expect(brain.conversations.findAll).toHaveBeenCalledWith('authenticated-user'); + }); + + it('scopes brain project, mission, and task reads to the authenticated actor', async () => { + const { service } = makeService({ + projects: [ + { id: 'project-owned', ownerId: 'authenticated-user', name: 'owned' }, + { id: 'project-victim', ownerId: 'victim-user', name: 'victim' }, + ], + missions: [ + { id: 'mission-owned', projectId: 'project-owned' }, + { id: 'mission-victim', userId: 'victim-user', projectId: 'project-victim' }, + ], + tasks: [ + { id: 'task-owned-project', projectId: 'project-owned', status: 'not-started' }, + { id: 'task-owned-mission', missionId: 'mission-owned', status: 'not-started' }, + { id: 'task-victim-project', projectId: 'project-victim', status: 'not-started' }, + { id: 'task-unowned', status: 'not-started' }, + ], + }); + const { server, tools } = makeCapturingServer(); + const actor = makeMemberActor('authenticated-user'); + + 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-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-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-project', + 'task-owned-mission', + ]); + }); + + it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => { + const { service } = makeService({ + projects: [ + { + id: 'project-tenant-a', + ownerId: 'other-user-a', + teamId: 'tenant-a', + name: 'same tenant', + }, + { + id: 'project-tenant-b', + ownerId: 'other-user-b', + teamId: 'tenant-b', + name: 'other tenant', + }, + ], + missions: [ + { 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-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'); + + 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 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 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: [ + { id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' }, + { id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' }, + ], + missions: [ + { 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-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'); + + service.registerTools(server, actor); + + await expect( + getTool(tools, 'brain_create_task').handler({ + title: 'unscoped tenant write', + }), + ).rejects.toThrow('MCP task scope denied'); + expect(brain.tasks.create).not.toHaveBeenCalled(); + + await expect( + getTool(tools, 'brain_create_task').handler({ + title: 'cross-tenant write', + projectId: 'project-tenant-b', + }), + ).rejects.toThrow('MCP task project scope denied'); + expect(brain.tasks.create).not.toHaveBeenCalled(); + + await expect( + getTool(tools, 'brain_update_task').handler({ + id: 'task-tenant-a', + projectId: 'project-tenant-b', + }), + ).rejects.toThrow('MCP task project scope denied'); + expect(brain.tasks.update).not.toHaveBeenCalled(); + + const updateResult = await getTool(tools, 'brain_update_task').handler({ + id: 'task-tenant-b', + title: 'cross-tenant update', + }); + expect(updateResult.content[0]!.text).toBe('Task not found: task-tenant-b'); + expect(brain.tasks.update).not.toHaveBeenCalled(); + + await getTool(tools, 'brain_create_task').handler({ + title: 'same-tenant write', + projectId: 'project-tenant-a', + }); + expect(brain.tasks.create).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'project-tenant-a', title: 'same-tenant write' }), + ); + }); + + it('keeps admin-only coordination tools on server-derived paths', async () => { + const { service, coord } = makeService(); + const { server, tools } = makeCapturingServer(); + const member = makeMemberActor('authenticated-user'); + const tenantAdmin = makeAdminActor('admin-user'); + const platformAdmin = makePlatformAdminActor('platform-admin-user'); + + service.registerTools(server, member); + const memberTool = getTool(tools, 'coord_list_tasks'); + expect(memberTool.inputSchema.safeParse({ projectPath: '/tmp/victim' }).success).toBe(false); + await expect(memberTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read'); + + tools.clear(); + service.registerTools(server, tenantAdmin); + const tenantAdminTool = getTool(tools, 'coord_list_tasks'); + await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read'); + + tools.clear(); + service.registerTools(server, platformAdmin); + const platformAdminTool = getTool(tools, 'coord_list_tasks'); + await platformAdminTool.handler({ projectPath: '/tmp/victim' }); + expect(coord.listTasks).toHaveBeenCalledWith(process.cwd()); + }); + + it('does not attach a guessed or stale-scope MCP session to another authenticated context', async () => { + const { service } = makeService(); + const owner = makeMemberActor('owner-user'); + const attacker = makeMemberActor('attacker-user'); + const admin = makeAdminActor('admin-user'); + const downgradedAdmin = makeMemberActor('admin-user'); + + const { sessionId, transport } = service.createSession(owner); + const staleSession = service.createSession(admin); + + expect(service.getSession(sessionId, owner)).toBe(transport); + expect(service.getSession(sessionId, attacker)).toBeNull(); + expect(service.getSession(sessionId, owner)).toBe(transport); + expect(service.getSession(staleSession.sessionId, downgradedAdmin)).toBeNull(); + expect(service.getSession(staleSession.sessionId, admin)).toBe(staleSession.transport); + + await service.onModuleDestroy(); + }); +}); diff --git a/apps/gateway/src/mcp/mcp.service.ts b/apps/gateway/src/mcp/mcp.service.ts index c32dfc03..e5fb6b92 100644 --- a/apps/gateway/src/mcp/mcp.service.ts +++ b/apps/gateway/src/mcp/mcp.service.ts @@ -10,11 +10,216 @@ import { MEMORY } from '../memory/memory.tokens.js'; import { EmbeddingService } from '../memory/embedding.service.js'; import { CoordService } from '../coord/coord.service.js'; +export const MCP_CALLER_IDENTITY_FIELDS = [ + 'actorId', + 'actor', + 'authenticatedUserId', + 'channel', + 'organizationId', + 'ownerId', + 'sessionUserId', + 'teamId', + 'tenant', + 'tenantId', + 'user', + 'userId', +] as const; + +type McpCallerIdentityField = (typeof MCP_CALLER_IDENTITY_FIELDS)[number]; + +export const MCP_TOOL_SCOPES = { + brain_list_projects: 'brain:project:read', + brain_get_project: 'brain:project:read', + brain_list_tasks: 'brain:task:read', + brain_create_task: 'brain:task:write', + brain_update_task: 'brain:task:write', + brain_list_missions: 'brain:mission:read', + brain_list_conversations: 'brain:conversation:read', + memory_search: 'memory:insight:read', + memory_get_preferences: 'memory:preference:read', + memory_save_preference: 'memory:preference:write', + memory_save_insight: 'memory:insight:write', + coord_mission_status: 'coord:read', + coord_list_tasks: 'coord:read', + coord_task_detail: 'coord:read', +} as const; + +export type McpToolName = keyof typeof MCP_TOOL_SCOPES; +export type McpToolScope = (typeof MCP_TOOL_SCOPES)[McpToolName]; + +export interface McpActorContext { + userId: string; + tenantId: string; + role: string; + channel: 'mcp'; + correlationId: string; + scopes: ReadonlySet; +} + interface SessionEntry { server: McpServer; transport: StreamableHTTPServerTransport; createdAt: Date; + actor: McpActorContext; +} + +const GLOBAL_ADMIN_MCP_SCOPES = new Set(Object.values(MCP_TOOL_SCOPES)); +const TENANT_ADMIN_MCP_SCOPES = new Set([ + 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([ + MCP_TOOL_SCOPES.brain_list_projects, + MCP_TOOL_SCOPES.brain_get_project, + MCP_TOOL_SCOPES.brain_list_tasks, + 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, +]); + +export function deriveMcpToolScopesForUser(input: { + role?: string | null; +}): ReadonlySet { + 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); +} + +export function createMcpActorContext(input: { userId: string; + tenantId?: string; + role?: string | null; + scopes?: Iterable; + correlationId?: string; +}): McpActorContext { + const userId = input.userId.trim(); + if (userId.length === 0) { + throw new Error('MCP authenticated user is required'); + } + + return { + userId, + tenantId: input.tenantId?.trim() || `user:${userId}`, + role: input.role ?? 'member', + channel: 'mcp', + correlationId: input.correlationId ?? randomUUID(), + scopes: new Set(input.scopes ?? []), + }; +} + +export function assertNoCallerControlledIdentity(params: unknown): void { + if (params === null || typeof params !== 'object') return; + + const keys = new Set(Object.keys(params)); + const forbidden = MCP_CALLER_IDENTITY_FIELDS.find((field: McpCallerIdentityField) => + keys.has(field), + ); + if (forbidden) { + throw new Error(`MCP caller-controlled identity field is forbidden: ${forbidden}`); + } +} + +export function assertMcpToolAuthorized( + actor: McpActorContext, + toolName: McpToolName, + params: unknown, +): void { + assertNoCallerControlledIdentity(params); + const requiredScope = MCP_TOOL_SCOPES[toolName]; + if (!actor.scopes.has(requiredScope)) { + throw new Error(`MCP tool scope denied: ${requiredScope}`); + } +} + +function strictObject(shape: T): z.ZodObject { + return z.object(shape).strict(); +} + +type TenantScopedLike = { + tenantId?: string | null; + organizationId?: string | null; + teamId?: string | null; +}; +type ProjectLike = TenantScopedLike & { id: string; ownerId?: string | null }; +type MissionLike = TenantScopedLike & { + id: string; + projectId?: string | null; + userId?: string | null; +}; +type TaskLike = TenantScopedLike & { + projectId?: string | null; + missionId?: string | null; + userId?: string | null; +}; + +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(actor: McpActorContext, projects: T[]): T[] { + if (isGlobalAdminActor(actor)) return projects; + return projects.filter( + (project) => + project.ownerId === actor.userId || + (isTenantAdminActor(actor) && matchesTenant(actor, project)), + ); +} + +function filterMissionsByDirectActorScope( + actor: McpActorContext, + missions: T[], +): T[] { + if (isGlobalAdminActor(actor)) return missions; + return missions.filter( + (mission) => + mission.userId === actor.userId || + (isTenantAdminActor(actor) && matchesTenant(actor, mission)), + ); +} + +function scopesEqual(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) return false; + for (const scope of left) { + if (!right.has(scope)) return false; + } + return true; +} + +function sameActorAuthorization(stored: McpActorContext, current: McpActorContext): boolean { + return ( + stored.userId === current.userId && + stored.tenantId === current.tenantId && + stored.role === current.role && + scopesEqual(stored.scopes, current.scopes) + ); } @Injectable() @@ -33,13 +238,18 @@ export class McpService implements OnModuleDestroy { * Creates a new MCP session with its own server + transport pair. * Returns the transport for use by the controller. */ - createSession(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } { + createSession(actor: McpActorContext): { + sessionId: string; + transport: StreamableHTTPServerTransport; + } { const sessionId = randomUUID(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => sessionId, onsessioninitialized: (id) => { - this.logger.log(`MCP session initialized: ${id} for user ${userId}`); + this.logger.log( + `MCP session initialized: ${id} for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`, + ); }, }); @@ -48,7 +258,7 @@ export class McpService implements OnModuleDestroy { { capabilities: { tools: {} } }, ); - this.registerTools(server, userId); + this.registerTools(server, actor); transport.onclose = () => { this.logger.log(`MCP session closed: ${sessionId}`); @@ -61,31 +271,126 @@ export class McpService implements OnModuleDestroy { ); }); - this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId }); + this.sessions.set(sessionId, { server, transport, createdAt: new Date(), actor }); return { sessionId, transport }; } /** - * Returns the transport for an existing session, or null if not found. + * Returns the transport for an existing session only when it belongs to the + * currently authenticated MCP actor. Guessed or cross-tenant session IDs grant + * no authority. */ - getSession(sessionId: string): StreamableHTTPServerTransport | null { - return this.sessions.get(sessionId)?.transport ?? null; + getSession(sessionId: string, actor: McpActorContext): StreamableHTTPServerTransport | null { + const entry = this.sessions.get(sessionId); + if (!entry) return null; + if (!sameActorAuthorization(entry.actor, actor)) { + this.logger.warn( + `MCP session actor or scope mismatch: session=${sessionId} actor=${actor.userId} tenant=${actor.tenantId} role=${actor.role}`, + ); + return null; + } + return entry.transport; + } + + private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise { + if (isGlobalAdminActor(actor)) return true; + const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined; + return project ? filterProjectsForActor(actor, [project]).length === 1 : false; + } + + private async filterMissionsForActor( + actor: McpActorContext, + missions: T[], + ): Promise { + 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), + ); + + return missions.filter( + (mission) => + filterMissionsByDirectActorScope(actor, [mission]).length === 1 || + (typeof mission.projectId === 'string' && projectIds.has(mission.projectId)), + ); + } + + private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise { + 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; + } + + private async assertTaskReferencesAuthorized( + actor: McpActorContext, + refs: { projectId?: string | null; missionId?: string | null }, + ): Promise { + if (refs.projectId && !(await this.isProjectAuthorized(actor, refs.projectId))) { + throw new Error('MCP task project scope denied'); + } + if (refs.missionId && !(await this.isMissionAuthorized(actor, refs.missionId))) { + throw new Error('MCP task mission scope denied'); + } + } + + private async assertTaskCreateScopeAuthorized( + actor: McpActorContext, + refs: { projectId?: string | null; missionId?: string | null }, + ): Promise { + if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) { + throw new Error('MCP task scope denied'); + } + await this.assertTaskReferencesAuthorized(actor, refs); + } + + private async filterTasksForActor( + actor: McpActorContext, + tasks: T[], + ): Promise { + if (isGlobalAdminActor(actor)) return tasks; + + const [projects, missions] = await Promise.all([ + this.brain.projects.findAll(), + this.brain.missions.findAll(), + ]); + const projectIds = new Set( + filterProjectsForActor(actor, projects as ProjectLike[]).map((project) => project.id), + ); + const missionIds = new Set( + (await this.filterMissionsForActor(actor, missions as MissionLike[])).map( + (mission) => mission.id, + ), + ); + + 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)), + ); } /** * Registers all platform tools on the given McpServer instance. */ - private registerTools(server: McpServer, _userId: string): void { + registerTools(server: McpServer, actor: McpActorContext): void { // ─── Brain: Project tools ──────────────────────────────────────────── server.registerTool( 'brain_list_projects', { description: 'List all projects in the brain.', - inputSchema: z.object({}), + inputSchema: strictObject({}), }, - async () => { - const projects = await this.brain.projects.findAll(); + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_projects', params); + const projects = filterProjectsForActor( + actor, + (await this.brain.projects.findAll()) as ProjectLike[], + ); return { content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }], }; @@ -96,17 +401,21 @@ export class McpService implements OnModuleDestroy { 'brain_get_project', { description: 'Get a project by ID.', - inputSchema: z.object({ + inputSchema: strictObject({ id: z.string().describe('Project ID (UUID)'), }), }, - async ({ id }) => { - const project = await this.brain.projects.findById(id); + async ({ id, ...params }) => { + assertMcpToolAuthorized(actor, 'brain_get_project', params); + const project = (await this.brain.projects.findById(id)) as ProjectLike | undefined; + const authorizedProject = project ? filterProjectsForActor(actor, [project])[0] : undefined; return { content: [ { type: 'text' as const, - text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`, + text: authorizedProject + ? JSON.stringify(authorizedProject, null, 2) + : `Project not found: ${id}`, }, ], }; @@ -119,20 +428,23 @@ export class McpService implements OnModuleDestroy { 'brain_list_tasks', { description: 'List tasks, optionally filtered by project, mission, or status.', - inputSchema: z.object({ + inputSchema: strictObject({ projectId: z.string().optional().describe('Filter by project ID'), missionId: z.string().optional().describe('Filter by mission ID'), status: z.string().optional().describe('Filter by status'), }), }, - async ({ projectId, missionId, status }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_tasks', params); + const { projectId, missionId, status } = params; type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; let tasks; if (projectId) tasks = await this.brain.tasks.findByProject(projectId); else if (missionId) tasks = await this.brain.tasks.findByMission(missionId); else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus); else tasks = await this.brain.tasks.findAll(); - return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] }; + const scopedTasks = await this.filterTasksForActor(actor, tasks as TaskLike[]); + return { content: [{ type: 'text' as const, text: JSON.stringify(scopedTasks, null, 2) }] }; }, ); @@ -140,7 +452,7 @@ export class McpService implements OnModuleDestroy { 'brain_create_task', { description: 'Create a new task in the brain.', - inputSchema: z.object({ + inputSchema: strictObject({ title: z.string().describe('Task title'), description: z.string().optional().describe('Task description'), projectId: z.string().optional().describe('Project ID'), @@ -149,6 +461,8 @@ export class McpService implements OnModuleDestroy { }), }, async (params) => { + assertMcpToolAuthorized(actor, 'brain_create_task', params); + await this.assertTaskCreateScopeAuthorized(actor, params); type Priority = 'low' | 'medium' | 'high' | 'critical'; const task = await this.brain.tasks.create({ ...params, @@ -162,7 +476,7 @@ export class McpService implements OnModuleDestroy { 'brain_update_task', { description: 'Update an existing task.', - inputSchema: z.object({ + inputSchema: strictObject({ id: z.string().describe('Task ID'), title: z.string().optional(), description: z.string().optional(), @@ -171,9 +485,17 @@ export class McpService implements OnModuleDestroy { .optional() .describe('not-started, in-progress, blocked, done, cancelled'), priority: z.string().optional(), + projectId: z.string().optional().describe('Project ID'), + missionId: z.string().optional().describe('Mission ID'), }), }, async ({ id, ...updates }) => { + assertMcpToolAuthorized(actor, 'brain_update_task', updates); + const existing = (await this.brain.tasks.findById(id)) as TaskLike | undefined; + if (!existing || (await this.filterTasksForActor(actor, [existing])).length === 0) { + return { content: [{ type: 'text' as const, text: `Task not found: ${id}` }] }; + } + await this.assertTaskReferencesAuthorized(actor, updates); type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; type Priority = 'low' | 'medium' | 'high' | 'critical'; const task = await this.brain.tasks.update(id, { @@ -198,14 +520,19 @@ export class McpService implements OnModuleDestroy { 'brain_list_missions', { description: 'List all missions, optionally filtered by project.', - inputSchema: z.object({ + inputSchema: strictObject({ projectId: z.string().optional().describe('Filter by project ID'), }), }, - async ({ projectId }) => { - const missions = projectId - ? await this.brain.missions.findByProject(projectId) - : await this.brain.missions.findAll(); + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_missions', params); + const { projectId } = params; + const missions = await this.filterMissionsForActor( + actor, + (projectId + ? await this.brain.missions.findByProject(projectId) + : await this.brain.missions.findAll()) as MissionLike[], + ); return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] }; }, ); @@ -213,13 +540,12 @@ export class McpService implements OnModuleDestroy { server.registerTool( 'brain_list_conversations', { - description: 'List conversations for a user.', - inputSchema: z.object({ - userId: z.string().describe('User ID'), - }), + description: 'List conversations for the authenticated MCP actor.', + inputSchema: strictObject({}), }, - async ({ userId }) => { - const conversations = await this.brain.conversations.findAll(userId); + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_conversations', params); + const conversations = await this.brain.conversations.findAll(actor.userId); return { content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }], }; @@ -232,14 +558,15 @@ export class McpService implements OnModuleDestroy { 'memory_search', { description: - 'Search across stored insights and knowledge using natural language. Returns semantically similar results.', - inputSchema: z.object({ - userId: z.string().describe('User ID to search memory for'), + 'Search stored insights and knowledge for the authenticated MCP actor using natural language.', + inputSchema: strictObject({ query: z.string().describe('Natural language search query'), limit: z.number().optional().describe('Max results (default 5)'), }), }, - async ({ userId, query, limit }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_search', params); + const { query, limit } = params; if (!this.embeddings.available) { return { content: [ @@ -251,7 +578,11 @@ export class McpService implements OnModuleDestroy { }; } const embedding = await this.embeddings.embed(query); - const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5); + const results = await this.memory.insights.searchByEmbedding( + actor.userId, + embedding, + limit ?? 5, + ); return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] }; }, ); @@ -259,20 +590,21 @@ export class McpService implements OnModuleDestroy { server.registerTool( 'memory_get_preferences', { - description: 'Retrieve stored preferences for a user.', - inputSchema: z.object({ - userId: z.string().describe('User ID'), + description: 'Retrieve stored preferences for the authenticated MCP actor.', + inputSchema: strictObject({ category: z .string() .optional() .describe('Filter by category: communication, coding, workflow, appearance, general'), }), }, - async ({ userId, category }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_get_preferences', params); + const { category } = params; type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general'; const prefs = category - ? await this.memory.preferences.findByUserAndCategory(userId, category as Cat) - : await this.memory.preferences.findByUser(userId); + ? await this.memory.preferences.findByUserAndCategory(actor.userId, category as Cat) + : await this.memory.preferences.findByUser(actor.userId); return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] }; }, ); @@ -281,9 +613,8 @@ export class McpService implements OnModuleDestroy { 'memory_save_preference', { description: - 'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").', - inputSchema: z.object({ - userId: z.string().describe('User ID'), + 'Store a learned preference for the authenticated MCP actor (e.g., "prefers tables over paragraphs").', + inputSchema: strictObject({ key: z.string().describe('Preference key'), value: z.string().describe('Preference value (JSON string)'), category: z @@ -292,7 +623,9 @@ export class McpService implements OnModuleDestroy { .describe('Category: communication, coding, workflow, appearance, general'), }), }, - async ({ userId, key, value, category }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_save_preference', params); + const { key, value, category } = params; type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general'; let parsedValue: unknown; try { @@ -301,7 +634,7 @@ export class McpService implements OnModuleDestroy { parsedValue = value; } const pref = await this.memory.preferences.upsert({ - userId, + userId: actor.userId, key, value: parsedValue, category: (category as Cat) ?? 'general', @@ -315,9 +648,8 @@ export class McpService implements OnModuleDestroy { 'memory_save_insight', { description: - 'Store a learned insight, decision, or knowledge extracted from the current interaction.', - inputSchema: z.object({ - userId: z.string().describe('User ID'), + 'Store a learned insight, decision, or knowledge for the authenticated MCP actor.', + inputSchema: strictObject({ content: z.string().describe('The insight or knowledge to store'), category: z .string() @@ -325,11 +657,13 @@ export class McpService implements OnModuleDestroy { .describe('Category: decision, learning, preference, fact, pattern, general'), }), }, - async ({ userId, content, category }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_save_insight', params); + const { content, category } = params; type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general'; const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null; const insight = await this.memory.insights.create({ - userId, + userId: actor.userId, content, embedding, source: 'agent', @@ -346,16 +680,11 @@ export class McpService implements OnModuleDestroy { { description: 'Get the current orchestration mission status including milestones, tasks, and active session.', - inputSchema: z.object({ - projectPath: z - .string() - .optional() - .describe('Project path. Defaults to gateway working directory.'), - }), + inputSchema: strictObject({}), }, - async ({ projectPath }) => { - const resolvedPath = projectPath ?? process.cwd(); - const status = await this.coordService.getMissionStatus(resolvedPath); + async (params) => { + assertMcpToolAuthorized(actor, 'coord_mission_status', params); + const status = await this.coordService.getMissionStatus(process.cwd()); return { content: [ { @@ -371,16 +700,11 @@ export class McpService implements OnModuleDestroy { 'coord_list_tasks', { description: 'List all tasks from the orchestration TASKS.md file.', - inputSchema: z.object({ - projectPath: z - .string() - .optional() - .describe('Project path. Defaults to gateway working directory.'), - }), + inputSchema: strictObject({}), }, - async ({ projectPath }) => { - const resolvedPath = projectPath ?? process.cwd(); - const tasks = await this.coordService.listTasks(resolvedPath); + async (params) => { + assertMcpToolAuthorized(actor, 'coord_list_tasks', params); + const tasks = await this.coordService.listTasks(process.cwd()); return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] }; }, ); @@ -389,17 +713,14 @@ export class McpService implements OnModuleDestroy { 'coord_task_detail', { description: 'Get detailed status for a specific orchestration task.', - inputSchema: z.object({ + inputSchema: strictObject({ taskId: z.string().describe('Task ID (e.g. P2-005)'), - projectPath: z - .string() - .optional() - .describe('Project path. Defaults to gateway working directory.'), }), }, - async ({ taskId, projectPath }) => { - const resolvedPath = projectPath ?? process.cwd(); - const detail = await this.coordService.getTaskStatus(resolvedPath, taskId); + async (params) => { + assertMcpToolAuthorized(actor, 'coord_task_detail', params); + const { taskId } = params; + const detail = await this.coordService.getTaskStatus(process.cwd(), taskId); return { content: [ { diff --git a/apps/gateway/src/memory/memory.module.ts b/apps/gateway/src/memory/memory.module.ts index 779ad40c..56e02789 100644 --- a/apps/gateway/src/memory/memory.module.ts +++ b/apps/gateway/src/memory/memory.module.ts @@ -3,8 +3,10 @@ import { createMemory, type Memory, createMemoryAdapter, + createOperatorMemoryPlugin, type MemoryAdapter, type MemoryConfig, + type OperatorMemoryPlugin, } from '@mosaicstack/memory'; import type { Db } from '@mosaicstack/db'; import type { StorageAdapter } from '@mosaicstack/storage'; @@ -14,6 +16,9 @@ import { DB, STORAGE_ADAPTER } from '../database/database.module.js'; import { MEMORY } from './memory.tokens.js'; import { MemoryController } from './memory.controller.js'; import { EmbeddingService } from './embedding.service.js'; +import { redactSensitiveContent } from '@mosaicstack/log'; + +export const OPERATOR_MEMORY_PLUGIN = 'OPERATOR_MEMORY_PLUGIN'; export const MEMORY_ADAPTER = 'MEMORY_ADAPTER'; @@ -38,9 +43,24 @@ function buildMemoryConfig(config: MosaicConfig, storageAdapter: StorageAdapter) createMemoryAdapter(buildMemoryConfig(config, storageAdapter)), inject: [MOSAIC_CONFIG, STORAGE_ADAPTER], }, + { + provide: OPERATOR_MEMORY_PLUGIN, + useFactory: (adapter: MemoryAdapter): OperatorMemoryPlugin | null => { + const instanceId = process.env['MOSAIC_OPERATOR_MEMORY_INSTANCE_ID']?.trim(); + const namespace = process.env['MOSAIC_OPERATOR_MEMORY_NAMESPACE']?.trim(); + if (!instanceId || !namespace) return null; + return createOperatorMemoryPlugin({ + adapter, + instanceId, + namespace, + redact: (content) => redactSensitiveContent(content).content, + }); + }, + inject: [MEMORY_ADAPTER], + }, EmbeddingService, ], controllers: [MemoryController], - exports: [MEMORY, MEMORY_ADAPTER, EmbeddingService], + exports: [MEMORY, MEMORY_ADAPTER, OPERATOR_MEMORY_PLUGIN, EmbeddingService], }) export class MemoryModule {} diff --git a/apps/gateway/src/plugin/discord-ingress.security.spec.ts b/apps/gateway/src/plugin/discord-ingress.security.spec.ts new file mode 100644 index 00000000..3f12aa2a --- /dev/null +++ b/apps/gateway/src/plugin/discord-ingress.security.spec.ts @@ -0,0 +1,715 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createDiscordIngressEnvelope, + verifyDiscordIngressEnvelope, + DiscordPlugin, + type DiscordIngressPayload, + parseDiscordInteractionBindings, + resolveDiscordInteractionActorId, + resolveDiscordInteractionBinding, +} from '@mosaicstack/discord-plugin'; +import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js'; +import { ChatGateway } from '../chat/chat.gateway.js'; +import { CommandAuthorizationService } from '../commands/command-authorization.service.js'; +import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js'; +import { DiscordReplayProtector } from './discord-replay-protector.js'; + +const SERVICE_TOKEN = 'test-service-token'; +const ENV_KEYS = [ + 'DISCORD_SERVICE_TOKEN', + 'DISCORD_SERVICE_USER_ID', + 'DISCORD_SERVICE_TENANT_ID', + 'DISCORD_INTERACTION_BINDINGS', + 'DISCORD_ALLOWED_GUILD_IDS', + 'DISCORD_ALLOWED_CHANNEL_IDS', + 'DISCORD_ALLOWED_USER_IDS', + 'MOSAIC_AGENT_NAME', + 'MOSAIC_AGENT_CONFIG_ID', +] as const; +const savedEnv = new Map(); + +function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void { + for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]); + process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN; + process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service'; + process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord'; + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + process.env['MOSAIC_AGENT_CONFIG_ID'] = 'agent-config-nova'; + process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { + role: role === 'admin' ? 'admin' : 'operator', + mosaicUserId: 'mosaic-admin-001', + }, + }, + }, + ]); +} + +afterEach((): void => { + for (const key of ENV_KEYS) { + const value = savedEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + savedEnv.clear(); +}); + +function commandAuthorization(role: 'admin' | 'member'): CommandAuthorizationService { + const entries = new Map(); + const db = { + select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }), + }; + const redis = { + get: async (key: string) => entries.get(key) ?? null, + set: async (key: string, value: string) => entries.set(key, value), + del: async (key: string) => Number(entries.delete(key)), + }; + return new CommandAuthorizationService(db as never, redis); +} + +function discordGateway(role: 'admin' | 'member'): { + gateway: ChatGateway; + client: { data: { discordService: boolean }; emit: ReturnType }; + consumedActions: Array<{ actorId: string; correlationId: string }>; + durable: { getSnapshot: ReturnType }; + audit: { record: ReturnType }; +} { + const authorization = commandAuthorization(role); + const consumedActions: Array<{ actorId: string; correlationId: string }> = []; + const durable = { + getSnapshot: vi.fn().mockResolvedValue({ + identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }), + }; + const audit = { record: vi.fn().mockResolvedValue(undefined) }; + const runtimeRegistry = new RuntimeProviderService( + { + require: () => ({ + capabilities: async () => ({ supported: ['session.terminate'] }), + terminate: async () => undefined, + }), + } as never, + { record: async () => undefined } as never, + { + consume: async (approvalId, action) => { + consumedActions.push({ actorId: action.actorId, correlationId: action.correlationId }); + return authorization.consumeRuntimeTerminationApproval(approvalId, action); + }, + }, + ); + return { + gateway: new ChatGateway( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + authorization, + runtimeRegistry, + durable as never, + audit as never, + ), + client: { data: { discordService: true }, emit: vi.fn() }, + consumedActions, + durable, + audit, + }; +} + +function ingressEnvelope( + content: string, + messageId: string, + overrides: Partial = {}, +): ReturnType { + return createDiscordIngressEnvelope( + createPayload({ content, messageId, ...overrides }), + SERVICE_TOKEN, + ); +} + +function createPayload(overrides: Partial = {}): DiscordIngressPayload { + return { + correlationId: 'correlation-001', + messageId: 'discord-message-001', + guildId: 'guild-001', + channelId: 'channel-001', + userId: 'user-001', + conversationId: 'Nova:discord:channel-001', + content: 'hello Tess', + ...overrides, + }; +} + +describe('Discord ingress security', () => { + it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => { + const [binding] = parseDiscordInteractionBindings( + JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { 'user-001': 'admin' }, + }, + ]), + ); + expect( + resolveDiscordInteractionBinding([binding!], 'guild-001', 'channel-001', 'user-001', 'send'), + ).toEqual(binding); + expect(resolveDiscordInteractionActorId(binding!, 'user-001')).toBeNull(); + }); + + it('binds a differently named configured interaction instance without code changes', () => { + const binding = resolveDiscordInteractionBinding( + [ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, + }, + ], + 'guild-001', + 'channel-001', + 'user-001', + 'send', + ); + + expect(binding?.instanceId).toBe('Nova'); + }); + + it('accepts only the configured Discord service identity', () => { + expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true); + expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false); + expect(validateDiscordServiceToken(undefined, SERVICE_TOKEN)).toBe(false); + }); + + it('rejects unauthenticated or tampered service envelopes', () => { + const envelope = createDiscordIngressEnvelope(createPayload(), SERVICE_TOKEN); + + expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)).toEqual(createPayload()); + expect(verifyDiscordIngressEnvelope(envelope, 'wrong-service-token')).toBeNull(); + expect( + verifyDiscordIngressEnvelope( + { ...envelope, payload: { ...envelope.payload, content: 'forged command' } }, + SERVICE_TOKEN, + ), + ).toBeNull(); + }); + + it.each([ + ['guild', { guildId: 'unlisted-guild' }], + ['channel', { channelId: 'unlisted-channel' }], + ['user', { userId: 'unlisted-user' }], + ])( + 'rejects an unallowlisted Discord %s', + (_kind: string, overrides: Partial) => { + const envelope = createDiscordIngressEnvelope(createPayload(overrides), SERVICE_TOKEN); + + expect( + verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, { + guildIds: ['guild-001'], + channelIds: ['channel-001'], + userIds: ['user-001'], + }), + ).toBeNull(); + }, + ); + + it('retains Discord message and correlation IDs after authenticated allowlisted validation', () => { + const payload = createPayload({ + correlationId: 'correlation-trace-123', + messageId: 'discord-snowflake-987', + }); + const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN); + + expect( + verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, { + guildIds: ['guild-001'], + channelIds: ['channel-001'], + userIds: ['user-001'], + }), + ).toEqual(payload); + }); + + it('rejects a replayed Discord message ID while retaining bounded replay state', () => { + const replayProtector = new DiscordReplayProtector(60_000, 2); + + expect(replayProtector.claim('discord-message-001')).toBe(true); + expect(replayProtector.claim('discord-message-001')).toBe(false); + expect(replayProtector.claim('discord-message-002')).toBe(true); + expect(replayProtector.claim('discord-message-003')).toBe(true); + expect(replayProtector.size).toBe(2); + }); + + it('consumes the exact target once when approval and stop are separate Discord messages', async () => { + configureDiscordEnv(); + const { gateway, client, consumedActions } = discordGateway('admin'); + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve', 'approve-message', { + correlationId: 'approval-ingress-correlation', + }), + ); + const approval = client.emit.mock.calls.find( + ([event]) => event === 'discord:approval', + )?.[1] as { + approvalId: string; + success: boolean; + }; + expect(approval.success).toBe(true); + + await gateway.handleDiscordStop( + client as never, + ingressEnvelope(`/stop ${approval.approvalId}`, 'stop-message', { + correlationId: 'stop-ingress-correlation', + }), + ); + expect(client.emit).toHaveBeenCalledWith('discord:stop', { + correlationId: 'stop-ingress-correlation', + success: true, + }); + expect(consumedActions).toEqual([ + { + actorId: 'mosaic-admin-001', + correlationId: expect.stringMatching(/^discord-action:v1:/), + }, + ]); + }); + + it('audits a Discord mint-side authorization denial', async () => { + configureDiscordEnv(); + const { gateway, client, audit } = discordGateway('member'); + + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve', 'denied-approve'), + ); + + expect(client.emit).toHaveBeenCalledWith('discord:approval', { + correlationId: 'correlation-001', + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: 'denied', + operation: 'session.terminate', + errorCode: 'policy_denied', + }), + ); + }); + + it('rejects approval when the durable session targets a different logical agent', async () => { + configureDiscordEnv(); + const { gateway, client, durable } = discordGateway('admin'); + durable.getSnapshot.mockResolvedValueOnce({ + identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }); + + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve', 'mismatched-agent-approve'), + ); + + expect(client.emit).toHaveBeenCalledWith('discord:approval', { + correlationId: 'correlation-001', + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + }); + + it('rejects privileged envelopes with a forged current conversation route', async () => { + configureDiscordEnv(); + const { gateway, client } = discordGateway('admin'); + + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve', 'forged-approval-route', { + conversationId: 'Nova:discord:other-channel', + }), + ); + await gateway.handleDiscordStop( + client as never, + ingressEnvelope('/stop forged', 'forged-stop-route', { + conversationId: 'Nova:discord:other-channel', + }), + ); + + expect(client.emit).not.toHaveBeenCalledWith('discord:approval', expect.anything()); + expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything()); + }); + + it('rejects unpaired and non-admin Discord users for approval and stop', async () => { + configureDiscordEnv(); + const { gateway, client } = discordGateway('member'); + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve', 'member-approve'), + ); + expect(client.emit).toHaveBeenCalledWith('discord:approval', { + correlationId: 'correlation-001', + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([]); + await gateway.handleDiscordStop( + client as never, + ingressEnvelope('/stop forged', 'unpaired-stop'), + ); + expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything()); + }); + + it('rejects replaying a Discord-created termination approval', async () => { + configureDiscordEnv(); + const { gateway, client } = discordGateway('admin'); + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve', 'replay-approve', { + correlationId: 'replay-approval-correlation', + }), + ); + const approval = client.emit.mock.calls.find( + ([event]) => event === 'discord:approval', + )?.[1] as { + approvalId: string; + }; + await gateway.handleDiscordStop( + client as never, + ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-one', { + correlationId: 'replay-stop-correlation-one', + }), + ); + await gateway.handleDiscordStop( + client as never, + ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-two', { + correlationId: 'replay-stop-correlation-two', + }), + ); + const stopResults = client.emit.mock.calls.filter(([event]) => event === 'discord:stop'); + expect(stopResults.map(([, result]) => (result as { success: boolean }).success)).toEqual([ + true, + false, + ]); + }); + + it.each([ + 'https://user:password@cdn.example.test/diagram.png', + 'https://cdn.example.test/diagram.png?token=secret', + 'https://cdn.example.test/diagram.png?X-Amz-Signature=secret', + 'https://cdn.example.test/diagram.png?auth=secret', + 'https://cdn.example.test/diagram.png?hm=secret', + ])('rejects credential-bearing attachment URLs before gateway dispatch', async (url) => { + configureDiscordEnv(); + const { gateway, client } = discordGateway('admin'); + + await gateway.handleMessage( + client as never, + ingressEnvelope('', `credential-url-${url.length}`, { + conversationId: 'Nova:discord:channel-001', + attachments: [ + { id: 'attachment-credential', name: 'diagram.png', url, contentType: 'image/png' }, + ], + }), + ); + + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + }); + + it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => { + configureDiscordEnv(); + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + { + instanceId: 'Orion', + agentConfigId: 'agent-config-orion', + guildId: 'guild-001', + channelId: 'channel-002', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const createSession = vi.fn().mockResolvedValue(session); + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const brain = { + agents: { + findById: vi.fn((id: string) => + Promise.resolve({ + id, + name: id === 'agent-config-orion' ? 'Orion' : 'Nova', + }), + ), + }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage: vi.fn().mockResolvedValue(undefined), + }, + }; + const routingEngine = { resolve: vi.fn() }; + const gateway = new ChatGateway( + agentService as never, + {} as never, + brain as never, + {} as never, + {} as never, + routingEngine as never, + ); + const client = { + id: 'discord-client-new-session', + data: { discordService: true }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + ingressEnvelope('start configured session', 'configured-session-001', { + conversationId: 'Nova:discord:channel-001', + }), + ); + + await gateway.handleMessage( + client as never, + ingressEnvelope('start second configured session', 'configured-session-002', { + channelId: 'channel-002', + conversationId: 'Orion:discord:channel-002', + }), + ); + + expect(createSession).toHaveBeenCalledWith( + 'Nova:discord:channel-001', + expect.objectContaining({ + agentConfigId: 'agent-config-nova', + userId: 'discord-service', + tenantId: 'tenant-discord', + }), + ); + expect(createSession).toHaveBeenCalledWith( + 'Orion:discord:channel-002', + expect.objectContaining({ agentConfigId: 'agent-config-orion' }), + ); + expect(routingEngine.resolve).not.toHaveBeenCalled(); + }); + + it('retains validated persisted attachments in resumed conversation history', async () => { + const attachment = { + id: 'attachment-history', + name: 'diagram.png', + url: 'https://cdn.example.test/diagram.png', + mimeType: 'image/png', + sizeBytes: 4_096, + }; + const gateway = new ChatGateway( + {} as never, + {} as never, + { + conversations: { + findMessages: vi.fn().mockResolvedValue([ + { + role: 'user', + content: '', + createdAt: new Date('2026-07-14T12:00:00.000Z'), + metadata: { channelAttachments: [attachment] }, + }, + ]), + }, + } as never, + {} as never, + {} as never, + {} as never, + ) as unknown as { + loadConversationHistory( + conversationId: string, + userId: string, + ): Promise>; + }; + + await expect( + gateway.loadConversationHistory('Nova:discord:channel-001', 'discord-service'), + ).resolves.toEqual([expect.objectContaining({ attachments: [attachment] })]); + }); + + it('rejects malformed signed attachment payloads before gateway dispatch', async () => { + configureDiscordEnv(); + const { gateway, client } = discordGateway('admin'); + const malformedPayload: Record = { + ...createPayload({ + messageId: 'malformed-attachments-001', + conversationId: 'Nova:discord:channel-001', + }), + attachments: { id: 'not-an-array' }, + }; + const envelope = createDiscordIngressEnvelope( + malformedPayload as unknown as DiscordIngressPayload, + SERVICE_TOKEN, + ); + + await gateway.handleMessage(client as never, envelope); + + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + }); + + it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => { + configureDiscordEnv(); + const prompt = vi.fn().mockResolvedValue(undefined); + const addMessage = vi.fn().mockResolvedValue(undefined); + const session = { + provider: 'test-provider', + modelId: 'test-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + prompt, + }; + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const gateway = new ChatGateway( + agentService as never, + {} as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'discord-client-001', + data: { discordService: true }, + emit: vi.fn(), + }; + const attachment = { + id: 'attachment-001', + name: 'diagram.png', + url: 'https://cdn.example.test/diagram.png', + contentType: 'image/png', + sizeBytes: 4_096, + }; + + await gateway.handleMessage( + client as never, + ingressEnvelope('', 'attachment-message-001', { + conversationId: 'Nova:discord:channel-001', + attachments: [attachment], + }), + ); + + const expectedAttachment = { + id: attachment.id, + name: attachment.name, + url: attachment.url, + mimeType: attachment.contentType, + sizeBytes: attachment.sizeBytes, + }; + expect(prompt).toHaveBeenCalledWith( + 'Nova:discord:channel-001', + '', + { userId: 'discord-service', tenantId: 'tenant-discord' }, + [expectedAttachment], + ); + expect(addMessage).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: 'Nova:discord:channel-001', + metadata: expect.objectContaining({ channelAttachments: [expectedAttachment] }), + }), + 'discord-service', + ); + }); + + it('accepts a thread message through its allowed bound parent channel', () => { + const emitted = vi.fn(); + const plugin = new DiscordPlugin({ + token: 'unused', + gatewayUrl: 'http://unused', + serviceToken: SERVICE_TOKEN, + allowedGuildIds: ['guild-001'], + allowedChannelIds: ['channel-001'], + allowedUserIds: ['user-001'], + interactionBindings: [ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, + }, + ], + }); + const internals = plugin as unknown as { + client: { user: { id: string } }; + socket: { connected: boolean; emit: ReturnType }; + handleDiscordMessage(message: unknown): void; + }; + internals.client = { user: { id: 'bot-001' } }; + internals.socket = { connected: true, emit: emitted }; + internals.handleDiscordMessage({ + id: 'thread-message', + guildId: 'guild-001', + channelId: 'thread-001', + author: { id: 'user-001', bot: false }, + mentions: { has: () => true }, + content: '<@bot-001> hello from thread', + channel: { parentId: 'channel-001' }, + attachments: new Map(), + }); + + const [, envelope] = emitted.mock.calls[0] as [ + string, + ReturnType, + ]; + expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)?.channelId).toBe('channel-001'); + }); +}); diff --git a/apps/gateway/src/plugin/discord-replay-protector.ts b/apps/gateway/src/plugin/discord-replay-protector.ts new file mode 100644 index 00000000..626a193a --- /dev/null +++ b/apps/gateway/src/plugin/discord-replay-protector.ts @@ -0,0 +1,40 @@ +/** + * Bounded replay cache for Discord's globally unique native message IDs. + * Durable ingress idempotency is added with Tess's canonical inbox/outbox work. + */ +export class DiscordReplayProtector { + private readonly claimedAt = new Map(); + + constructor( + private readonly ttlMs = 15 * 60 * 1000, + private readonly maxEntries = 10_000, + ) {} + + get size(): number { + return this.claimedAt.size; + } + + /** Claims an ID exactly once within its bounded retention window. */ + claim(messageId: string, now = Date.now()): boolean { + this.prune(now); + if (this.claimedAt.has(messageId)) return false; + + this.claimedAt.set(messageId, now); + this.evictOverflow(); + return true; + } + + private prune(now: number): void { + for (const [messageId, claimedAt] of this.claimedAt) { + if (now - claimedAt >= this.ttlMs) this.claimedAt.delete(messageId); + } + } + + private evictOverflow(): void { + while (this.claimedAt.size > this.maxEntries) { + const oldestMessageId = this.claimedAt.keys().next().value; + if (oldestMessageId === undefined) return; + this.claimedAt.delete(oldestMessageId); + } + } +} diff --git a/apps/gateway/src/plugin/plugin.module.ts b/apps/gateway/src/plugin/plugin.module.ts index 3991c9bc..ecb45a4b 100644 --- a/apps/gateway/src/plugin/plugin.module.ts +++ b/apps/gateway/src/plugin/plugin.module.ts @@ -6,7 +6,7 @@ import { type OnModuleDestroy, type OnModuleInit, } from '@nestjs/common'; -import { DiscordPlugin } from '@mosaicstack/discord-plugin'; +import { DiscordPlugin, parseDiscordInteractionBindings } from '@mosaicstack/discord-plugin'; import { TelegramPlugin } from '@mosaicstack/telegram-plugin'; import { PluginService } from './plugin.service.js'; import type { IChannelPlugin } from './plugin.interface.js'; @@ -50,19 +50,58 @@ class TelegramChannelPluginAdapter implements IChannelPlugin { const DEFAULT_GATEWAY_URL = 'http://localhost:14242'; +function requiredDiscordAllowlist(name: string): string[] { + const value = process.env[name] + ?.split(',') + .map((id: string): string => id.trim()) + .filter((id: string): boolean => id.length > 0); + if (!value || value.length === 0) { + throw new Error(`${name} is required when DISCORD_BOT_TOKEN is configured`); + } + return value; +} + +function optionalPositiveInteger(name: string): number | undefined { + const raw = process.env[name]; + if (raw === undefined) return undefined; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer when configured`); + } + return value; +} + function createPluginRegistry(): IChannelPlugin[] { const plugins: IChannelPlugin[] = []; const discordToken = process.env['DISCORD_BOT_TOKEN']; const discordGuildId = process.env['DISCORD_GUILD_ID']; const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL; + const discordServiceToken = process.env['DISCORD_SERVICE_TOKEN']; + const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; if (discordToken) { + if (!discordServiceToken || !discordServiceUserId) { + throw new Error( + 'DISCORD_SERVICE_TOKEN and DISCORD_SERVICE_USER_ID are required when DISCORD_BOT_TOKEN is configured', + ); + } plugins.push( new DiscordChannelPluginAdapter( new DiscordPlugin({ token: discordToken, guildId: discordGuildId, gatewayUrl: discordGatewayUrl, + serviceToken: discordServiceToken, + messageRateLimitPerMinute: optionalPositiveInteger( + 'DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE', + ), + threadRateLimitPerMinute: optionalPositiveInteger('DISCORD_THREAD_RATE_LIMIT_PER_MINUTE'), + allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), + allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), + allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), + interactionBindings: parseDiscordInteractionBindings( + process.env['DISCORD_INTERACTION_BINDINGS'], + ), }), ), ); diff --git a/apps/gateway/src/preferences/system-override.service.spec.ts b/apps/gateway/src/preferences/system-override.service.spec.ts new file mode 100644 index 00000000..8d65080a --- /dev/null +++ b/apps/gateway/src/preferences/system-override.service.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import type { MosaicConfig } from '@mosaicstack/config'; +import { SystemOverrideService } from './system-override.service.js'; + +const localConfig = { queue: { type: 'local' } } as MosaicConfig; + +describe('SystemOverrideService local tier', () => { + it('keeps ephemeral overrides isolated by tenant and user scope', async () => { + const service = new SystemOverrideService(localConfig); + const firstScope = { tenantId: 'tenant-a', userId: 'user-a' }; + const secondScope = { tenantId: 'tenant-b', userId: 'user-b' }; + + await service.set('shared-session', 'first override', firstScope); + await service.set('shared-session', 'second override', secondScope); + + await expect(service.get('shared-session', firstScope)).resolves.toBe('first override'); + await expect(service.get('shared-session', secondScope)).resolves.toBe('second override'); + + await service.clear('shared-session', firstScope); + await expect(service.get('shared-session', firstScope)).resolves.toBeNull(); + await expect(service.get('shared-session', secondScope)).resolves.toBe('second override'); + }); +}); diff --git a/apps/gateway/src/preferences/system-override.service.ts b/apps/gateway/src/preferences/system-override.service.ts index e35ed631..06cfb68c 100644 --- a/apps/gateway/src/preferences/system-override.service.ts +++ b/apps/gateway/src/preferences/system-override.service.ts @@ -1,11 +1,15 @@ import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common'; import { createQueue, type QueueHandle } from '@mosaicstack/queue'; import type { MosaicConfig } from '@mosaicstack/config'; +import type { ActorTenantScope } from '../auth/session-scope.js'; import { MOSAIC_CONFIG } from '../config/config.module.js'; -const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`; -const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) => - `mosaic:session:${sessionId}:system:fragments`; +const scopedSessionId = (sessionId: string, scope: ActorTenantScope) => + `${scope.tenantId}:${scope.userId}:${sessionId}`; +const SESSION_SYSTEM_KEY = (sessionId: string, scope: ActorTenantScope) => + `mosaic:session:${scopedSessionId(sessionId, scope)}:system`; +const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string, scope: ActorTenantScope) => + `mosaic:session:${scopedSessionId(sessionId, scope)}:system:fragments`; const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days interface OverrideFragment { @@ -22,12 +26,7 @@ interface LocalOverrideEntry { export class SystemOverrideService implements OnApplicationShutdown { private readonly logger = new Logger(SystemOverrideService.name); private readonly handle: QueueHandle | null; - /** - * In-memory fallback used on Local tier (no Redis). - * NOTE: state is ephemeral — lost on restart. For Local single-user installs - * this is acceptable; system overrides are re-applied at the next session. - * This is a deliberate behavior change from the Redis-backed 7-day TTL. - */ + /** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */ private readonly localStore = new Map(); constructor( @@ -35,26 +34,22 @@ export class SystemOverrideService implements OnApplicationShutdown { @Inject(MOSAIC_CONFIG) private readonly mosaicConfig: MosaicConfig | null, ) { - if (this.mosaicConfig?.queue?.type === 'local') { - this.handle = null; - } else { - this.handle = createQueue(); - } + this.handle = this.mosaicConfig?.queue?.type === 'local' ? null : createQueue(); } async onApplicationShutdown(): Promise { - // On non-local tiers the constructor opens an ioredis connection; close it - // on graceful shutdown to avoid leaking the handle (local tier is null). await this.handle?.close().catch(() => {}); } - async set(sessionId: string, override: string): Promise { + async set(sessionId: string, override: string, scope: ActorTenantScope): Promise { if (!this.handle) { - // Local tier: in-memory path - const entry = this.localStore.get(sessionId) ?? { condensed: '', fragments: [] }; + const key = scopedSessionId(sessionId, scope); + const entry = this.localStore.get(key) ?? { condensed: '', fragments: [] }; entry.fragments.push({ text: override, addedAt: Date.now() }); - entry.condensed = await this.condenseOverrides(entry.fragments.map((f) => f.text)); - this.localStore.set(sessionId, entry); + entry.condensed = await this.condenseOverrides( + entry.fragments.map((fragment) => fragment.text), + ); + this.localStore.set(key, entry); this.logger.debug( `Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`, ); @@ -62,7 +57,7 @@ export class SystemOverrideService implements OnApplicationShutdown { } // Load existing fragments - const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId)); + const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope)); const fragments: OverrideFragment[] = existing ? (JSON.parse(existing) as OverrideFragment[]) : []; @@ -77,11 +72,11 @@ export class SystemOverrideService implements OnApplicationShutdown { // Store both: fragments array and condensed result const pipeline = this.handle.redis.pipeline(); pipeline.setex( - SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), + SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, JSON.stringify(fragments), ); - pipeline.setex(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS, condensed); + pipeline.setex(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, condensed); await pipeline.exec(); this.logger.debug( @@ -89,33 +84,30 @@ export class SystemOverrideService implements OnApplicationShutdown { ); } - async get(sessionId: string): Promise { + async get(sessionId: string, scope: ActorTenantScope): Promise { if (!this.handle) { - return this.localStore.get(sessionId)?.condensed ?? null; + return this.localStore.get(scopedSessionId(sessionId, scope))?.condensed ?? null; } - return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId)); + return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope)); } - async renew(sessionId: string): Promise { - if (!this.handle) { - // Local tier: no TTL to renew; entry persists until restart - return; - } + async renew(sessionId: string, scope: ActorTenantScope): Promise { + if (!this.handle) return; const pipeline = this.handle.redis.pipeline(); - pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS); - pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS); + pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS); + pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS); await pipeline.exec(); } - async clear(sessionId: string): Promise { + async clear(sessionId: string, scope: ActorTenantScope): Promise { if (!this.handle) { - this.localStore.delete(sessionId); + this.localStore.delete(scopedSessionId(sessionId, scope)); this.logger.debug(`Cleared system override for session ${sessionId} (local)`); return; } await this.handle.redis.del( - SESSION_SYSTEM_KEY(sessionId), - SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), + SESSION_SYSTEM_KEY(sessionId, scope), + SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), ); this.logger.debug(`Cleared system override for session ${sessionId}`); } diff --git a/apps/gateway/src/queue/queue.service.spec.ts b/apps/gateway/src/queue/queue.service.spec.ts index 85f1e641..94e86a02 100644 --- a/apps/gateway/src/queue/queue.service.spec.ts +++ b/apps/gateway/src/queue/queue.service.spec.ts @@ -17,6 +17,7 @@ describe('QueueService local tier', () => { await expect( service.addRepeatableJob('mosaic-test', 'local-noop', {}, '* * * * *'), ).resolves.toBeUndefined(); + await expect(service.removeRepeatableJobs('mosaic-test', 'local-noop')).resolves.toBe(0); await expect(service.getHealthStatus()).resolves.toEqual({ queues: {}, healthy: true }); await expect(service.listJobs()).resolves.toEqual([]); await expect(service.retryJob('mosaic-test__1')).resolves.toEqual({ diff --git a/apps/gateway/src/queue/queue.service.ts b/apps/gateway/src/queue/queue.service.ts index 6e84340a..76f6268c 100644 --- a/apps/gateway/src/queue/queue.service.ts +++ b/apps/gateway/src/queue/queue.service.ts @@ -194,6 +194,30 @@ export class QueueService implements OnModuleInit, OnModuleDestroy { ); } + /** + * Remove every existing repeatable schedule for a job name. This supports + * safe retirement of previously registered system-wide jobs. + */ + async removeRepeatableJobs(queueName: string, jobName: string): Promise { + if (!this.enabled) { + this.logger.debug( + `Skipping repeatable-job removal for "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`, + ); + return 0; + } + const queue = this.getQueue(queueName); + if (!queue) return 0; + const jobs = await queue.getRepeatableJobs(); + const matchingJobs = jobs.filter((job) => job.name === jobName); + await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key))); + if (matchingJobs.length > 0) { + this.logger.log( + `Removed ${matchingJobs.length} repeatable "${jobName}" job(s) from "${queueName}"`, + ); + } + return matchingJobs.length; + } + /** * Register a Worker for the given queue name with error handling and * exponential backoff. diff --git a/apps/web/package.json b/apps/web/package.json index 519f64af..51ed3daa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,7 @@ "version": "0.0.2", "private": true, "scripts": { - "build": "next build", + "build": "node ../../scripts/build-web.mjs", "dev": "next dev", "lint": "eslint src", "typecheck": "tsc --noEmit", diff --git a/docs/MISSION-MANIFEST.md b/docs/MISSION-MANIFEST.md index 812cd3a9..8baa41e8 100644 --- a/docs/MISSION-MANIFEST.md +++ b/docs/MISSION-MANIFEST.md @@ -10,9 +10,9 @@ **Statement:** Ship a self-hosted, multi-user AI agent platform that consolidates the user's disparate jarvis-brain usage across home and USC workstations into a single coherent system reachable via three first-class surfaces — webUI, TUI, and CLI — with federation as the data-layer mechanism that makes cross-host agent sessions work in real time without copying user data across the boundary. **Phase:** Execution (workstream W1 in planning-complete state) **Current Workstream:** W1 — Federation v1 -**Progress:** 0 / 1 declared workstreams complete (more workstreams will be declared as scope is refined) +**Progress:** 0 / 3 declared workstreams complete (more workstreams will be declared as scope is refined) **Status:** active (continuous since 2026-03-13) -**Last Updated:** 2026-04-19 (manifest authored at the rollup level; install-ux-v2 archived; W1 federation planning landed via PR #468) +**Last Updated:** 2026-07-14 (W3 Native Kanban/SOT canon independently approved under issue #751) **Source PRD:** [docs/PRD.md](./PRD.md) — Mosaic Stack v0.1.0 **Scratchpad:** [docs/scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md) (active since 2026-03-13; 14 prior sessions of phase-based execution) @@ -67,10 +67,12 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c ## Workstreams -| # | ID | Name | Status | Manifest | Notes | -| --- | --- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- | -| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed | -| W2+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated | +| # | ID | Name | Status | Manifest | Notes | +| --- | ---- | ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed | +| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready | +| W3 | KBN | Native Kanban and canonical task SOT | planning-complete | [docs/native-kanban-sot/MISSION-MANIFEST.md](./native-kanban-sot/MISSION-MANIFEST.md) | P0–P3; issue #751; implementation held until canon merge | +| W4+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated | ### Likely Additional Workstreams (Not Yet Declared) diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 499320aa..c1703e70 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -149,15 +149,9 @@ for any `` components added in the future. --- -## How to Apply +## Held future procedure -```bash -# Run the DB migration (requires a live DB) -pnpm --filter @mosaicstack/db exec drizzle-kit migrate - -# Or, in Docker/Swarm — migrations run automatically on gateway startup -# via runMigrations() in packages/db/src/migrate.ts -``` +This report is non-operative evidence, not a current runbook. Until **KBN-101-00, KBN-101-03, and KBN-101-05** land, do not execute a PostgreSQL runner from this checkout. The approved future procedure is exactly: external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. Deployment will supply the reviewed runner, migration-only credentials, and TLS material; Gateway startup only verifies readiness. --- diff --git a/docs/PRD.md b/docs/PRD.md index 9d0db596..77ccd609 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -79,6 +79,364 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and --- +## 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. + +--- + +## 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` | M1–M5 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. + +--- + +## 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. + +--- + ## Architecture ### High-Level System Diagram @@ -433,7 +791,8 @@ Discord remote control channel. Architecture inspired by OpenClaw (https://githu - Single-guild binding only (v0.1.0) — prevents data leaks between servers - Receives Discord messages, dispatches through gateway routing - Streams agent responses back to Discord (chunked for 2000-char limit) -- Supports mention-based activation, thread management for multi-turn +- Routes authorized untagged messages in-channel; mentions create threads (or reuse the same message's attached thread) for multi-turn topics +- Uses stable logical-agent/channel conversation addresses independent of the active harness/provider - Bot pairing and permission management (Discord user → Mosaic user mapping) - DM support for private conversations @@ -606,10 +965,12 @@ Telegram remote control channel. ### FR-9: Remote Control — Discord -- Discord bot that connects to the gateway -- Mention-based activation in channels +- Discord bot that connects to the gateway through a transport-neutral channel adapter contract +- Authorized messages in configured agent-bound channels work without a mention and respond in-channel +- Mentions in parent channels create threads, or reuse a thread already attached to that same native message, for multi-turn conversations +- Messages already in a thread remain there without requiring repeated mentions +- Stable logical-agent/channel conversation identity survives underlying harness/provider changes - DM support for private conversations -- Thread creation for multi-turn conversations - Chunked message delivery (Discord 2000-char limit) - Bot configuration via web dashboard - Permission management (which Discord users/roles can interact) @@ -793,10 +1154,13 @@ Telegram remote control channel. ### AC-3: Discord Remote Control -- [ ] Discord bot connects and responds to mentions -- [ ] Messages route through gateway to agent pool +- [ ] Discord bot connects through the harness-neutral channel contract +- [ ] Authorized untagged channel messages route through the gateway and respond in-channel +- [ ] Mentioned parent-channel messages create a thread (or reuse their already-attached thread) and respond there +- [ ] Existing-thread follow-ups stay in the thread without repeated mentions +- [ ] Channel/session identity remains stable while the underlying harness/provider changes - [ ] Responses stream back to Discord (chunked) -- [ ] Thread creation for multi-turn conversations +- [ ] Unauthorized guilds, channels, users, pairings, and roles create no thread and dispatch no message ### AC-4: Gateway Orchestration @@ -840,10 +1204,10 @@ Telegram remote control channel. ### AC-10: Deployment -- [ ] `docker compose up` starts full stack from clean state -- [ ] `mosaic` CLI installable and functional on bare metal -- [ ] Database migrations run automatically on first start -- [ ] `.env.example` documents all required configuration +- [ ] PGlite data-layer work uses no PostgreSQL; optional Compose services are selected individually and do not start PostgreSQL; Gateway/Web local start remains held until KBN-101-02 rejects daemon/inherited/project DSNs before connection or DDL +- [ ] PostgreSQL/federated activation is unavailable until KBN-101-00/-03/-05 deliver external bootstrap, TLS/roles, runner `--run`, runner `--verify`, and Gateway/Compose readiness in that order +- [ ] `mosaic` CLI installable and functional on bare metal after the reviewed KBN-101-05 secret-renderer/process-exec or `LoadCredential` interface exists +- [ ] Local-only configuration documentation is distinct from production generation-pinned Vault-rendered consumer material ### AC-11: @mosaicstack/\* Packages @@ -995,7 +1359,7 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release. 6. ASSUMPTION: **Log summarization uses Haiku-tier LLM by default, configurable.** Haiku is well-suited for summarization (compression, not generation — source material is in context). Guardrails: structured output via Zod schema (force extraction of decisions/tools/outcomes/errors as discrete fields), chunked per-session processing (no bulk conflation), extraction-focused prompts. Raw logs stay in hot tier (7 days) as safety net. Users can override the summarization model via routing engine config if they want higher fidelity. Rationale: Haiku is 10-20x cheaper than Sonnet; log summarization runs on schedule against large volumes where cost matters. -7. ASSUMPTION: **Discord plugin starts minimal and single-guild only** — DM support, mention-based channel activation, thread management, chunked responses. Single guild binding to prevent data leaks between servers. Advanced features (voice, components, slash commands, multi-guild) are post-beta. Rationale: Proven pattern from OpenClaw; ship core interaction first; data isolation is non-negotiable. +7. ASSUMPTION: **Discord plugin starts minimal and single-guild only** — explicitly configured agent-bound channels accept authorized untagged messages in-channel, while mentions create threads or reuse a thread already attached to that same native message; responses are chunked. Single guild binding prevents data leaks between servers. DM support, voice, components, slash commands, and multi-guild operation are post-beta. Rationale: Ship the requested core interaction model while preserving default-deny data isolation. 8. ASSUMPTION: **Telegram plugin is lower priority than Discord** and may ship as v0.0.7 or later if Discord takes longer than expected. Rationale: Jason indicated Discord as the high-priority remote channel. diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md new file mode 100644 index 00000000..3f5a296c --- /dev/null +++ b/docs/SITEMAP.md @@ -0,0 +1,103 @@ +# Documentation Sitemap + +## Compaction refresh lease broker + +- [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings. +- [Broker operations](guides/lease-broker-operations.md) — protected paths, startup, constrained recovery, fail-closed posture, distinct-principal deployment, and residual risk. +- [Constrained recovery skill](../packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md) — source-resident thin wrapper, receipt scope, C4 replay boundary, and T-C middle-drop disclosure. +- [Lease-broker security notes](architecture/lease-broker-security.md) — identity, whole-class authorization, threat boundaries, and coordinator review requirements. +- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary. +- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window. + +## CLI and skill management + +- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior. +- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes. + +## Fleet configuration management + +- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map. +- [Desired, derived, and observed state](fleet/concepts/desired-vs-observed-state.md) — roster authority, generation, ownership, and drift. +- [Identity, class, and runtime](fleet/concepts/identity-class-runtime.md) — stable name, display alias, class, runtime, provider, and model separation. +- [Role authority and leases](fleet/concepts/role-authority-and-leases.md) — validator/merge-gate separation and bounded lease authority. +- [Generated launch chain](fleet/concepts/generated-env-launch-chain.md) — strict data parsing, precedence, and quarantine. +- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — schema, supported values, required fields, defaults, and constraints. +- [Fleet CLI reference](fleet/reference/cli.md) — local desired-state commands, JSON/exit behavior, and gateway-catalog separation. +- [Lifecycle transitions](fleet/reference/lifecycle-transitions.md) — create/apply/reboot/migration/rollback boundaries. +- [Status and drift](fleet/reference/status-and-drift.md) — desired/managed/observed state and current/future classifications. +- [Safe agent CRUD](fleet/how-to/create-update-delete-agent.md) — expected generation, dry-run, and partial-failure recovery. +- [Local lifecycle operations](fleet/how-to/start-stop-restart.md) — persisted versus one-shot actions. +- [Configurable interaction instance](fleet/how-to/configure-tess-interaction.md) and [validator instance](fleet/how-to/configure-ultron-validator.md) — generic identities and protected limits. +- [Reconcile and recover](fleet/operations/reconcile-and-recover.md) — plan/apply lock and recovery behavior. +- [Environment quarantine](fleet/operations/env-quarantine.md) — private evidence and value-free diagnostics. +- [Systemd/tmux troubleshooting](fleet/operations/systemd-tmux-troubleshooting.md) — socket, holder, unmanaged-session, and lock decisions. +- [Backup/restore boundary](fleet/operations/backup-restore.md) and [upgrade-assets hold](fleet/operations/upgrade-assets.md). +- [v1-to-v2 migration preview](fleet/migration/v1-to-v2.md) and [executable artifact dispositions](fleet/migration/example-profile-disposition.md). +- [FCM M5 closure evidence](reports/documentation/758-fleet-config-ia-closure.md) and [approved deferrals](reports/deferred/758-fleet-config-deferrals.md). + +## Official channel plugins + +- [Channel protocol architecture](architecture/channel-protocol.md) — shared lifecycle, message, stable-route, authorization, and response-target contracts. +- [Discord administrator configuration](guides/admin-guide.md#discord-ingress-security) — secrets, allowlists, bindings, role policy, and thread permissions. +- [Discord user workflow](tess/USER-GUIDE.md#discord-conversations) — in-channel messages, mention-created threads, and runtime-transparent continuity. +- [Channel plugin authoring](tess/PLUGIN-GUIDE.md#official-channel-adapter-contract) — requirements for future Matrix, Slack, and other official adapters. +- [Discord package guide](../plugins/discord/README.md) — package behavior, configuration shape, and development commands. + +## Native Kanban and canonical task SOT + +- [Canonical requirements](requirements/native-kanban-sot.md) — ratified P0–P3 requirements and acceptance criteria. +- [Workstream index](native-kanban-sot/INDEX.md) — artifact map, lane partition, and delivery order. +- [Mission manifest](native-kanban-sot/MISSION-MANIFEST.md) — scope, authority, invariants, and gate model. +- [Task decomposition](native-kanban-sot/TASKS.md) — dependency-ordered implementation slices and ownership boundaries. +- [KBN-101 database role split](native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md) — rc.16 direct-Drizzle storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README/user-guide wrapper forms fail before masking and source-consistency rejects runner-delegation copy; held bootstrap → TLS/roles → run → verify → readiness; plus prior attestation, pgvector owner, classifier, TLS, activation, and certification prerequisite. +- [Federated tier data migration](guides/migrate-tier.md) — active KBN-101-07 operator route: runner-produced target attestation, dedicated non-DDL importer, and paired credential-/attestation-file references only. +- [Frozen shared contract](native-kanban-sot/SHARED-CONTRACT.md) — schema, API, Coordinator, health, recovery, and migration contracts. +- [KBN-101 exact-head security review](reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) — retained prior REQUEST CHANGES evidence for `da742ca`; rc.16 awaits independent exact-head re-review after closing the current generic storage-wrapper authority HIGH finding. +- [Initial independent review](reports/native-kanban-sot/canon-initial-review-no-go.md) — KCR-001–016 findings that blocked the first draft. +- [Final independent re-review](reports/native-kanban-sot/canon-final-rereview-go.md) — closure evidence and GO verdict. +- [Ultron final gate](reports/native-kanban-sot/ultron-final-go.md) — final requirements, authority, schema, migration, recovery, and evidence review. + +## Tess interaction agent + +### Operator guides + +- [User guide](tess/USER-GUIDE.md) — authorized session, attach, send, stop, and handoff workflows. +- [Admin guide](tess/ADMIN-GUIDE.md) — deployment configuration, policy, and approval controls. +- [Developer guide](tess/DEVELOPER-GUIDE.md) — provider contracts, scope boundaries, and test workflow. +- [Plugin guide](tess/PLUGIN-GUIDE.md) — adapter, redaction, and identity-as-data requirements. +- [Operations guide](tess/OPERATIONS-GUIDE.md) — readiness, recovery, and incident-safe procedures. + +### Architecture and security + +- [Architecture](tess/ARCHITECTURE.md) +- [Threat model](tess/THREAT-MODEL.md) +- [Mos coordination boundary](tess/MOS-COORDINATION.md) +- [Hermes runtime adapter design](tess/hermes-runtime-adapter-design.md) +- [Operator plugin sketch](tess/M4-003-OPERATOR-PLUGIN-SKETCH.md) + +### API contract + +- [Tess OpenAPI contract](openapi-tess.yaml) + +### Migration and qualification + +- [Migration inventory](tess/M5-MIGRATION-INVENTORY.md) +- [Cutover procedure](tess/M5-MIGRATION-CUTOVER.md) +- [Rollback procedure](tess/M5-MIGRATION-ROLLBACK.md) +- [Retention and deprecation evidence](tess/M5-MIGRATION-RETENTION-DEPRECATION.md) +- [Verification matrix](tess/VERIFICATION-MATRIX.md) +- [Documentation checklist](tess/M5-003-DOCUMENTATION-CHECKLIST.md) +- [Independent Option 2 runtime-portability qualification (2026-07-14)](tess/qualification/2026-07-14-option2-runtime-portability.md) + +## Runtime-neutral Mos portability + +- [Optional AI egress gateway ADR](architecture/ADR-MOS-EGRESS-GATEWAYS.md) — placement and gates for LiteLLM, Bifrost, and purpose-built translation proxies. +- [Runtime-neutral Mos identity and failover mission](https://git.mosaicstack.dev/mosaicstack/stack/issues/754) +- [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755) +- [M1 logical identity and fencing architecture](architecture/mos-runtime-portability-m1.md) +- [M1 connector lease operations](guides/mos-connector-lease-operations.md) + +## Comms evolution — Matrix-native MACP (design, draft) + +- [RFC-001 — MACP: a Mosaic-native, Matrix-native comms layer](rfcs/RFC-001-MACP-MATRIX-NATIVE.md) — Synapse + Mosaic appservice backbone, MACP v1 protocol, presence/escalation, federation, strangler migration off the Hermes MCP bridge. +- [RFC-002 — Install, configuration & topology for the Matrix/MACP comms system](rfcs/RFC-002-INSTALL-CONFIG-TOPOLOGY.md) — open-source install topology modes, ACME cert provisioning, pluggable secret backend, and config precedence. diff --git a/docs/TASKS.md b/docs/TASKS.md index 8d85fa03..afb5f00d 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -14,9 +14,12 @@ ## Workstream Rollup -| id | status | workstream | progress | tasks file | notes | -| --- | ----------------- | ------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- | -| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning | +| id | status | workstream | progress | tasks file | notes | +| --- | ----------------- | ------------------------------ | ---------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning | +| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready | +| W3 | planning-complete | Native Kanban/SOT | 0 / 4 phases | [docs/native-kanban-sot/TASKS.md](./native-kanban-sot/TASKS.md) | Issue #751; canon independently approved; implementation held until canon merges | +| W4 | planning-complete | Fleet configuration management | 0 / 12 cards | This file (§ Fleet configuration management #758) | Issue #758; M0 docs gate defines the implementation DAG before any fleet mutation | ## Cross-Cutting Tracking @@ -40,6 +43,30 @@ Active workstream is **W1 — Federation v1**. Workers should: 2. Read [docs/federation/TASKS.md](./federation/TASKS.md) for the next pending task 3. Follow per-task agent + tier guidance from the workstream manifest +## Fleet configuration management (#758) — M0–M5 implementation DAG + +> **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 +> the repository quality gates, independent code and security review, terminal-green CI, and +> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes. + +| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes | +| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 | +| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation | +| FCM-M1-002 | done | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | #768 squash `a5e8e55`; shared resolver and canonical authority/alias validation delivered | +| FCM-M1-003 | done | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | #770 squash `e9c4aa3`; shipped artifact disposition validation delivered | +| FCM-M2-001 | done | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | #772 squash `191efae`; generated/local boundary and private quarantine delivered | +| FCM-M2-002 | done | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | #773 squash `bc5e736`; generation-guarded atomic CRUD and recovery contracts delivered | +| FCM-M3-001 | done | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | #785 squash `4990905`; exact roster-owned systemd/tmux reconcile and lifecycle contracts delivered | +| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only | +| FCM-M4-001 | done | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | PR #788; final head `d63bb0206a1d312ab8352ec1d3ca3631146b0baa`; tree `4da210da9a71b035130d4160a4a2e691bdfde2da`; squash `9745bc3f29c26b021a478b7ad03cfb494f6c9de3`; descendant-main pipeline 1855 terminal success | +| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | HOLD: never starts a previously stopped agent or kills an unproven unmanaged session; not authorized by FCM-M5-001 | +| FCM-M5-001 | done | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | #789 content squash 627cf2bb; de-flake repair PR#851/#849 squash 77c9a826; completion proof wp1937 @aa999daf push/ci step 49632 recovery_runtime_unittest.py 3/3 OK (closes wp1932 step 49576 Errno111) | +| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | HOLD: final #758 gate; quality, independent code/security review, validator certificate, merge-gate approval, and green CI remain out of M5-001 | + ## Thin-core prompt diet (#528) — feat/contract-thin-core - Status: PR open, awaiting maintainer merge ratification (fleet-governing change). diff --git a/docs/architecture/ADR-MOS-EGRESS-GATEWAYS.md b/docs/architecture/ADR-MOS-EGRESS-GATEWAYS.md new file mode 100644 index 00000000..de479f8f --- /dev/null +++ b/docs/architecture/ADR-MOS-EGRESS-GATEWAYS.md @@ -0,0 +1,151 @@ +# ADR: Optional AI egress gateways for runtime-neutral Mos + +**Status:** Proposed for controlled prototypes; not approved as Mosaic core + +**Date:** 2026-07-14 + +**Issues:** #754, #755 + +**Decision owner:** Mosaic Gateway / provider-adapter architecture + +## Context + +The emergency Mos continuity path kept Claude Code as the harness and translated Anthropic Messages traffic to Codex OAuth through a small localhost proxy. That preserved the existing Claude Discord plugin and transcript, but exposed two architectural facts: + +1. Harness identity, channel entitlement, provider credentials, and inference transport are separate concerns. +2. A generic AI gateway can improve provider routing, budgets, and observability, but must not become Mosaic's identity, authorization, tenant, or orchestration boundary. + +The Tess qualification report also found that current provider rebinding is not identity-continuous failover. Mosaic still needs a logical agent identity, durable connector lease/fencing, canonical handoff/checkpoint, exactly-once receipts, concrete harness adapters, and cross-harness rollback E2E. + +## Decision + +Mosaic MAY support LiteLLM, Bifrost, the purpose-built Claude/Codex proxy, or future gateways as optional egress implementations behind `IProviderAdapter` / `AgentRuntimeProvider`. + +Mosaic Gateway remains authoritative for: + +- authenticated actor and tenant identity; +- logical agent identity and connector binding; +- authorization, approval, and policy; +- lease epoch and stale-holder fencing; +- audit correlation and redaction; +- canonical handoff/checkpoint state; +- idempotency and side-effect receipts. + +An egress gateway MUST NOT: + +- receive channel ingress directly; +- authorize tools or connector ownership; +- define Mosaic tenant or agent identity; +- persist raw Mosaic handoffs or channel credentials; +- bypass adapter capability negotiation; +- silently fail over when policy, lease, or provider health is uncertain. + +Allowed topology: + +```text +Discord / Matrix / CLI / web + ↓ +Mosaic Gateway: identity, authz, lease/fence, approvals, audit + ↓ +IProviderAdapter / AgentRuntimeProvider + ↓ +optional egress gateway + ↓ +upstream provider or subscription-backed OAuth session +``` + +## Candidate assessment + +### Purpose-built `raine/claude-code-proxy` + +**Disposition:** Approved only for the verified emergency localhost bridge. + +Strengths: + +- explicit Codex device OAuth flow; +- small operational surface; +- Anthropic Messages translation suitable for Claude Code; +- model and reasoning-effort enforcement; +- straightforward loopback systemd supervision and rollback. + +Constraints: + +- not a Mosaic multi-tenant control plane; +- Claude built-in channels still depend on Claude subscription entitlement and feature lookup; +- model aliases can obscure the upstream model unless proxy policy/logs are treated as evidence; +- no replacement for connector leasing, canonical handoff, or exactly-once effects. + +### LiteLLM + +**Disposition:** Candidate for a formal adapter-only prototype and terms/security review. + +Current documentation states that ChatGPT subscription access is available through an OAuth device-code flow. LiteLLM also provides broad provider routing, virtual keys, budgets, observability, and OpenAI/Anthropic-compatible surfaces. + +Required prototype gates: + +- verify the exact ChatGPT subscription OAuth flow and supported models against current provider terms; +- document token location, encryption, revocation, refresh, scope, and incident response; +- prove tenant isolation and prevent virtual keys from becoming Mosaic principals; +- verify streaming, tool calls, reasoning controls, cancellation, and idempotency metadata; +- fail closed instead of selecting an unhealthy provider merely to return a result; +- demonstrate that Mosaic audit correlation survives gateway retries/failover; +- keep channel ingress and connector credentials outside LiteLLM. + +Source references: + +- [LiteLLM ChatGPT subscription provider](https://docs.litellm.ai/docs/providers/chatgpt) +- [LiteLLM providers](https://docs.litellm.ai/docs/providers) + +### Bifrost + +**Disposition:** Candidate for governance/routing research; subscription OAuth compatibility unverified. + +Useful concepts include virtual keys, budgets, rate limits, weighted load balancing, and automatic provider failover. Those features may inform Mosaic egress policy, but Bifrost virtual keys are downstream credentials—not Mosaic actors or tenants. + +Required prototype gates: + +- verify Codex/ChatGPT subscription OAuth rather than assuming API-key compatibility; +- map budgets and virtual keys to server-derived Mosaic tenants without duplicating authority; +- prove failover does not violate connector lease, approval, or exactly-once semantics; +- ensure request/response logs are redacted before persistence; +- disable or constrain automatic failover when policy or side-effect state is ambiguous. + +Source references: + +- [Bifrost overview](https://docs.getbifrost.ai/overview) +- [Bifrost repository](https://github.com/maximhq/bifrost) + +### `teremterem/claude-code-gpt-5-codex` + +**Disposition:** Not selected as the emergency implementation; useful as a historical LiteLLM recipe. + +The reviewed repository uses `OPENAI_API_KEY`, tells previously authenticated Claude users to log out, and documents a Claude Web Search schema incompatibility. Logging Claude out conflicts with the channel-entitlement requirement observed in the live Mos cutover. The repository therefore does not, as provided, satisfy subscription-OAuth plus built-in-channel continuity. + +Source references: + +- [Repository](https://github.com/teremterem/claude-code-gpt-5-codex) +- [Environment template](https://github.com/teremterem/claude-code-gpt-5-codex/blob/main/.env.template) + +## Security consequences + +- Subscription OAuth grants are high-value credentials and require the same lifecycle controls as service credentials. +- Downstream virtual keys reduce provider-key exposure but do not establish user, tenant, or agent authority. +- Automatic retry/failover can duplicate tool or external side effects unless Mosaic owns operation IDs and receipts. +- Gateway telemetry can contain prompts, tool schemas, and model output; redaction and retention policy must apply before persistence. +- A localhost unauthenticated translation endpoint must remain loopback-only and process-isolated. + +## Acceptance before production use + +1. Threat model and provider-terms review approved. +2. Credential lifecycle and revocation drill documented and exercised. +3. Adapter contract tests pass for streaming, tools, cancellation, reasoning policy, errors, and audit correlation. +4. Tenant-bound authorization remains entirely in Mosaic Gateway. +5. Failure injection proves no duplicate side effects across retries or provider failover. +6. Rollback to the prior provider path is exercised. +7. Independent code and security reviews approve the exact deployed revision. + +## Follow-up + +- #754 owns cross-harness logical identity, checkpoint, receipt, adapter, and failover work. +- #755 / PR #757 implements the first logical identity and connector lease/fencing boundary. +- A later issue should prototype LiteLLM and Bifrost behind the provider adapter after #755 is merged and independently qualified. diff --git a/docs/architecture/channel-protocol.md b/docs/architecture/channel-protocol.md index ad96cbd1..d2f8c35f 100644 --- a/docs/architecture/channel-protocol.md +++ b/docs/architecture/channel-protocol.md @@ -1,9 +1,9 @@ # Channel Protocol Architecture -**Status:** Draft +**Status:** Official adapter baseline implemented by #756; extended registry/multiplexing remains iterative **Authors:** Mosaic Core Team -**Last Updated:** 2026-03-22 -**Covers:** M7-001 (IChannelAdapter interface), M7-002 (ChannelMessage protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix) +**Last Updated:** 2026-07-14 +**Covers:** M7-001 (OfficialChannelAdapter interface), M7-002 (ChannelMessageDto protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix) --- @@ -11,93 +11,80 @@ The channel protocol defines a unified abstraction layer between Mosaic's core messaging infrastructure and the external communication channels it supports (Matrix, Discord, Telegram, TUI, WebUI, and future channels). -The protocol consists of two main contracts: +The implemented baseline is exported from `@mosaicstack/types` and consists of four contract groups: -1. `IChannelAdapter` — the interface each channel driver must implement. -2. `ChannelMessage` — the canonical message format that flows through the system. +1. `OfficialChannelAdapter` — transport lifecycle and connection health. +2. `ChannelMessageDto` / `ChannelAttachmentDto` — canonical transport data. +3. `ChannelConversationRouteDto` — stable logical-agent conversation and authorization address. +4. `ChannelResponseTargetDto` — channel/thread destination for replies. -All channel-specific translation logic lives inside the adapter implementation. The rest of Mosaic works exclusively with `ChannelMessage` objects. +All channel-specific translation logic lives inside the adapter implementation. Runtime selection does not: gateway durable-session and provider services may rebind the logical session from Claude to Codex, Pi, OpenCode, or another harness without reconnecting the channel adapter. --- -## M7-001: IChannelAdapter Interface +## M7-001: OfficialChannelAdapter Interface ```typescript -interface IChannelAdapter { - /** - * Stable, lowercase identifier for this channel (e.g. "matrix", "discord"). - * Used as a namespace key in registry lookups and log metadata. - */ +interface OfficialChannelAdapter { + /** Stable, lowercase adapter identifier such as "discord" or "matrix". */ readonly name: string; - - /** - * Establish a connection to the external channel backend. - * Called once at application startup. Must be idempotent (safe to call - * when already connected). - */ - connect(): Promise; - - /** - * Gracefully disconnect from the channel backend. - * Must flush in-flight sends and release resources before resolving. - */ - disconnect(): Promise; - - /** - * Return the current health of the adapter connection. - * Used by the admin health endpoint and alerting. - * - * - "connected" — fully operational - * - "degraded" — partial connectivity (e.g. read-only, rate-limited) - * - "disconnected" — no connection to channel backend - */ - health(): Promise<{ status: 'connected' | 'degraded' | 'disconnected' }>; - - /** - * Register an inbound message handler. - * The adapter calls `handler` for every message received from the channel. - * Multiple calls replace the previous handler (last-write-wins). - * The handler is async; the adapter must not deliver new messages until - * the previous handler promise resolves (back-pressure). - */ - onMessage(handler: (msg: ChannelMessage) => Promise): void; - - /** - * Send a ChannelMessage to the given channel/room/conversation. - * `channelId` is the channel-native identifier (e.g. Matrix room ID, - * Discord channel snowflake, Telegram chat ID). - */ - sendMessage(channelId: string, msg: ChannelMessage): Promise; - - /** - * Map a channel-native user identifier to the Mosaic internal userId. - * Returns null when no matching Mosaic account exists for the given - * channelUserId (anonymous or unlinked user). - */ - mapIdentity(channelUserId: string): Promise; + /** Establish both native-channel and gateway connections. */ + start(): Promise; + /** Gracefully close connections and release resources. */ + stop(): Promise; + /** Best-effort health; ordinary disconnection is a result, not an exception. */ + health(): Promise<{ + status: 'connected' | 'degraded' | 'disconnected'; + detail?: string; + }>; } ``` +The small lifecycle seam lets the gateway host official plugins uniformly without moving native message translation into gateway core. Message ingress remains adapter-owned; gateway policy, durable session routing, auditing, and runtime/provider selection remain gateway-owned. + +### Stable conversation route + +```typescript +interface ChannelConversationRouteDto { + bindingId: string; + logicalAgentId: string; + conversationId: string; + channelName: string; + authorizationChannelId: string; + responseTarget: { channelId: string; threadId?: string }; +} +``` + +Harness, provider, model, process, and native runtime-session identifiers are forbidden from this route. Runtime adapters consume the gateway's durable logical-session binding; channel adapters consume only the stable route and response target. + +### Typed ingress and egress ports + +`ChannelIngressPort` is the transport-neutral direct-integration seam for official adapters. The current deployed Discord adapter preserves its existing HMAC-signed Socket.IO compatibility ingress so gateway-side service authentication, replay protection, approval handling, and correlation semantics remain unchanged; it normalizes the same `ChannelIngressDto` before signing. The adapter uses a supplied `ChannelIngressPort` directly when a future gateway registration provides one. New adapters must use the shared ports rather than adding channel branches to gateway core. + +`ChannelBindingDto` contains the configuration-owned workspace/channel→logical-agent mapping and paired external principals; credentials are absent. After native allowlist, pairing, and role checks pass, an adapter submits `ChannelIngressDto` to `ChannelIngressPort.receive()`. It includes the normalized message, `ChannelAuthorizedPrincipalDto`, operation, correlation ID, native message ID, and stable route. Unauthorized input never reaches the port. + +Gateway policy and runtime routing produce `ChannelEgressDto`, which `ChannelEgressPort.send()` delivers to the route's response target. Discord's existing HMAC envelope is its authenticated wire encoding of this boundary; future Matrix/Slack adapters use their native authenticated transports while preserving the same actor/operation/correlation semantics. + ### Adapter Registration -Adapters are registered with the `ChannelRegistry` service at startup. The registry calls `connect()` on each adapter and monitors `health()` on a configurable interval (default: 30 s). +Adapters are registered with the gateway plugin host at startup. The host calls `start()`/`stop()` and may monitor `health()` on a configurable interval. A richer dynamic `ChannelRegistry` remains a compatible future extension of this lifecycle contract. ``` ChannelRegistry - └── register(adapter: IChannelAdapter): void - └── getAdapter(name: string): IChannelAdapter | null - └── listAdapters(): IChannelAdapter[] + └── register(adapter: OfficialChannelAdapter): void + └── getAdapter(name: string): OfficialChannelAdapter | null + └── listAdapters(): OfficialChannelAdapter[] └── healthAll(): Promise> ``` --- -## M7-002: ChannelMessage Protocol +## M7-002: ChannelMessageDto Protocol ### Canonical Message Format ```typescript -interface ChannelMessage { +interface ChannelMessageDto { /** * Globally unique message ID. * Format: UUID v4. Generated by the adapter when receiving, or by Mosaic @@ -110,6 +97,7 @@ interface ChannelMessage { * The adapter populates this from the inbound message. * For outbound messages, the caller supplies the target channel. */ + channelName: string; channelId: string; /** @@ -119,7 +107,7 @@ interface ChannelMessage { senderId: string; /** Sender classification. */ - senderType: 'user' | 'agent' | 'system'; + senderKind: 'user' | 'agent' | 'system'; /** * Textual content of the message. @@ -136,7 +124,7 @@ interface ChannelMessage { * - "image" — binary image; content is empty, see attachments * - "file" — binary file; content is empty, see attachments */ - contentType: 'text' | 'markdown' | 'code' | 'image' | 'file'; + contentKind: 'text' | 'markdown' | 'code' | 'image' | 'file'; /** * Arbitrary key-value metadata for channel-specific extension fields. @@ -144,7 +132,7 @@ interface ChannelMessage { * Adapters should store channel-native IDs here so round-trip correlation * is possible without altering the canonical fields. */ - metadata: Record; + metadata: Readonly>; /** * Optional thread or reply-chain identifier. @@ -163,18 +151,21 @@ interface ChannelMessage { * Binary or URI-referenced attachments. * Each attachment carries its MIME type and a URL or base64 payload. */ - attachments?: ChannelAttachment[]; + attachments?: readonly ChannelAttachmentDto[]; - /** Wall-clock timestamp when the message was sent/received. */ - timestamp: Date; + /** ISO-8601 wall-clock timestamp when the message was sent/received. */ + timestamp: string; } -interface ChannelAttachment { - /** Filename or identifier. */ +interface ChannelAttachmentDto { + /** Channel-native attachment identifier. */ + id: string; + + /** Filename or display name. */ name: string; - /** MIME type (e.g. "image/png", "application/pdf"). */ - mimeType: string; + /** MIME type when supplied by the channel. */ + mimeType: string | null; /** * URL pointing to the attachment, OR a `data:` URI with base64 payload. @@ -192,23 +183,23 @@ interface ChannelAttachment { ## Channel Translation Reference -The following sections document how each supported channel maps its native message format to and from `ChannelMessage`. +The following sections document how each supported channel maps its native message format to and from `ChannelMessageDto`. ### Matrix -| ChannelMessage field | Matrix equivalent | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) | -| `channelId` | Matrix room ID (`!roomid:homeserver`) | -| `senderId` | Matrix user ID (`@user:homeserver`) | -| `senderType` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound | -| `content` | `event.content.body` | -| `contentType` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` | -| `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` | -| `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` | -| `attachments` | Populated from `url` in `m.image` / `m.file` events | -| `timestamp` | `new Date(event.origin_server_ts)` | -| `metadata` | `{ channelMessageId, roomId, eventType, unsigned }` | +| ChannelMessageDto field | Matrix equivalent | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) | +| `channelId` | Matrix room ID (`!roomid:homeserver`) | +| `senderId` | Matrix user ID (`@user:homeserver`) | +| `senderKind` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound | +| `content` | `event.content.body` | +| `contentKind` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` | +| `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` | +| `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` | +| `attachments` | Populated from `url` in `m.image` / `m.file` events | +| `timestamp` | `new Date(event.origin_server_ts)` | +| `metadata` | `{ channelMessageId, roomId, eventType, unsigned }` | **Outbound:** Adapter sends `m.room.message` with `msgtype = m.text` (or `m.notice` for system messages). Markdown content is sent with `format = org.matrix.custom.html` and a rendered HTML body. @@ -216,41 +207,58 @@ The following sections document how each supported channel maps its native messa ### Discord -| ChannelMessage field | Discord equivalent | -| -------------------- | ----------------------------------------------------------------------- | -| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake | -| `channelId` | Discord channel ID (snowflake string) | -| `senderId` | Discord user ID (snowflake) | -| `senderType` | `"user"` for human members; `"agent"` for bot messages | -| `content` | `message.content` | -| `contentType` | `"markdown"` (Discord uses a markdown-like syntax natively) | -| `threadId` | `message.thread.id` when the message is inside a thread channel | -| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` | -| `attachments` | `message.attachments` mapped to `ChannelAttachment` | -| `timestamp` | `new Date(message.timestamp)` | -| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` | +| ChannelMessageDto field | Discord equivalent | +| ----------------------- | ----------------------------------------------------------------------- | +| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake | +| `channelId` | Discord channel ID (snowflake string) | +| `senderId` | Discord user ID (snowflake) | +| `senderKind` | `"user"` for human members; `"agent"` for bot messages | +| `content` | `message.content` | +| `contentKind` | `"markdown"` (Discord uses a markdown-like syntax natively) | +| `threadId` | `message.thread.id` when the message is inside a thread channel | +| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` | +| `attachments` | `message.attachments` mapped to `ChannelAttachmentDto` | +| `timestamp` | `new Date(message.timestamp)` | +| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` | -**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag. +**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentKind = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag. + +### Discord routing and thread policy + +A configured Discord binding maps `(guildId, parentChannelId)` to a stable logical agent and a trusted gateway agent-config ID. Gateway verifies that configuration's name matches the binding logical agent before session creation. The stable conversation handle is derived from logical agent plus response channel/thread and never includes the active harness, provider, model, process, or agent-config ID. + +| Inbound location/trigger | Conversation and response target | +| ------------------------------------------ | --------------------------------------------------------------- | +| Authorized untagged parent-channel message | Parent channel; response is sent in-channel | +| Authorized bot mention in parent channel | Thread already attached to that message, or a new public thread | +| Authorized message already in a thread | Existing thread; no repeated mention and no nested thread | +| `/approve` or `/stop ` | Current parent/thread durable session; no new topic is created | + +Authorization order is fixed: guild allowlist → parent-channel allowlist → user allowlist → configured binding/pairing → operation role → per-user/channel message and thread rate limits → thread creation/dispatch. A normal Discord channel's category parent is never treated as the thread authorization parent. If requested thread creation fails, dispatch does not occur because the adapter cannot honor the response target. + +### Discord service ingress security + +The Discord adapter is an authenticated gateway service, not an anonymous Socket.IO client. It presents `DISCORD_SERVICE_TOKEN` during its `/chat` connection and signs each inbound envelope using HMAC-SHA-256. The envelope contains the Discord native message ID and a generated correlation ID. Gateway verifies the service credential, signature, and configured guild/channel/user allowlists before agent dispatch, then rejects duplicate native message IDs inside its bounded replay window. All three allowlists are default-deny and required when the Discord plugin is enabled. The service credential is injected at runtime and is never logged or included in protocol payloads. --- ### Telegram -| ChannelMessage field | Telegram equivalent | -| -------------------- | ------------------------------------------------------------------------------------------------------------- | -| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) | -| `channelId` | Telegram `chat_id` (integer as string) | -| `senderId` | Telegram `from.id` (integer as string) | -| `senderType` | `"user"` for human senders; `"agent"` for bot-originated messages | -| `content` | `message.text` or `message.caption` | -| `contentType` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` | -| `threadId` | `message.message_thread_id` (for supergroup topics) | -| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` | -| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachment` | -| `timestamp` | `new Date(message.date * 1000)` | -| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` | +| ChannelMessageDto field | Telegram equivalent | +| ----------------------- | ------------------------------------------------------------------------------------------------------------- | +| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) | +| `channelId` | Telegram `chat_id` (integer as string) | +| `senderId` | Telegram `from.id` (integer as string) | +| `senderKind` | `"user"` for human senders; `"agent"` for bot-originated messages | +| `content` | `message.text` or `message.caption` | +| `contentKind` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` | +| `threadId` | `message.message_thread_id` (for supergroup topics) | +| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` | +| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachmentDto` | +| `timestamp` | `new Date(message.date * 1000)` | +| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` | -**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentType = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`. +**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentKind = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`. --- @@ -258,19 +266,19 @@ The following sections document how each supported channel maps its native messa The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the channel protocol so that TUI sessions can be treated as a first-class channel. -| ChannelMessage field | TUI equivalent | -| -------------------- | ------------------------------------------------------------------ | -| `id` | Generated UUID (TUI has no native message IDs) | -| `channelId` | `"tui:"` — the active conversation ID | -| `senderId` | Authenticated Mosaic `userId` | -| `senderType` | `"user"` for human input; `"agent"` for agent replies | -| `content` | Raw text from stdin / agent output | -| `contentType` | `"text"` for input; `"markdown"` for agent responses | -| `threadId` | Not used (TUI sessions are linear) | -| `replyToId` | Not used | -| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs | -| `timestamp` | `new Date()` at the moment of send | -| `metadata` | `{ conversationId, sessionId, ttyWidth, colorSupport }` | +| ChannelMessageDto field | TUI equivalent | +| ----------------------- | ------------------------------------------------------------------ | +| `id` | Generated UUID (TUI has no native message IDs) | +| `channelId` | `"tui:"` — the active conversation ID | +| `senderId` | Authenticated Mosaic `userId` | +| `senderKind` | `"user"` for human input; `"agent"` for agent replies | +| `content` | Raw text from stdin / agent output | +| `contentKind` | `"text"` for input; `"markdown"` for agent responses | +| `threadId` | Not used (TUI sessions are linear) | +| `replyToId` | Not used | +| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs | +| `timestamp` | `new Date()` at the moment of send | +| `metadata` | `{ conversationId, sessionId, ttyWidth, colorSupport }` | **Outbound:** The adapter writes rendered content to stdout. Markdown is rendered via a terminal markdown renderer (e.g. `marked-terminal`). Code blocks are syntax-highlighted when `metadata.colorSupport = true`. @@ -280,19 +288,19 @@ The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the chan The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel protocol over the existing Socket.IO gateway (`apps/gateway`). -| ChannelMessage field | WebUI equivalent | -| -------------------- | ------------------------------------------------------------ | -| `id` | Generated UUID; echoed back in the WebSocket event | -| `channelId` | `"webui:"` | -| `senderId` | Authenticated Mosaic `userId` | -| `senderType` | `"user"` for browser input; `"agent"` for agent responses | -| `content` | Message text from the input field | -| `contentType` | `"text"` or `"markdown"` | -| `threadId` | Not used (conversation model handles threading) | -| `replyToId` | Message ID the user replied to (UI reply affordance) | -| `attachments` | Files uploaded via the file picker; stored to object storage | -| `timestamp` | `new Date()` at send, or server timestamp from event | -| `metadata` | `{ conversationId, sessionId, clientTimezone, userAgent }` | +| ChannelMessageDto field | WebUI equivalent | +| ----------------------- | ------------------------------------------------------------ | +| `id` | Generated UUID; echoed back in the WebSocket event | +| `channelId` | `"webui:"` | +| `senderId` | Authenticated Mosaic `userId` | +| `senderKind` | `"user"` for browser input; `"agent"` for agent responses | +| `content` | Message text from the input field | +| `contentKind` | `"text"` or `"markdown"` | +| `threadId` | Not used (conversation model handles threading) | +| `replyToId` | Message ID the user replied to (UI reply affordance) | +| `attachments` | Files uploaded via the file picker; stored to object storage | +| `timestamp` | `new Date()` at send, or server timestamp from event | +| `metadata` | `{ conversationId, sessionId, clientTimezone, userAgent }` | **Outbound:** Adapter emits a `chat:message` Socket.IO event. The WebUI React component receives it and appends to the conversation list. Markdown content is rendered client-side via the existing markdown renderer component. @@ -300,7 +308,7 @@ The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel prot ## Identity Mapping -`mapIdentity(channelUserId)` resolves a channel-native user identifier to a Mosaic `userId`. This is required to attribute inbound messages to authenticated Mosaic accounts. +Gateway identity-linking policy resolves a channel-native user identifier to a Mosaic `userId` and produces `ChannelAuthorizedPrincipalDto`. Adapters provide native identity evidence but cannot self-authorize Mosaic scope. Discord currently uses configuration-owned paired users; database-backed linking remains the canonical direction for dynamic Matrix/Slack identity. The implementation must query a `channel_identities` table (or equivalent) keyed on `(channel_name, channel_user_id)`. When no mapping exists the method returns `null` and the message is treated as anonymous (no Mosaic session context). @@ -319,8 +327,8 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out ## Error Handling Conventions -- `connect()` must throw a structured error (subclass of `ChannelConnectError`) if the initial connection cannot be established within a reasonable timeout (default: 10 s). -- `sendMessage()` must throw `ChannelSendError` on terminal failures (auth revoked, channel not found). Transient failures (rate limit, network blip) should be retried internally with exponential backoff before throwing. +- `start()` must establish the native channel transport or throw a structured connection error. An adapter hosted inside the gateway must not wait for a loopback connection to that same not-yet-listening process; it starts the native transport, lets Socket.IO reconnect, and reports `degraded` until both links are ready. +- `ChannelEgressPort.send()` implementations must throw a typed terminal error for revoked auth, an invalid route, or a missing channel. Only transient rate/network/server failures are retried with bounded exponential backoff; Discord retries reuse a stable enforced nonce to prevent duplicate chunks, while permanent 4xx failures are not retried. - `health()` must never throw — it returns `{ status: 'disconnected' }` on error. - Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability. @@ -328,7 +336,7 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out ## Versioning -The `ChannelMessage` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide. +The `ChannelMessageDto` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide. Current version: **1.0.0** @@ -469,7 +477,7 @@ A single Mosaic conversation can be accessed simultaneously from multiple surfac ### Real-Time Sync Flow 1. A message arrives on any surface (TUI keystroke, browser send, Matrix event). -2. The surface's adapter normalizes the message to `ChannelMessage` and delivers it to `ConversationService`. +2. The surface's adapter normalizes the message to `ChannelMessageDto` and delivers it to `ConversationService`. 3. `ConversationService` persists the message to PostgreSQL, assigns a canonical `id`, and publishes a `message:new` event to the Valkey pub/sub channel keyed by `conversationId`. 4. All active surfaces subscribed to that `conversationId` receive the fanout event and push it to their respective clients: - TUI adapter: writes rendered output to the connected terminal session. @@ -557,7 +565,7 @@ Matrix sessions for linked users are persistent and long-lived. Unlike TUI sessi - Their `channel_identities` row exists (link not revoked). - They remain members of the relevant Matrix rooms. -Revoking a Matrix link (`DELETE /auth/channel-link/matrix/`) removes the `channel_identities` row and causes `mapIdentity()` to return `null`. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off). +Revoking a Matrix link (`DELETE /auth/channel-link/matrix/`) removes the `channel_identities` row and causes gateway principal resolution to deny the identity. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off). --- @@ -729,7 +737,7 @@ room_retention_policies created_at TIMESTAMP ``` -The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessage` record unless the Mosaic retention policy also covers it. +The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessageDto` record unless the Mosaic retention policy also covers it. Default retention values: diff --git a/docs/architecture/compaction-revocation.md b/docs/architecture/compaction-revocation.md new file mode 100644 index 00000000..1ff030f5 --- /dev/null +++ b/docs/architecture/compaction-revocation.md @@ -0,0 +1,59 @@ +# Compaction observer revocation and runtime generations + +WI-3 connects Claude and Pi compaction/session lifecycle events to the existing authenticated lease-broker state machine. It does not add a second lease store or let runtime hooks assert identity. Each observer inherits the broker-minted session, resolves the current private runtime generation, and sends the existing `revoke_lease` action over the authenticated Unix socket. + +## Observer matrix + +| Runtime | Lifecycle signal | Action | +| ---------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Claude / Claudex | `PreCompact` | Revoke the current lease before compaction. A non-zero hook result blocks the lifecycle transition. | +| Claude / Claudex | `SessionStart` with matcher `compact` | Revoke again after compacted context starts. | +| Claude / Claudex | `SessionStart` with matcher `resume\|clear` | Atomically advance the private generation, then revoke the replacement incarnation. | +| Pi | `session_before_compact` | Revoke before compaction; return `{ cancel: true }` if revocation cannot be confirmed. | +| Pi | `session_compact` then the first `context` | Arm and run an independent post-compaction revoke. A failed post observer blocks later tools locally until a retry succeeds. | +| Pi | `session_start` with reason `reload`, `new`, `resume`, or `fork` | Atomically advance the private generation, then revoke the replacement incarnation before reuse. | + +The first observer that reaches the broker deletes pending promotion tokens and makes the lease `UNVERIFIED`. The second compaction observer is deliberate redundancy, not a prerequisite for the first. Claudex receives the same mandatory hooks in its isolated `CLAUDE_CONFIG_DIR`; hook merging preserves unrelated isolated settings and rejects malformed or symlinked settings fail-closed. + +## Private generation authority + +`launch-runtime.py` still registers before `exec`, preserving the kernel-authenticated PID/starttime anchor. It now also creates `generation-.state` beside the broker socket. The file is owner-only mode `0600` under the broker's mode-`0700` directory. Hook descendants read that file instead of relying only on an immutable inherited environment value. + +Generation changes use an exclusive file lock, validate owner/type/mode/size, increment monotonically, truncate and write the complete new value, and `fsync` before contacting the broker. Therefore reload, new-session, resume, and fork events may retain the same PID/starttime while still becoming a new broker incarnation. The higher generation causes the broker to atomically discard prior tokens and lease authority; the replacement generation inherits no VERIFIED lease. + +If an observer fires while broker transport is unavailable, `revoke-lease.py` advances the private generation as a local fence before returning non-zero. Every later all-tools gate reads that higher value. When the broker is reachable again, authentication of that value performs the same old-generation revocation before authorization. Pi also keeps a process-local post-compaction/rollover failure latch that blocks tool calls. An unsafe or unreadable generation file itself makes both lifecycle revocation and tool authorization fail closed. + +## Threat contract and stopping boundary + +### BOUNDED RESIDUAL STALE WINDOW + +If **both** pre- and post-compaction observers are missed entirely, no revocation signal exists. During the remaining unexpired lease, **within-TTL consequential actions are allowed**. Their count and timing are **bounded by lease expiry, not by the mutator gate**. WI-3 makes no claim that it bounds mutator actions inside this stale interval. The broker's monotonic lease TTL is capped at 300 seconds; after expiry, the next consequential tool is denied with `LEASE_EXPIRED`. + +This is the named D2-v5 T-A residual. It is distinct from an observer that fires but cannot contact the broker: the latter creates a local generation fence and fails closed. It is also distinct from T-C total rot, where the lifecycle observers and the all-tools gate are both absent or replaced. Server-side branch protection, required CI, and independent review remain the irreducible backstop for T-C. + +| Condition | Result | +| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Either compaction observer succeeds | Existing lease and pending promotion tokens are revoked immediately. | +| Observer runs but broker confirmation fails | Lifecycle transition is denied where supported; local generation fence and runtime latch prevent inherited authority. | +| Both observers are missed, lease unexpired | **ALLOWED** inside the bounded residual stale window. No within-window mutator bound is claimed. | +| Both observers are missed, lease expired | **DENIED** by monotonic TTL expiry. | +| Generation advances on reload/new/resume/fork | Prior incarnation revoked; replacement starts `UNVERIFIED`. | +| Lifecycle observers and all-tools gate both fail or are removed | T-C total-hook-miss residual; protected-branch controls remain required. | + +## T-C server-side branch-protection posture + +The required posture is that `main` is push-blocked and PR-only-merge is **MANDATORY**, regardless +of client-gate state. The client-side gate narrows the exposure window only; it is not the T-C +guarantee. The server-side protected-branch configuration is the irreducible guarantee for protected +repository actions. Status-check enforcement and approval enforcement are **RECOMMENDED**. + +## Current-vs-required gap (recorded, not enacted) + +The current empirical configuration is recorded here without re-probing or mutating live branch +protection. `enable_push=False` (push-block present), so the mandatory push-block/PR-only-merge core +holds. `require_approvals=0` (approvals not enforced), `enable_status_check=False` (status checks not +enforced), and `block_on_official_review=False` (official review not enforced). Those recommended +merge-quality controls are the current gap; changing them is a separate, owner-gated operations +decision and is not enacted by this documentation change. + +The permanent T12b/T30 acceptance case prints both required outcomes: dual-hook miss within TTL is **ALLOWED**, and the same lease after TTL is **DENIED**. Separate real-socket tests prove each Claude observer and same-PID generation rollover; Pi lifecycle tests exercise pre/post observers, all four replacement reasons, and local failure closure. diff --git a/docs/architecture/lease-broker-protocol.md b/docs/architecture/lease-broker-protocol.md new file mode 100644 index 00000000..3ef327db --- /dev/null +++ b/docs/architecture/lease-broker-protocol.md @@ -0,0 +1,35 @@ +# Authenticated external lease broker protocol + +The compaction-refresh lease broker is a Linux-only, newline-framed JSON protocol over a Unix stream socket. It is runtime-neutral; M1 consumers are limited to Claude and Pi. This is an internal process boundary, not an HTTP API, so it is intentionally absent from OpenAPI. + +The broker, never the caller, obtains `(pid, uid, gid)` from kernel `SO_PEERCRED`. It correlates the PID with `/proc//stat` field 22 (`starttime`) and mints `session_id` on `register_anchor`. Presence of `session_id` in that request is refused even when its value is `null` or empty. Later requests must originate from the anchor or a descendant. The broker walks parent PIDs to the `(pid,starttime)` anchor and then rereads every walked PID's starttime before accepting the chain. + +## Request and response boundary + +Each connection carries exactly one UTF-8 JSON object followed by one newline, capped at 64 KiB. The protocol deliberately uses EOF to prove that there is exactly one frame: immediately after writing the newline, the client **MUST half-close its write side** with `shutdown(SHUT_WR)` (or Node `socket.end()`) before awaiting the response. A client that writes a newline but leaves its write side open receives no successful response; the broker's one-second connection deadline fails closed. Malformed, unterminated, multiple (including a delayed second frame), or oversized frames fail closed. Responses are one JSON object and one newline. Success has `{"ok":true,...}`; refusal has `{"ok":false,"code":"TYPED_CODE"}`. Requests are: + +- `register_anchor`: `action`, non-negative `runtime_generation`; no `session_id` field. +- `authenticate`: `action`, broker-minted `session_id`, non-negative `runtime_generation`. +- `mint_token`: authenticated identity plus `binding` containing exactly `compaction_epoch`, `request_epoch`, `h_source`, `h_payload`, and `schema_version`. +- `consume_token`: authenticated identity plus `token`. +- `begin_verification`: authenticated identity, runtime (`claude` or `pi`), cycle `binding`, and a TTL no greater than 300 seconds. The broker revokes existing authority first, enters `PENDING_VERIFICATION`, and returns a single-use promotion token. +- `begin_recovery`: the constrained recovery entrypoint. It rejects caller-provided receipt/challenge fields and delegates to the same `begin_verification` transition, but reports `PENDING_DELIVERY` and marks the volatile cycle as recovery-owned. +- `complete_recovery`: authenticated identity only. It rejects caller-provided receipt/challenge fields, obtains the current recovery challenge only from broker state, and delegates to the same trusted-observer → evidence → consume → promote sequence. An observation failure revokes recovery authority; retry starts a fresh challenge. + +The daemon owns a second protected production observer socket (mode `0600`) unless a private `--test-observer-file` fixture is selected. That transport accepts only the exact `record_runtime_observation` schema after kernel `SO_PEERCRED` plus the existing anchor/ancestry authentication; it validates the pending runtime/generation before storing one finalized assistant entry for the in-process `RuntimeReceiptObserver`. It is **not** a broker request action. Claude sends its latest assistant entry from the Stop-hook transport; Pi sends only finalized `message_end` assistant content. The public broker socket continues to reject request-supplied `latest_assistant_message` in begin, observe, and complete paths. + +- `promote_lease`: authenticated identity plus the exact pending promotion token. The broker commits token consumption before making `VERIFIED` visible. +- `revoke_lease`: authenticated observer signal; deletes pending tokens and makes the session `UNVERIFIED` immediately. WI-3 Claude/Pi hooks send this existing action; `runtime` and bounded `reason` fields are diagnostic input only and never identity authority. +- `authorize_tool`: authenticated identity, runtime, and exact runtime-reported tool name. The broker returns an explicit allow/deny decision from the whole-class policy and current lease. + +A higher generation for the same anchor atomically replaces the stored incarnation and deletes all prior tokens and lease authority for that session. A lower generation is stale. Runtime descendants resolve the current generation from an owner-only, locked generation file created by the register-before-exec launcher; reload/new/resume/fork observers advance and `fsync` it before broker revocation. This supports generation replacement even when PID/starttime do not change. Tokens are 256-bit values from the operating-system cryptographic RNG and are single use. At most 256 pending tokens may be persisted; another mint fails with `TOKEN_CAPACITY` before mutation. Successful consumption deletes the token, while a replay still fails with `TOKEN_REPLAY`. Live v1 token records retain the existing `consumed: false` schema. + +VERIFIED leases are volatile and monotonic-time bounded: broker restart, generation change, explicit observer revocation, or expiry returns the session to `UNVERIFIED`. `begin_verification` always revokes before minting a new prerequisite. `begin_recovery` reuses that exact transition and mints a new challenge, so a normal-path receipt/challenge cannot be replayed through recovery. `promote_lease` is valid only from the matching pending cycle; persistence failure rolls token and lease state back, while post-rename durability uncertainty terminates the broker. The WI-1 token is the atomic promotion prerequisite substrate. + +## Receipt boundary and T-C residual (R1) + +Receipt evidence is a T-A delivery/liveness prerequisite only; it cannot replace the mechanical +mutator gate as safety authority. The receipt detects an **ABSENT** or **PREFIX-TRUNCATED** terminal +token. A **MIDDLE-DROP** that preserves the tail is a T-C contract violation that is **NOT receipt-detectable**. It is covered by server-side protected-branch controls, **NOT** by the receipt; no category-wide receipt-detection claim is made for that tail-preserving transformation. + +State replacement serializes and enforces the 4 MiB maximum before opening a temporary file, then uses a mode-`0600` temporary file, `fsync`, atomic rename, and parent-directory `fsync`. Every broker mutation snapshots the prior v1 state. A commit failure before rename restores that snapshot and leaves durable state unchanged. A failure after rename makes durability uncertain, so the store is poisoned without rolling memory back and the daemon terminates rather than serving with divergent state. Existing state is opened without following symlinks, must be a bounded regular file at mode `0600`, and is fully schema- and invariant-validated before use. Persisted tokens must be unconsumed, match their session's current generation, and remain within the 256-token cap. Session identity is uniquely keyed by `(anchor_pid,anchor_starttime)`; duplicate logical sessions for one anchor refuse startup. State integrity or mode failures refuse startup. The daemon does not log session IDs or tokens. diff --git a/docs/architecture/lease-broker-security.md b/docs/architecture/lease-broker-security.md new file mode 100644 index 00000000..183f94b2 --- /dev/null +++ b/docs/architecture/lease-broker-security.md @@ -0,0 +1,26 @@ +# WI-1 lease broker security notes + +- Trusted identity comes only from Linux `SO_PEERCRED` plus `/proc` starttime, never request identity fields. +- Descendant authorization is anchored to `(pid,starttime)` and uses a complete second starttime pass to fail closed on disappearance or PID-reuse races. +- Runtime generations are monotonic per anchor; a bump revokes prior-incarnation tokens before persistence commits. WI-3 stores the live generation in an owner-only locked file so same-PID Pi reload/new/resume/fork and Claude resume/clear transitions cannot inherit a VERIFIED lease. +- Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources. +- Framing and persistence failures fail closed. Sensitive tokens are not logged. +- Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity. +- WI-2 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection. First-class Claude/Pi, both Claudex dispatch modes, PRDY, QA remediation, coord, orchestrator, and fleet starts converge on broker register-before-exec; Claudex additionally installs the mandatory all-tools hook inside its preserved isolated config and fails closed on unsafe settings. +- The permanent `check-runtime-launches.py` suite/CI guard scans production source for direct literal, absolute-path, process-API, command-array, and dynamic Claude/Pi launches. It has no bypass allowlist: an unrecognized launch form fails CI until routed through the common boundary. +- WI-2 promotion consumes a WI-1 cycle token before VERIFIED becomes visible. Observer revocation, runtime-generation replacement, broker restart, and monotonic TTL expiry remove authority. +- WI-3 wires redundant Claude `PreCompact`/`SessionStart(compact)` and Pi `session_before_compact`/post-`session_compact` `context` observers to that same revoke action. If broker confirmation fails after an observer fires, the revoker advances the private generation as a local fence; subsequent authorization revokes the stale broker incarnation before any consequential allow. +- Dual observer absence while a lease remains live is the named **bounded residual stale window**: consequential tools remain allowed until monotonic expiry, with no claimed within-window action bound. After expiry they are denied. Total observer-plus-gate absence remains T-C. +- Receipt observation, payload construction, and constrained recovery implementation remain later surfaces. A receipt can become a promotion prerequisite but is never the safety mechanism. + +## Named residual: promote-lease-lost-ACK (WI-3 D2-v5) + +A valid `promote_lease` can leave a session `VERIFIED` in the broker while the client never learns of it. This is a named, bounded D2-v5 T-A residual — an **authority-observability divergence, not an authority divergence, not an ALLOW-risk, and not a retry double-apply**. It is disclosed here, not laundered. + +**Window — where it can occur.** The broker commits token consumption and durable `VERIFIED` state _before_ the success reply becomes visible (see the promotion order in `lease-broker-protocol.md`). The residual is confined to the interval after that commit+fsync when the broker→client reply or peer-ACK is lost — for example an extreme-contention send failure or peer disconnect after `handle()` has already mutated and persisted state (the #838 fail-closed transport path). The lease mutation is already durable broker-side; only the acknowledgement to the client is lost. No uncommitted or partially-applied state is involved: the commit either happened (and is authoritative) or it did not (and no lease exists). + +**Fail-safe direction — the client can only under-claim.** Broker intent is the ceiling; client authority is always ≤ broker intent, never more. Client-side authority-belief is granted only by a _received_ acknowledgement; a lost acknowledgement conveys nothing, so the client cannot conclude "verified" and continues to treat itself as `UNVERIFIED` (it re-verifies or recovers). If the client retries `promote_lease` with the same token, the token is already consumed and the broker rejects the retry (`PROMOTION_TOKEN_MISMATCH` / `INVALID_LEASE_TRANSITION`); there is no double-apply. The committed `VERIFIED` state the broker holds is authority the lease _legitimately earned_ from a real promotion — the broker authorizing consequential tools under it is correct, not inflation. Divergence is therefore strictly toward _less_ client authority than the broker granted; it never produces authority the broker did not grant. + +**Bound — TTL plus the observer/gen-bump revoke backstop, self-healing.** The orphaned `VERIFIED` lease is indistinguishable to the broker from any other legitimately verified lease, so the identical D2-v5 revocation backstops dispose of it: any compaction observer (`PreCompact` / `SessionStart(compact)` for Claude; `session_before_compact` / post-`session_compact` `context` for Pi), any same-PID runtime-generation bump (reload/new/resume/fork), broker restart, or monotonic-time expiry returns the session to `UNVERIFIED`. Monotonic TTL expiry (capped at 300 seconds) is **unconditional** — it requires no observer at all — so the maximum exposure of the orphaned lease is one TTL, ≤ 300 s, after which the next consequential tool is denied with `LEASE_EXPIRED`. Any observer that fires shortens the window further. The residual self-heals: "≥1 observer fires OR expiry ⇒ revoke" catches the lost-ACK lease on the same terms as every other stale lease. As with the dual-observer-miss stale window, WI-3 makes no claim that the mutator gate bounds actions inside the residual interval; the interval is bounded by TTL and the revoke backstop, and the server-side branch-protection / required-CI / independent-review line remains the irreducible backstop for protected-repository mutations. + +Coordinator security review must rerun the real socket/peercred and mutator-gate acceptance suites on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration. diff --git a/docs/architecture/mos-runtime-portability-m1.md b/docs/architecture/mos-runtime-portability-m1.md new file mode 100644 index 00000000..932d10fd --- /dev/null +++ b/docs/architecture/mos-runtime-portability-m1.md @@ -0,0 +1,49 @@ +# Mos Runtime Portability M1 — Logical Identity and Fencing + +## Boundary + +M1 separates the logical Mosaic agent from any Claude, Pi, Codex, tmux, Matrix, or provider-native session. The normalized identity is: + +```text +(tenant_id, logical_agent_id, binding_id) +``` + +`logical_agent_id` is a server-owned stable identifier. A connector is a replaceable holder of a lease for one binding; it is not the agent identity. + +## Durable lease model + +PostgreSQL table `logical_agent_connector_leases` has one unique row per identity/binding tuple. The current row records: + +- an opaque lease UUID; +- connector ID and normalized allowed scopes; +- a positive decimal fencing epoch stored as PostgreSQL `bigint`; +- acquired, heartbeat, expiry, release, and update timestamps. + +Initial acquisition is insert-only. An existing active row causes `lease_held`. An expired or released row causes `takeover_required`; ordinary acquisition cannot recover it. Authorized takeover uses compare-and-swap against the expected epoch, rotates the lease UUID, and increments the epoch atomically. Heartbeat and release match the full identity, binding, connector, lease UUID, and epoch. + +The companion `connector_lease_audit_log` is append-only metadata. It stores lifecycle event, outcome/reason, identity/binding/connector, epoch, correlation ID, and timestamp. It deliberately excludes scopes, grant objects, payloads, approval references, tokens, and credentials. + +## Execution grants + +`ConnectorLeaseCoordinator` issues a short-lived internal grant only after rereading the durable current lease. Defense-in-depth caps leases at 5 minutes and grants at 30 seconds by default; constructor options may tighten these limits. A grant is bound to tenant, logical agent, binding, connector, lease UUID, scope subset, expiry, and epoch. + +Validation occurs immediately before adapter invocation and rereads PostgreSQL. The adapter receives only `ConnectorExecutionContext`; harness-native schemas remain behind the adapter. Validation denies: + +- grants not minted by the current gateway process (including cloned/forged objects); +- expired grants or leases; +- released leases; +- stale epochs or replaced connector/lease UUIDs; +- missing/cross-tenant/cross-agent/cross-binding leases; +- scopes not authorized by both grant and current lease. + +A gateway restart intentionally invalidates process-local grants. The durable lease and epoch survive, and a fresh grant may be issued only after current-lease and gateway-policy validation. + +## Concurrency and side-effect rule + +The database CAS determines the sole current holder. A successful takeover makes every old-epoch validation fail. Connector adapters must consume and propagate the normalized lease epoch/context so downstream effect boundaries can also fence races that occur after gateway validation. + +M1 does not provide exactly-once receipts or a side-effect journal. Those remain later #754 work; callers must not infer exactly-once delivery from lease fencing. + +## Extension boundary + +`ConnectorLeaseService` is the gateway-owned policy surface. Every policy decision receives the normalized requested scopes and TTL (or explicit `null` where no TTL applies), so a concrete policy can enforce least privilege and duration limits. Its production default policy denies every lease/grant operation until a server-configured connector policy is supplied. No M1 HTTP endpoint accepts caller-controlled tenant or logical identity, and no concrete Claude/Pi/Codex adapter or channel cutover is included. diff --git a/docs/architecture/mutator-class-gate.md b/docs/architecture/mutator-class-gate.md new file mode 100644 index 00000000..522b5833 --- /dev/null +++ b/docs/architecture/mutator-class-gate.md @@ -0,0 +1,72 @@ +# Whole mutator-class lease gate + +WI-2 adds the framework-native authorization boundary for Claude (including the supported Claudex overlay) and Pi. Every runtime-reported tool name reaches the lease broker before execution. The gate classifies capabilities by the whole tool class; it never parses a Bash command to decide whether that particular string looks read-only. + +## Default-deny policy + +While a session is not VERIFIED, only these exact classes are allowed: + +- Claude: `Read`, `Grep`, `Glob`, `Ls`, `Find` +- Pi: `read`, `grep`, `find`, `ls` +- Both runtimes: the fixed `mosaic_context_recover` primitive + +Every other built-in, unknown tool, and custom/MCP tool is consequential by default and is denied. This includes Claude `Bash`, `Edit`, `Write`, and `NotebookEdit`, plus Pi `bash`, `edit`, and `write`. A compromised model therefore cannot bypass Mosaic wrappers by selecting raw `git`, `curl`, `kubectl`, provider, deployment, or filesystem commands inside a generic mutator—the generic mutator itself is blocked before its input executes. + +## Broker-owned transition order + +The authenticated broker is the sole lease writer: + +1. `begin_verification` revokes existing authority and pending tokens first, then records `PENDING_VERIFICATION` and mints one WI-1 single-use promotion token bound to the exact cycle. +2. `promote_lease` accepts only that session/generation/binding/token combination. +3. Token consumption commits before the volatile lease becomes VERIFIED. Promotion is last and cannot be reached directly from UNVERIFIED. +4. `revoke_lease`, a runtime-generation increase, broker restart, or monotonic expiry removes mutator authority. + +The initial TTL is capped at the ratified 300-second maximum. A caller may request a shorter positive TTL but cannot lengthen the maximum. WI-3 installs the [compaction observer and generation lifecycle](compaction-revocation.md). Dual compaction-hook miss within an unexpired lease remains the ratified bounded T-A residual: consequential tools are allowed until expiry, with no claimed within-window action bound; once either observer revokes or TTL expires, the next consequential tool is denied. + +A receipt is only a future promotion prerequisite. It is not an obedience, residency, or safety proof and never replaces this mechanical gate. + +## Runtime adapters + +`launch-runtime.py` registers itself with the broker and then `exec`s Claude or Pi so PID/starttime remain the authenticated parent anchor. It exports the broker-minted session ID and an owner-only generation-file reference to descendants; lifecycle hooks advance that file for same-PID replacement generations. + +- Claude installs `mutator-gate.py` as an all-tools (`.*`) `PreToolUse` hook. +- `mosaic claudex` and `mosaic yolo claudex` preserve their isolated `CLAUDE_CONFIG_DIR`, merge the mandatory hook into that isolated `settings.json`, and use the same register-before-exec launcher. Malformed or symlinked isolated settings deny launch. +- Pi invokes the same executable from its `tool_call` handler. + +The executable submits the runtime's actual tool name to `authorize_tool`. Missing identity, malformed input/reply, timeout, broker unavailability, or denial exits with status 2 and blocks fail-closed. + +## Runtime-launch choke-point and permanent guard + +Every repository-owned Claude/Pi launch entry converges on `launch-runtime.py`, either directly or through `mosaic` → `execLeaseGatedRuntime`. PRDY init/update and QA remediation invoke the wrapper directly so their existing prompts, dangerous-permission behavior, working directory, and environment survive without skipping broker registration. The raw Claude `--dangerously-skip-permissions` primitive is owned only by `launch-runtime.py`; callers request semantic `--dangerous` mode, and the wrapper validates Claude before injecting the primitive. `@mosaicstack/coord` rewrites direct Claude commands to `mosaic claude` and rejects unknown custom Claude launchers. + +`check-runtime-launches.py` is the permanent completeness guard. It scans production shell, TypeScript/JavaScript, Python, and data launch definitions under `packages/`, `apps/`, `plugins/`, and `tools/`; direct literal, absolute-path, process-API, dynamic, command-substitution, `eval`, and variable-execution runtime launches fail. Shell comments are stripped with quote awareness, wrapper prefixes are tokenized with `shlex`, and only an invocation in command position with `--runtime` before the command separator is gated. Literal and tracked-variable command tokens use one terminal resolver after any nesting of `exec`, `command`, `nohup`, or `env` plus assignments. A direct command always wins over an inert marker on the same line. Independently, the raw dangerous primitive anywhere outside the choke-point is RED. + +The command parser is a best-effort CI defense, not a complete shell interpreter. Alias/function redefinition, sourced commands, generated scripts, and encoded pipelines are intentionally residual rather than an invitation to chase an unbounded shell language. Two runtime controls backstop that residual surface: primitive ownership rejects a dangerous launch even when command identity is alias-indirected, and Claude's global `.*` `PreToolUse` hook invokes the broker gate for non-dangerous launches. Without `MOSAIC_LEASE_SESSION_ID`, representative read, mutator, and custom/MCP tools all fail closed with `GATE_UNAVAILABLE`. Hook absence or replacement remains in the documented T-C boundary. + +### Parser stopping criterion + +- **A — realistic parser matrix:** comments, inert strings/assignments, heredocs, continuations, chained commands, command substitution, `eval`, bare tracked variables, and quoted/unquoted tracked variables behind `exec`, `command`, `nohup`, or `env` are permanent RED regressions. Prefix-variable forms are covered in both multiline and same-line assignment shapes. +- **B — residual backstops:** a dangerous alias-indirected launch is RED solely through primitive anchoring; a parser-missed non-dangerous alias launch is paired with an acceptance test proving the global all-tools hook denies every representative tool class as `GATE_UNAVAILABLE` without a lease. +- **C — independent fresh review:** the parser class is considered complete only when reviewers find no new non-overlapping realistic evasion on the exact head. A and B are repository evidence; C is supplied by the fresh review round. + +All three layers are load-bearing and complementary. The guard is mandatory in `@mosaicstack/mosaic`'s test script, so root CI fails on a future realistic bypass. Real-socket tests separately prove PRDY init/update and QA receive broker sessions and deny an unverified mutator. + +The live inventory is emitted by: + +```bash +python3 packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py --root . --json +``` + +| Production launch family | Gated entries | +| ------------------------------------------------------ | ------------: | +| `@mosaicstack/coord` default/configured Claude command | 2 | +| Fleet runtime start | 1 | +| QA remediation + generated QA command | 2 | +| Orchestrator command construction/session launches | 3 | +| PRDY init/update | 2 | +| Mosaic Claude/Pi/Claudex adapter and wrapper boundary | 4 | +| **Total** | **14 / 14** | + +## Assurance boundary + +This closes T-A after an observer fires or lease expiry and T-B for in-runtime tool calls. Hook/extension absence, a runtime executing outside the gated launcher, ptrace/same-UID broker replacement, and other fully rotted behavior remain T-C. Server-side branch protection and required PR review/CI remain the irreducible line for protected repository mutations. diff --git a/docs/compaction-refresh/probes/p5_receipt_replay.py b/docs/compaction-refresh/probes/p5_receipt_replay.py new file mode 100644 index 00000000..76f8d4b0 --- /dev/null +++ b/docs/compaction-refresh/probes/p5_receipt_replay.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""P5 Gate0 replay probe; BUILT ONLY, execution is Mos-gated. + +Run only under fresh-executor authorization: + python3 -I -S -B docs/compaction-refresh/probes/p5_receipt_replay.py + +Each of the default three isolated runs launches the shipped lease-broker daemon +in a distinct private temporary directory. This driver never changes broker +state directly and does not replace the promote gate: every transition is sent +over the daemon's real Unix socket. It proves the shipped order is +PENDING_DELIVERY -> observe/evidence commit -> consume -> VERIFIED and that a +consumed challenge cannot be replayed or reopen/renew its lease. +""" + +from __future__ import annotations + +import argparse +import base64 +import importlib.util +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + + +HERE = Path(__file__).resolve().parent +REPOSITORY = HERE.parents[2] +TOOLS = REPOSITORY / "packages/mosaic/framework/tools/lease-broker" +DAEMON = TOOLS / "daemon.py" +FRAGMENTS = TOOLS / "normative_fragments.py" + + +def load_shipped_fragments(): + if not FRAGMENTS.is_file(): + raise RuntimeError(f"shipped normative construction missing: {FRAGMENTS}") + spec = importlib.util.spec_from_file_location("p5_shipped_normative_fragments", FRAGMENTS) + if spec is None or spec.loader is None: + raise RuntimeError("unable to load shipped normative construction") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(3.0) + connection.connect(str(socket_path)) + connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode()) + connection.shutdown(socket.SHUT_WR) + response = bytearray() + while True: + chunk = connection.recv(4096) + if not chunk: + break + response.extend(chunk) + if not response.endswith(b"\n") or response.count(b"\n") != 1: + raise AssertionError(f"unframed broker reply: {bytes(response)!r}") + parsed = json.loads(response[:-1]) + if not isinstance(parsed, dict): + raise AssertionError(f"non-object broker reply: {parsed!r}") + return parsed + + +def wait_ready(process: subprocess.Popen[str], socket_path: Path) -> None: + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if socket_path.exists(): + return + if process.poll() is not None: + output = process.stdout.read() if process.stdout is not None else "" + raise RuntimeError(f"shipped daemon exited before READY: {output}") + time.sleep(0.02) + raise TimeoutError("shipped daemon did not create private probe socket") + + +def expect_refused(reply: dict[str, object], code: str) -> None: + if reply != {"ok": False, "code": code}: + raise AssertionError(f"expected refusal {code}, got {reply!r}") + + +def run_once(index: int) -> str: + fragments = load_shipped_fragments() + root = Path(tempfile.mkdtemp(prefix=f"mosaic-p5-replay-{index}-")) + os.chmod(root, 0o700) + socket_path = root / "broker.sock" + state_path = root / "state.json" + observer_path = root / "test-observer.json" + process = subprocess.Popen( + [ + sys.executable, "-I", "-S", "-B", str(DAEMON), "--socket", str(socket_path), + "--state", str(state_path), "--test-observer-file", str(observer_path), + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + wait_ready(process, socket_path) + registered = request(socket_path, {"action": "register_anchor", "runtime_generation": 1}) + if registered.get("ok") is not True or not isinstance(registered.get("session_id"), str): + raise AssertionError(f"registration failed: {registered!r}") + session_id = registered["session_id"] + construction = fragments.build_payload( + manifest_version=1, + generator_version="p5-replay-probe", + fragments=[ + fragments.NormativeFragment( + "authority/probe", + b"P5 shipped transition driver\n", + "63537df1a6cb0d80195a96757ab11d629e5b5e1f23be167218b84cb195b1c1d6", + ), + ], + ) + if construction.injectionDecision != "ACCEPTED" or not construction.promotion: + raise AssertionError("shipped normative construction refused P5 fixture") + binding = { + "compaction_epoch": index, + "request_epoch": index + 100, + "h_source": construction.h_source, + "h_payload": construction.h_payload, + "schema_version": 1, + } + construction_request = { + "manifest_version": 1, + "generator_version": "p5-replay-probe", + "fragments": [{ + "source_id": "authority/probe", + "content_base64": base64.b64encode(b"P5 shipped transition driver\n").decode("ascii"), + "expected_sha256": "63537df1a6cb0d80195a96757ab11d629e5b5e1f23be167218b84cb195b1c1d6", + }], + } + pending = request(socket_path, { + "action": "begin_verification", + "session_id": session_id, + "runtime_generation": 1, + "runtime": "pi", + "binding": binding, + "construction": construction_request, + }) + if pending.get("ok") is not True or pending.get("state") != "PENDING_VERIFICATION": + raise AssertionError(f"shipped pending-delivery transition failed: {pending!r}") + challenge = pending.get("receipt_challenge") + receipt = pending.get("receipt") + if not isinstance(challenge, str) or not isinstance(receipt, str): + raise AssertionError(f"shipped broker did not mint a receipt challenge: {pending!r}") + + # Promotion before observation/evidence/consumption is forbidden. + expect_refused(request(socket_path, { + "action": "promote_lease", + "session_id": session_id, + "runtime_generation": 1, + "receipt_challenge": challenge, + }), "INVALID_LEASE_TRANSITION") + + observer_path.write_text(json.dumps({ + "session_id": session_id, + "runtime_generation": 1, + "latest_assistant_message": receipt, + }), encoding="utf-8") + os.chmod(observer_path, 0o600) + observed = request(socket_path, { + "action": "observe_receipt", + "session_id": session_id, + "runtime_generation": 1, + "receipt_challenge": challenge, + }) + if observed.get("ok") is not True or observed.get("state") != "PENDING_PROMOTION": + raise AssertionError(f"shipped evidence transition failed: {observed!r}") + durable = json.loads(state_path.read_text(encoding="utf-8")) + evidence = durable["tokens"][challenge].get("evidence") + if not isinstance(evidence, dict) or not isinstance(evidence.get("h_latest_assistant"), str): + raise AssertionError("shipped receipt evidence was not committed before consume/promote") + + promoted = request(socket_path, { + "action": "promote_lease", + "session_id": session_id, + "runtime_generation": 1, + "receipt_challenge": challenge, + }) + if promoted.get("ok") is not True or promoted.get("state") != "VERIFIED": + raise AssertionError(f"shipped consume-before-promote transition failed: {promoted!r}") + + # T25/T28: the actual consumed challenge, re-presented through the + # shipped daemon, can neither be observed again nor re-promote/reopen. + expect_refused(request(socket_path, { + "action": "observe_receipt", + "session_id": session_id, + "runtime_generation": 1, + "receipt_challenge": challenge, + }), "RECEIPT_REPLAY") + expect_refused(request(socket_path, { + "action": "promote_lease", + "session_id": session_id, + "runtime_generation": 1, + "receipt_challenge": challenge, + }), "RECEIPT_REPLAY") + return challenge + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=3.0) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + shutil.rmtree(root, ignore_errors=True) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--runs", type=int, default=3) + arguments = parser.parse_args() + if arguments.runs != 3: + raise SystemExit("P5 requires exactly three isolated runs") + challenges = [run_once(index) for index in range(arguments.runs)] + if len(set(challenges)) != arguments.runs: + raise AssertionError("separate shipped cycles did not mint unique challenges") + print("P5 receipt replay probe PASS: 3 isolated shipped-daemon runs") + + +if __name__ == "__main__": + main() diff --git a/docs/compaction-refresh/probes/p6_constrained_recovery.py b/docs/compaction-refresh/probes/p6_constrained_recovery.py new file mode 100644 index 00000000..db2a1067 --- /dev/null +++ b/docs/compaction-refresh/probes/p6_constrained_recovery.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""P6 constrained-recovery probe; BUILT ONLY and Mos-gated. + +DO NOT self-fire. Under Mos authorization only: + python3 -I -S -B docs/compaction-refresh/probes/p6_constrained_recovery.py + +The default three isolated runs launch the shipped daemon plus its production +observer transport on private sockets. The driver invokes the shipped recovery +command and adapter gate identity; it never resets broker state, mocks promote, +or taps a live model-output stream. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import importlib.util +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + + +HERE = Path(__file__).resolve().parent +REPOSITORY = HERE.parents[2] +TOOLS = REPOSITORY / "packages/mosaic/framework/tools/lease-broker" +DAEMON = TOOLS / "daemon.py" +GATE = TOOLS / "mutator-gate.py" +RECOVERY_COMMAND = TOOLS / "recover-context.py" +OBSERVER_CLIENT = TOOLS / "receipt-observer-client.py" +FRAGMENTS = TOOLS / "normative_fragments.py" +CLAUDE_SETTINGS = REPOSITORY / "packages/mosaic/framework/runtime/claude/settings.json" +PI_EXTENSION = REPOSITORY / "packages/mosaic/framework/runtime/pi/mosaic-extension.ts" + + +def load_shipped_fragments(): + spec = importlib.util.spec_from_file_location("p6_shipped_fragments", FRAGMENTS) + if spec is None or spec.loader is None: + raise RuntimeError("shipped normative construction unavailable") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(3.0) + connection.connect(str(socket_path)) + connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode()) + connection.shutdown(socket.SHUT_WR) + response = bytearray() + while True: + chunk = connection.recv(4096) + if not chunk: + break + response.extend(chunk) + if not response.endswith(b"\n") or response.count(b"\n") != 1: + raise AssertionError(f"unframed broker reply: {bytes(response)!r}") + reply = json.loads(response[:-1]) + if not isinstance(reply, dict): + raise AssertionError("broker reply is not an object") + return reply + + +def wait_ready(process: subprocess.Popen[str], socket_path: Path) -> None: + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if socket_path.exists(): + return + if process.poll() is not None: + output = process.stdout.read() if process.stdout is not None else "" + raise RuntimeError(f"shipped daemon exited before READY: {output}") + time.sleep(0.02) + raise TimeoutError("shipped daemon did not create private probe socket") + + +def run_json(command: list[str], environment: dict[str, str], input_value: object | None = None) -> dict[str, object]: + completed = subprocess.run( + command, + input=None if input_value is None else json.dumps(input_value), + text=True, + capture_output=True, + env=environment, + check=False, + ) + if not completed.stdout.endswith("\n"): + raise AssertionError(f"command omitted framed result: {completed.stderr!r}") + reply = json.loads(completed.stdout) + if not isinstance(reply, dict): + raise AssertionError("command result is not an object") + return reply + + +def gate_recovery(runtime: str, phase: str, environment: dict[str, str]) -> None: + command = [sys.executable, "-I", "-S", "-B", str(GATE), "--runtime", runtime] + if runtime == "claude": + command.extend(["--recovery-command", str(RECOVERY_COMMAND)]) + recovery_invocation = ( + f"python3 {RECOVERY_COMMAND} begin --construction /tmp/p6.json " + "--compaction-epoch 1 --request-epoch 1" + if phase == "begin" + else f"python3 {RECOVERY_COMMAND} complete" + ) + value = {"tool_name": "Bash", "tool_input": {"command": recovery_invocation}} + else: + value = {"tool_name": "mosaic_context_recover"} + completed = subprocess.run(command, input=json.dumps(value), text=True, capture_output=True, env=environment, check=False) + if completed.returncode != 0: + raise AssertionError(f"{runtime} recovery invocation remained gated: {completed.stderr!r}") + + +def record_production_observation(runtime: str, message: str, root: Path, environment: dict[str, str]) -> None: + command = [sys.executable, "-I", "-S", "-B", str(OBSERVER_CLIENT), "--runtime", runtime] + if runtime == "claude": + transcript = root / "claude-transcript.jsonl" + transcript.write_text(json.dumps({"message": {"role": "assistant", "content": message}}) + "\n", encoding="utf-8") + payload = {"transcript_path": str(transcript)} + command.append("--latest-entry") + else: + payload = {"latest_assistant_message": message} + completed = subprocess.run(command, input=json.dumps(payload), text=True, capture_output=True, env=environment, check=False) + if completed.returncode != 0: + raise AssertionError(f"{runtime} production observer transport refused: {completed.stderr!r}") + + +def run_once(index: int, runtime: str) -> None: + # Parity guard: drive the shipped command and the repaired adapter/observer + # bytes, not a shadow receipt or promotion implementation. + recovery_source = RECOVERY_COMMAND.read_text(encoding="utf-8") + if '"action": "begin_recovery"' not in recovery_source or '"action": "complete_recovery"' not in recovery_source: + raise AssertionError("P6 parity guard: recovery command no longer drives shipped broker entrypoints") + gate_source = GATE.read_text(encoding="utf-8") + if "--recovery-command" not in CLAUDE_SETTINGS.read_text(encoding="utf-8"): + raise AssertionError("P6 parity guard: Claude recovery mapping is missing") + if "_SHELL_ACTIVE" not in gate_source or "argv[1] != str(recovery_command)" not in gate_source: + raise AssertionError("P6 parity guard: Claude mapping is not literal-only") + if "const RECOVERY_TOOL = 'mosaic_context_recover'" not in PI_EXTENSION.read_text(encoding="utf-8"): + raise AssertionError("P6 parity guard: Pi recovery tool mapping is missing") + + fragments = load_shipped_fragments() + root = Path(tempfile.mkdtemp(prefix=f"mosaic-p6-recovery-{index}-")) + os.chmod(root, 0o700) + socket_path = root / "broker.sock" + observer_socket = root / "observer.sock" + state_path = root / "state.json" + construction_path = root / "construction.json" + content = b"P6 constrained recovery fixture\n" + construction = { + "manifest_version": 1, + "generator_version": "p6-constrained-recovery", + "fragments": [{ + "source_id": "authority/p6", + "content_base64": base64.b64encode(content).decode("ascii"), + "expected_sha256": hashlib.sha256(content).hexdigest(), + }], + } + construction_path.write_text(json.dumps(construction), encoding="utf-8") + os.chmod(construction_path, 0o600) + process = subprocess.Popen( + [sys.executable, "-I", "-S", "-B", str(DAEMON), "--socket", str(socket_path), + "--state", str(state_path), "--observer-socket", str(observer_socket)], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + wait_ready(process, socket_path) + registered = request(socket_path, {"action": "register_anchor", "runtime_generation": 1}) + session_id = registered.get("session_id") + if registered.get("ok") is not True or not isinstance(session_id, str): + raise AssertionError(f"broker anchor registration failed: {registered!r}") + built = fragments.build_payload_from_wire(construction) + normal = request(socket_path, { + "action": "begin_verification", "session_id": session_id, "runtime_generation": 1, + "runtime": runtime, "construction": construction, + "binding": {"compaction_epoch": index, "request_epoch": index + 100, + "h_source": built.h_source, "h_payload": built.h_payload, "schema_version": 1}, + }) + normal_challenge = normal.get("receipt_challenge") + normal_receipt = normal.get("receipt") + if not isinstance(normal_challenge, str) or not isinstance(normal_receipt, str): + raise AssertionError("normal path did not mint a receipt challenge") + environment = { + **os.environ, + "MOSAIC_LEASE_BROKER_SOCKET": str(socket_path), + "MOSAIC_RECEIPT_OBSERVER_SOCKET": str(observer_socket), + "MOSAIC_LEASE_SESSION_ID": session_id, + "MOSAIC_RUNTIME_GENERATION": "1", + "MOSAIC_LEASE_RUNTIME": runtime, + } + gate_recovery(runtime, "begin", environment) + recovery = run_json([ + sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "begin", "--construction", str(construction_path), + "--compaction-epoch", str(index + 10), "--request-epoch", str(index + 110), + ], environment) + challenge = recovery.get("receipt_challenge") + receipt = recovery.get("receipt") + if recovery.get("state") != "PENDING_DELIVERY" or not isinstance(challenge, str) or not isinstance(receipt, str): + raise AssertionError(f"recovery command did not drive pending delivery: {recovery!r}") + if challenge == normal_challenge: + raise AssertionError("recovery reused a normal-path challenge") + + # C4: production observer content is still exact-current-cycle only. + record_production_observation(runtime, normal_receipt, root, environment) + refused = run_json([sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "complete"], environment) + if refused.get("ok") is not False or refused.get("code") != "RECEIPT_MISMATCH": + raise AssertionError(f"normal-path receipt replay was not refused: {refused!r}") + + gate_recovery(runtime, "begin", environment) + recovery = run_json([ + sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "begin", "--construction", str(construction_path), + "--compaction-epoch", str(index + 20), "--request-epoch", str(index + 120), + ], environment) + receipt = recovery.get("receipt") + if recovery.get("state") != "PENDING_DELIVERY" or not isinstance(receipt, str): + raise AssertionError(f"fresh recovery retry did not pend: {recovery!r}") + record_production_observation(runtime, receipt, root, environment) + gate_recovery(runtime, "complete", environment) + promoted = run_json([sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "complete"], environment) + if promoted.get("ok") is not True or promoted.get("state") != "VERIFIED": + raise AssertionError(f"recovery consume-before-promote failed: {promoted!r}") + replay = run_json([sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "complete"], environment) + if replay.get("ok") is not False or replay.get("code") != "INVALID_LEASE_TRANSITION": + raise AssertionError(f"consumed recovery challenge re-promoted: {replay!r}") + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=3.0) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + shutil.rmtree(root, ignore_errors=True) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--runs", type=int, default=3) + arguments = parser.parse_args() + if arguments.runs != 3: + raise SystemExit("P6 requires exactly three isolated runs") + for index, runtime in enumerate(("pi", "claude", "pi")): + run_once(index, runtime) + print("P6 constrained recovery probe PASS: 3 isolated shipped recovery-command runs") + + +if __name__ == "__main__": + main() diff --git a/docs/design/791-upgrade-config-protection.md b/docs/design/791-upgrade-config-protection.md new file mode 100644 index 00000000..2f1be6c8 --- /dev/null +++ b/docs/design/791-upgrade-config-protection.md @@ -0,0 +1,290 @@ +# Design — #791: Framework upgrades must not destroy operator-owned config under `~/.config/mosaic` + +- **Issue:** mosaicstack/stack#791 +- **Branch:** `feat/791-upgrade-config-protection` (off `origin/main` `9745bc3f`) +- **Author:** ms-791 worker lane +- **Status:** Phase 1 — DESIGN, awaiting MS-LEAD confirmation before implementation +- **Ratified scope (Mos-approved, not re-litigated):** deliver **(b) strict ownership separation [PRIMARY]** + **(a) transactional pre-update snapshot [safety net]** + **(d) regeneration-from-SSOT [recovery]**. **(c) periodic backup timer is DEFERRED** — noted as future work only. + +--- + +## 1. Current updater behavior + exact wipe mechanism (evidence) + +### 1.1 What runs on `mosaic update` + +`mosaic update` re-seeds the framework by invoking the **bash installer** in sync-only, keep mode: + +- `packages/mosaic/src/runtime/update-checker.ts:509` `buildReseedCommand()` returns + `bash /install.sh` with env `MOSAIC_SYNC_ONLY=1`, `MOSAIC_INSTALL_MODE=keep`, + `MOSAIC_HOME=`. +- The same `install.sh` is the direct/`tools/install.sh` upgrade path and the framework-vN migration path. + +So the destructive surface is **`packages/mosaic/framework/install.sh`**. + +### 1.2 The wipe + +`sync_framework()` (`install.sh:177`) performs, in `keep` mode: + +``` +rsync -a --delete --exclude .git --exclude .framework-version --exclude '*.pre-constitution.bak' \ + [--exclude "/$path" for each PRESERVE_PATHS entry] SOURCE_DIR/ TARGET_DIR/ +``` + +- `install.sh:199` — `rsync -a --delete`. **`--delete` prunes every path in `~/.config/mosaic` + that is NOT present in the shipped framework source**, unless excluded. +- `install.sh:47` — `PRESERVE_PATHS` is the **only** thing standing between `--delete` and operator + data. It is a _denylist of exclusions_: + ``` + PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" + "memory" "sources" "credentials" "fleet/roster.yaml" "fleet/roster.json" "fleet/agents" + "fleet/run" "fleet/backlog" "fleet/roles.local") + ``` +- The cp-fallback (no rsync) is equally destructive: `install.sh:223` + `find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ... -exec rm -rf {} +` then re-copies source, restoring + only PRESERVE_PATHS globs. + +**Root-cause model:** _"Everything under `~/.config/mosaic` is framework-owned and pruneable UNLESS +explicitly preserved."_ Any operator path the list forgets is destroyed on the next upgrade. + +### 1.3 The exact operator paths wiped + +Cross-referencing the issue's operator-owned list against `PRESERVE_PATHS`: + +| Operator path (issue #791) | In PRESERVE_PATHS? | Fate on `mosaic update` | +| ----------------------------------------------------------------- | --------------------------------------- | ----------------------- | +| `agents/*.conf` (per-agent runtime) | **NO** | **WIPED** | +| `policy/*.md` (operator overlays) | **NO** | **WIPED** | +| `*.local.md` (SOUL/USER/STANDARDS) | **NO** | **WIPED** | +| harvester / SOP artifacts + timers | **NO** | **WIPED** | +| `tools/_lib/credentials.json` | **NO** (`credentials/` dir ≠ this path) | **WIPED** | +| `fleet/agents/*.env` | yes (`fleet/agents`, added by #631) | survives | +| `memory/`, `fleet/roster.*`, `fleet/backlog`, `fleet/roles.local` | yes | survives | + +The `fleet/agents`, `memory`, `fleet/backlog` entries were **retro-added after prior incidents** +(#631). This whack-a-mole is the structural signature of a denylist. + +**Stale-comment evidence:** `update-checker.ts:492` claims the reseed preserves +"`SOUL/USER/*.local/credentials`" — but `PRESERVE_PATHS` contains **no `*.local` entry**. The code +documents protection it does not deliver. + +### 1.4 Second code path (TS) — already non-destructive, but drifted + +`FileConfigAdapter.syncFramework()` (`packages/mosaic/src/config/file-adapter.ts:157`) → +`syncDirectory()` (`packages/mosaic/src/platform/file-ops.ts:66`) is a **copy-overlay**: it copies +source over target and skips preserved paths, but **never deletes** target paths absent from source +(`file-ops.ts:77-109`). It is used by the wizard/init flow, not `mosaic update`. + +Two problems remain: + +1. Its `preservePaths` (`file-adapter.ts:164-185`) has **already diverged** from `install.sh` — it is + **missing `fleet/backlog` and `fleet/roles.local`**. Two hand-maintained denylists, drifted. This + is direct evidence for a single shared SSOT manifest. +2. Even non-destructive, it will happily _overwrite_ an operator file that collides with a + framework-shipped path unless that path is on its (incomplete) preserve list. + +### 1.5 Existing snapshot is inadequate for rollback + +`make_snapshot()`/`restore_snapshot()` (`install.sh:76-87`) copy `TARGET_DIR` to `mktemp -d` under +`/tmp`, restore **only on `ERR/INT/TERM` trap**, and are **deleted on success** (`cleanup_snapshot`, +`install.sh:345`). Consequences: ephemeral `/tmp`, no retention, no post-success rollback, and **no +`mosaic restore`**. It is crash-safety only, not the transactional safety net #791 requires. + +--- + +## 2. Fix (b) — Strict ownership separation [PRIMARY / root cause] + +### 2.1 Ownership model (invert to allow-list) + +Replace _"framework-owned unless preserved"_ with _"operator-owned unless framework-owned"_, resolved +**per target path** with operator carve-outs winning inside shared framework subtrees. + +Two declared lists, one SSOT data file shipped in the framework +(`framework/framework-manifest.json`), consumed by **both** bash and TS: + +- **`framework` globs** — paths the updater is entitled to create / overwrite / prune. Authored to + match exactly what the framework ships in `packages/mosaic/framework/` (e.g. `CONSTITUTION.md`, + `AGENTS.md`, `STANDARDS.md`, `TOOLS.md`, `guides/**`, `constitution/**`, `templates/**`, `tools/**`, + `skills/**`, `mcp/**`, `defaults/**`, `fleet/examples/**`, `fleet/roles/**`, `fleet/profiles/**`, + `fleet/roster.schema.json`). +- **`operatorReserved` globs** — NEVER written or pruned, even nested inside a `framework` subtree; + these **win** over `framework` (deny-wins / most-specific-wins). At minimum: + `agents/**`, `policy/**`, `memory/**`, `sources/**`, `credentials/**`, `*.local.md`, + `tools/_lib/credentials.json`, `fleet/roster.yaml`, `fleet/roster.json`, `fleet/agents/**`, + `fleet/run/**`, `fleet/backlog/**`, `fleet/roles.local/**`, plus operator harvester/SOP artifacts. + +### 2.2 Ownership resolution for a target path `P` + +1. `P` matches `operatorReserved` → **operator-owned**: updater MUST NOT write, MUST NOT delete. +2. else `P` matches `framework` → **framework-owned**: may overwrite; may prune **only if absent from + the current SOURCE** (a genuinely retired framework file). +3. else (matches neither) → **UNKNOWN ⇒ operator-owned by default (fail-safe)**: never delete. + +Rule 3 is the actual root-cause fix: an operator path the manifest authors forget is still protected, +because _unknown defaults to operator_. A denylist can never provide this guarantee. + +### 2.3 Sync mechanism change (the mechanically-critical part) + +`--delete` cannot express "prune only framework-owned" without re-enumerating every operator path +(the denylist trap). So: + +1. **Drop `--delete` from the bulk sync.** Copy `SOURCE → TARGET` non-destructively (writes/overwrites + all framework files; deletes nothing). rsync without `--delete`, or the existing overlay copy. +2. **Explicit manifest-scoped prune pass.** Iterate the **`framework` manifest** (not the whole tree); + for each framework path present in `TARGET` but **absent in `SOURCE`**, delete it — after + re-checking it does not match `operatorReserved`. Because the prune iterates only declared + framework globs, operator/unknown paths are **structurally unreachable** by deletion. + +This is implemented in both bash `sync_framework()` and TS `syncFramework()` from the shared manifest. +A pure **prune-planner** function (TS) computes the delete-set from +`(manifest, sourceListing, targetListing)` so the invariant is unit-testable in isolation. +`PRESERVE_PATHS` becomes redundant (kept as a defense-in-depth alias mapping to `operatorReserved`, or +removed) — either way the two lists stop drifting because they read one file. + +### 2.4 HARD GATE test — "upgrade touches no path outside the manifest" + +Filesystem-observation test in the existing `test-install-migration.sh` harness pattern (mktemp +`MOSAIC_HOME`, `MOSAIC_SYNC_ONLY=1`), plus TS specs: + +1. Seed a throwaway `TARGET` with a realistic operator mix — one sentinel per operator class: + `agents/x.conf`, `policy/p.md`, `SOUL.local.md`, `memory/m.md`, + `tools/_lib/credentials.json` (with a secret value), `fleet/agents/a.env`, `fleet/roster.yaml`, + `harvester/sop.md`, **and a deliberately-unanticipated `unknown-operator-dir/x`**. +2. Record hash+mtime of every sentinel. +3. Run the upgrade from a `SOURCE` containing none of those operator paths. +4. **Assert:** every sentinel exists, byte-identical, **mtime unchanged** (not even rewritten). The + `unknown-operator-dir` surviving proves the fail-safe default — a denylist could not pass this case. +5. **Positive controls:** framework files WERE updated; a retired framework file WAS pruned. +6. **Property test** (TS prune-planner): for fuzzed operator paths, `deleteSet ⊆ {matches framework ∧ +in target ∧ not in source}` and `deleteSet ∩ operatorReserved = ∅`. + +--- + +## 3. Fix (a) — Transactional pre-update snapshot [safety net] + +- **Destination:** `${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-/`. + **Outside `~/.config/mosaic`** (so no future sync can sweep it) and outside any repo. +- **Perms:** dir `0700`, files `0600` — enforced with `umask 077` around the copy **and** explicit + `chmod`. Never world-readable. +- **Scope:** the operator-owned surface (`operatorReserved` paths that exist) — bounded; does not copy + the framework tree. +- **Timing:** taken before ANY mutation in the upgrade flow. +- **Post-sync verify + selective restore:** after sync, diff the operator surface against the snapshot; + since (b) should never touch operator paths, any diff means a manifest bug — restore the affected + paths from the snapshot and warn loudly. This is precisely (a) catching a miss in (b). +- **Retention:** keep N most-recent (default 5; `MOSAIC_BACKUP_RETENTION` override); prune older. +- **`mosaic restore`:** `--list` (default, dry-run) enumerates snapshots by timestamp; + `--from ` restores that snapshot over the operator surface, confirmation-gated. Reports + counts/paths only. +- **Secret-safety:** snapshot copy and restore never emit file **contents**; only paths/counts. + Tests assert `0700/0600` and that no secret value appears in stdout/stderr. + +--- + +## 4. Fix (d) — Regeneration-from-SSOT [recovery] + +The incident's live blast radius: `fleet/agents/*.env` (systemd `EnvironmentFile` sources) gone → +`mosaic-agent@` boots **unit defaults** on restart (because `EnvironmentFile=-...` is +absent-tolerant) → **silent identity/runtime/workdir downgrade**. + +The SSOT for those `.env` files is the roster. The reconciler **already** separates a +`regenerate-projections-from-roster` projection phase from lifecycle +(`packages/mosaic/src/fleet/fleet-reconciler.ts:93,234`; env rendering in +`generated-env-boundary.ts:149-264`). + +**`mosaic fleet regen`** is therefore a **thin recovery-framed wrapper over the existing projection +phase** — it does NOT reimplement fleet logic and does NOT preempt in-flight FCM cards (M4/M5): + +- Regenerates derivable config (per-agent `*.env.generated`, unit files) from roster SSOT. +- **Preview-first:** dry-run default; `--write` to apply. Idempotent. +- **Never restarts agents** (the recovery order forbids restart-before-verify). +- Prints the runbook's next step (verify `EnvironmentFile` resolves, THEN restart). + +Alternatively documentable as `install.sh --relink` per the issue; `mosaic fleet regen` is preferred +because it reuses the merged reconciler plumbing. + +--- + +## 5. Secret-safety approach (secrev surface) + +- Snapshots/backups: `0700`/`0600`, outside any repo, never world-readable. (§3) +- No secret **value** ever emitted to logs/stdout/stderr by snapshot, restore, sync, or regen — + paths/counts only. Adversarial test: a secret value placed in `tools/_lib/credentials.json` must + never appear in installer or command output. +- `tools/_lib/credentials.json` is an explicit `operatorReserved` carve-out inside the framework-owned + `tools/**` subtree — it is never overwritten or pruned. +- The HARD GATE test doubles as a secret-safety test (asserts the credentials sentinel is untouched). + +--- + +## 6. Test plan (TDD, tests-first, ≥85% on new code, co-located `*.spec.ts`) + +1. **Manifest SSOT parity** — bash and TS resolve identical framework/operator sets from the one file; + a test fails if either path hard-codes a divergent list. +2. **Manifest completeness** — every path shipped in `framework/` is covered by a `framework` glob (so + a new shipped file cannot silently fall outside the manifest and become un-prunable/undeclared). +3. **HARD GATE** — upgrade touches nothing outside the manifest, incl. the unanticipated-path case + (§2.4). +4. **Prune-planner** unit + property tests (§2.4.6). +5. **Snapshot** — perms `0700/0600`, correct destination, retention prune, secret value absent from + output. +6. **Restore** — `--list` / `--from` round-trip restores operator surface byte-exact; confirmation + gate; no secret leakage. +7. **Regen** — roster→env projection deterministic + idempotent; dry-run makes no writes; `--write` + restores `*.env`; **never** issues a lifecycle/restart call. +8. **Cross-path regression** — TS `syncFramework` and bash `install.sh` agree on a shared fixture + (closes the current #631-style drift). + +Gates before every push: `pnpm typecheck && pnpm lint && pnpm format:check` + mosaic package tests +green. Never `--no-verify`. + +--- + +## 7. web1 recovery runbook (operator-agnostic; web1 specifics live in the issue as evidence only) + +For a currently-wiped fleet EnvironmentFile state — **do NOT service-restart while +`fleet/agents/*.env` is absent** (a restart boots unit defaults and silently downgrades identity): + +1. **Regenerate:** `mosaic fleet regen --write` — rebuild `~/.config/mosaic/fleet/agents/*.env` from + roster SSOT. +2. **Verify each unit resolves to the intended runtime/workdir** _before_ any restart: + `systemctl --user show mosaic-agent@ -p EnvironmentFile` and confirm the generated env exists + and carries the intended `MOSAIC_AGENT_*` runtime/workdir values. +3. **Only then** `systemctl --user restart mosaic-agent@`, one unit at a time. + +If config (not just fleet env) was lost, `mosaic restore --list` → `mosaic restore --from ` before +step 1. + +--- + +## 8. Proposed PR split (reviewable; DAG-ordered) + +| PR | Scope | Depends | Review focus | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -------------------------- | +| PR1 | **PRIMARY** — shared `framework-manifest.json` + ownership resolver + non-deleting sync + scoped prune (bash + TS) + **HARD GATE** + prune-planner tests | — | correctness (root fix) | +| PR2 | **Safety net** — pre-update snapshot (`~/.local/state`, 0700/0600, retention) + post-sync verify/restore + `mosaic restore` | PR1 | **secrev** (backup/secret) | +| PR3 | **Recovery** — `mosaic fleet regen` (projection-only, preview-first, no restart) + docs (upgrade-safety + recovery runbook) | PR1 | correctness + docs | + +Rationale: PR1 closes the failure class on its own; if PR2/PR3 slip, the class stays fixed. Each PR is +one reviewable unit with its own tests ≥85%. Independent review (author≠reviewer) on all; **secrev** on +PR2 (and PR1's secret-sentinel assertions). + +## 9. Deferred (noted per scope) + +**(c) periodic backup timer** — a systemd user timer snapshotting operator dirs on a cadence +(defense-in-depth for non-upgrade losses). Explicitly **out of scope now**; future phase. + +## 10. Constraints honored + +- **Framework-PR firewall:** manifest + logic are operator-agnostic; no SOUL/USER/operator specifics + in framework code; web1 details are issue evidence only. +- **Capacity-fill:** must not preempt M5-001 or #790; `fleet regen` reuses merged FCM-M3 plumbing and + does not overlap FCM-M4/M5 migration cards. +- **Delivery gates:** TDD tests-first, ≥85% new-code coverage, trunk-based squash PRs, independent + review + secrev, completion = merged PR + descendant-main green + #791 closed. + +--- + +**Requesting MS-LEAD confirmation of:** (1) the manifest allow-list + non-deleting-sync + scoped-prune +approach as the (b) root-cause fix; (2) snapshot destination/retention + `mosaic restore` UX; +(3) `mosaic fleet regen` as a projection-only wrapper; (4) the 3-PR split. Implementation begins only +on your confirmation. diff --git a/docs/design/storage-abstraction-middleware.md b/docs/design/storage-abstraction-middleware.md index 0d2232b2..85ce588a 100644 --- a/docs/design/storage-abstraction-middleware.md +++ b/docs/design/storage-abstraction-middleware.md @@ -70,6 +70,10 @@ export function createQueue(config?: QueueConfig): QueueHandle { ### `@mosaicstack/db` (packages/db/src/client.ts) +> **Historical design specimen — status-only, not an operator instruction.** KBN-101 supersedes +> this pre-split `DATABASE_URL` fallback shape; it cannot authorize runtime migration, DDL, or a +> connection-string fallback. See the KBN-101 runner/role contract for the produced interface. + ```typescript import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; diff --git a/docs/federation/MILESTONES.md b/docs/federation/MILESTONES.md index e9d9ba3b..612f818c 100644 --- a/docs/federation/MILESTONES.md +++ b/docs/federation/MILESTONES.md @@ -54,7 +54,7 @@ Every milestone adds tests to these layers. A milestone cannot be claimed comple - Add `"tier": "federated"` to `mosaic.config.json` schema and validators - Docker Compose `federated` profile (`docker-compose.federated.yml`) adds: Postgres+pgvector (5433), Valkey (6380), dedicated volumes - Tier detector in gateway bootstrap: reads config, asserts required services reachable, refuses to start otherwise -- `pgvector` extension installed + verified on startup +- **Historical/status only:** the prior startup-provisioning statement is superseded. Runtime/startup extension provisioning is forbidden. PostgreSQL activation remains non-operative with no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05 land; this record authorizes no current DDL, Compose/init, or startup path. - Migration logic: safe upgrade path from `local`/`standalone` → `federated` (data export/import script, one-way) - `mosaic doctor` reports tier + service health - Gateway continues to serve as a normal standalone instance (no federation yet) diff --git a/docs/federation/SETUP.md b/docs/federation/SETUP.md index 9125ac93..61436044 100644 --- a/docs/federation/SETUP.md +++ b/docs/federation/SETUP.md @@ -1,280 +1,74 @@ # Federated Tier Setup Guide -## What is the federated tier? - -The federated tier is designed for multi-user and multi-host deployments. It consists of PostgreSQL 17 with pgvector extension (for embeddings and RAG), Valkey for distributed task queueing and caching, and a shared configuration across multiple Mosaic gateway instances. Use this tier when running Mosaic in production or when scaling beyond a single-host deployment. - -## Prerequisites - -- Docker and Docker Compose installed -- Ports 5433 (PostgreSQL) and 6380 (Valkey) available on your host (or adjust environment variables) -- At least 2 GB free disk space for data volumes - -## Start the federated stack - -Run the federated overlay: - -```bash -docker compose -f docker-compose.federated.yml --profile federated up -d -``` - -This starts PostgreSQL 17 with pgvector and Valkey 8. The pgvector extension is created automatically on first boot. - -Verify the services are running: - -```bash -docker compose -f docker-compose.federated.yml ps -``` - -Expected output shows `postgres-federated` and `valkey-federated` both healthy. - -## Configure mosaic for federated tier - -Create or update your `mosaic.config.json`: - -```json -{ - "tier": "federated", - "database": "postgresql://mosaic:mosaic@localhost:5433/mosaic", - "queue": "redis://localhost:6380" -} -``` - -If you're using environment variables instead: - -```bash -export DATABASE_URL="postgresql://mosaic:mosaic@localhost:5433/mosaic" -export REDIS_URL="redis://localhost:6380" -``` - -## Verify health - -Run the health check: - -```bash -mosaic gateway doctor -``` - -Expected output (green): - -``` -Tier: federated Config: mosaic.config.json - ✓ postgres localhost:5433 (42ms) - ✓ valkey localhost:6380 (8ms) - ✓ pgvector (embedded) (15ms) -``` - -For JSON output (useful in CI/automation): - -```bash -mosaic gateway doctor --json -``` - -## Step 2: Step-CA Bootstrap - -Step-CA is a certificate authority that issues X.509 certificates for federation peers. In Mosaic federation, it signs peer certificates with custom OIDs that embed grant and user identities, enforcing authorization at the certificate level. - -### Prerequisites for Step-CA - -Before starting the CA, you must set up the dev password: - -```bash -cp infra/step-ca/dev-password.example infra/step-ca/dev-password -# Edit dev-password and set your CA password (minimum 16 characters) -``` - -The password is required for the CA to boot and derive the provisioner key used by the gateway. - -### Start the Step-CA service - -Add the step-ca service to your federated stack: - -```bash -docker compose -f docker-compose.federated.yml --profile federated up -d step-ca -``` - -On first boot, the init script (`infra/step-ca/init.sh`) runs automatically. It: - -- Generates the CA root key and certificate in the Docker volume -- Creates the `mosaic-fed` JWK provisioner -- Applies the X.509 template from `infra/step-ca/templates/federation.tpl` - -The volume is persistent, so subsequent boots reuse the existing CA keys. - -Verify the CA is healthy: - -```bash -curl https://localhost:9000/health --cacert /tmp/step-ca-root.crt -``` - -(If the root cert file doesn't exist yet, see the extraction steps below.) - -### Extract credentials for the gateway - -The gateway requires two credentials from the running CA: - -**1. Provisioner key (for `STEP_CA_PROVISIONER_KEY_JSON`)** - -```bash -docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json > /tmp/step-ca-provisioner.json -``` - -This JSON file contains the JWK public and private keys for the `mosaic-fed` provisioner. Store it securely and pass its contents to the gateway via the `STEP_CA_PROVISIONER_KEY_JSON` environment variable. - -**2. Root certificate (for `STEP_CA_ROOT_CERT_PATH`)** - -```bash -docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt -``` - -This PEM file is the CA's root certificate, used to verify peer certificates issued by step-ca. Pass its path to the gateway via `STEP_CA_ROOT_CERT_PATH`. - -### Custom OID Registry - -Federation certificates include custom OIDs in the certificate extension. These encode authorization metadata: - -| OID | Name | Description | -| ------------------- | ---------------------- | --------------------- | -| 1.3.6.1.4.1.99999.1 | mosaic_grant_id | Federation grant UUID | -| 1.3.6.1.4.1.99999.2 | mosaic_subject_user_id | Subject user UUID | - -These OIDs are verified by the gateway after the CSR is signed, ensuring the certificate was issued with the correct grant and user context. - -### Environment Variables - -Configure the gateway with the following environment variables before startup: - -| Variable | Required | Description | -| ------------------------------ | -------- | --------------------------------------------------------------------------------------------------------- | -| `STEP_CA_URL` | Yes | Base URL of the step-ca instance, e.g. `https://step-ca:9000` (use `https://localhost:9000` in local dev) | -| `STEP_CA_PROVISIONER_KEY_JSON` | Yes | JSON-encoded JWK from `/home/step/secrets/mosaic-fed.json` | -| `STEP_CA_ROOT_CERT_PATH` | Yes | Absolute path to the root CA certificate (e.g. `/tmp/step-ca-root.crt`) | -| `BETTER_AUTH_SECRET` | Yes | Secret used to seal peer private keys at rest; already required for M1 | - -Example environment setup: - -```bash -export STEP_CA_URL="https://localhost:9000" -export STEP_CA_PROVISIONER_KEY_JSON="$(cat /tmp/step-ca-provisioner.json)" -export STEP_CA_ROOT_CERT_PATH="/tmp/step-ca-root.crt" -export BETTER_AUTH_SECRET="" -``` - -## Troubleshooting - -### Port conflicts - -**Symptom:** `bind: address already in use` - -**Fix:** Stop the base dev stack first: - -```bash -docker compose down -docker compose -f docker-compose.federated.yml --profile federated up -d -``` - -Or change the host port with an environment variable: - -```bash -PG_FEDERATED_HOST_PORT=5434 VALKEY_FEDERATED_HOST_PORT=6381 \ - docker compose -f docker-compose.federated.yml --profile federated up -d -``` - -### pgvector extension error - -**Symptom:** `ERROR: could not open extension control file` - -**Fix:** pgvector is created at first boot. Check logs: - -```bash -docker compose -f docker-compose.federated.yml logs postgres-federated | grep -i vector -``` - -If missing, exec into the container and create it manually: - -```bash -docker exec psql -U mosaic -d mosaic -c "CREATE EXTENSION vector;" -``` - -### Valkey connection refused - -**Symptom:** `Error: connect ECONNREFUSED 127.0.0.1:6380` - -**Fix:** Check service health: - -```bash -docker compose -f docker-compose.federated.yml logs valkey-federated -``` - -If Valkey is running, verify your firewall allows 6380. On macOS, Docker Desktop may require binding to `host.docker.internal` instead of `localhost`. - -## Key rotation (deferred) - -Federation peer private keys (`federation_peers.client_key_pem`) are sealed at rest using AES-256-GCM with a key derived from `BETTER_AUTH_SECRET` via SHA-256. If `BETTER_AUTH_SECRET` is rotated, all sealed `client_key_pem` values in the database become unreadable and must be re-sealed with the new key before rotation completes. - -The full key rotation procedure (decrypt all rows with old key, re-encrypt with new key, atomically swap the secret) is out of scope for M2. Operators must not rotate `BETTER_AUTH_SECRET` without a migration plan for all sealed federation peer keys. - -## OID Assignments — Mosaic Internal OID Arc - -Mosaic uses the private enterprise arc `1.3.6.1.4.1.99999` for custom X.509 -certificate extensions in federation grant certificates. - -**IMPORTANT:** This is a development/internal OID arc. Before deploying to a -production environment accessible by external parties, register a proper IANA -Private Enterprise Number (PEN) at -and update these assignments accordingly. - -### Assigned OIDs - -| OID | Symbolic name | Description | -| --------------------- | --------------------------------- | --------------------------------------------------------- | -| `1.3.6.1.4.1.99999.1` | `mosaic.federation.grantId` | UUID of the `federation_grants` row authorising this cert | -| `1.3.6.1.4.1.99999.2` | `mosaic.federation.subjectUserId` | UUID of the local user on whose behalf the cert is issued | - -### Encoding - -Each extension value is DER-encoded as an ASN.1 **UTF8String**: - -``` -Tag 0x0C (UTF8String) -Length 0x24 (36 decimal — fixed length of a UUID string) -Value <36 ASCII bytes of the UUID> -``` - -The step-ca X.509 template at `infra/step-ca/templates/federation.tpl` -produces this encoding via the Go template expression: - -``` -{{ printf "\x0c\x24%s" .Token.mosaic_grant_id | b64enc }} -``` - -The resulting base64 value is passed as the `value` field of the extension -object in the template JSON. - -### CA Environment Variables - -The `CaService` (`apps/gateway/src/federation/ca.service.ts`) requires the -following environment variables at gateway startup: - -| Variable | Required | Description | -| ------------------------------ | -------- | -------------------------------------------------------------------- | -| `STEP_CA_URL` | Yes | Base URL of the step-ca instance, e.g. `https://step-ca:9000` | -| `STEP_CA_PROVISIONER_PASSWORD` | Yes | JWK provisioner password for the `mosaic-fed` provisioner | -| `STEP_CA_PROVISIONER_KEY_JSON` | Yes | JSON-encoded JWK (public + private) for the `mosaic-fed` provisioner | -| `STEP_CA_ROOT_CERT_PATH` | Yes | Absolute path to the step-ca root CA certificate PEM file | - -Set these variables in your environment or secret manager before starting -the gateway. In the federated Docker Compose stack they are expected to be -injected via Docker secrets and environment variable overrides. - -### Fail-loud contract - -The CA service (and the X.509 template) are designed to fail loudly if the -custom OIDs cannot be embedded: - -- The template produces a malformed extension value (zero-length UTF8String - body) when the JWT claims `mosaic_grant_id` or `mosaic_subject_user_id` are - absent. step-ca rejects the CSR rather than issuing a cert without the OIDs. -- `CaService.issueCert()` throws a `CaServiceError` on every error path with - a human-readable `remediation` string. It never silently returns a cert that - may be missing the required extensions. +> **KBN-101 N-1 hold:** This page is **non-operative** and grants no current command +> authority until KBN-101-00, KBN-101-03, and KBN-101-05 land and KBN-101-08 activates a +> reviewed release. It does not authorize a deployment operation, initialization artifacts, +> implicit extension/schema/migration creation, raw `CREATE`, direct database initialization, or +> a Gateway against an unverified database. The prior direct-start wording is retired; its +> regression fixture is owned by KBN-101-06. + +## Held future procedure + +This section is non-operative and grants no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05 land. + +The deployment control plane—not an operator shell or deployment lifecycle hook—performs this +exact held future sequence after activation authorization: external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. + +1. External bootstrap provisions the approved database/extension prerequisites. +2. TLS/roles are installed through the generation-pinned renderer. +3. The dedicated one-shot runner executes `mosaic-db-migrator --run`. +4. The same runner executes `mosaic-db-migrator --verify`, including readiness and the + importer-target attestation where that route is enabled. +5. Only after successful verification may Gateway reach its independent verified-TLS Gateway + readiness gate. + +No step may be reordered, skipped, replaced by a raw SQL command, or delegated to an initialization +hook. +A missing extension, schema, migration, role, secret generation, or readiness proof is a failed +control-plane precondition; it is not an instruction to start Compose, retry startup, or create +anything directly. + +## N-1 status and required disposition + +The current branch retains historical federation artifacts, but they are not a deployable +procedure. `docs/federation/TASKS.md` records their shipped status only. KBN-101-02 retires +runtime/init DDL; KBN-101-05 owns the renderer/deployment handoff; KBN-101-06 verifies the +finite scanner and command matrix; and KBN-101-07 owns this operator route. A path named in an +inventory, a historical-status label, or a normative requirement cannot suppress the semantic +checks above. + +Until the activation certificate names an exact release, use no database startup or recovery +command from this document. For the produced importer interface, see +[the federated tier migration contract](../guides/migrate-tier.md); it is likewise non-operative +until activation. + +## Federation and Step-CA reference + +Federation uses PostgreSQL 17 with pgvector, Valkey, and a shared configuration across multiple +Gateway instances. Step-CA issues federation peer X.509 certificates whose custom OIDs carry a +grant and subject identity. The following facts are reference material only; provisioning and +secret delivery remain deployment-control-plane work under the activation sequence. + +| OID | Name | Description | +| ------------------- | ------------------------ | --------------------- | +| 1.3.6.1.4.1.99999.1 | `mosaic_grant_id` | Federation grant UUID | +| 1.3.6.1.4.1.99999.2 | `mosaic_subject_user_id` | Subject user UUID | + +The internal arc `1.3.6.1.4.1.99999` is development-only. Before an externally reachable +production deployment, register an IANA Private Enterprise Number and version the assignments. +Each value is DER-encoded as an ASN.1 UTF8String containing the UUID. + +The future activated Gateway requires `STEP_CA_URL`, `STEP_CA_PROVISIONER_PASSWORD`, +`STEP_CA_PROVISIONER_KEY_JSON`, `STEP_CA_ROOT_CERT_PATH`, and `BETTER_AUTH_SECRET` through the +reviewed secret mechanism. These names do not authorize shell exports, copied credential files, +or an ad hoc service start. + +## Failure disposition + +- A TLS, CA, SAN, role, runner, or readiness failure is a control-plane incident. Preserve only + sanitized evidence and follow the approved rollback/repair record. +- A pgvector/extension failure is a failed external-bootstrap or runner precondition. Do not use + direct extension SQL, init artifacts, or a startup retry as remediation. +- A port, container, or Valkey problem does not permit bypassing the activation sequence. +- Federation peer-key rotation remains deferred until its separately approved migration plan; + do not rotate `BETTER_AUTH_SECRET` without that plan. diff --git a/docs/federation/TASKS.md b/docs/federation/TASKS.md index 03e2cc2c..1256ce27 100644 --- a/docs/federation/TASKS.md +++ b/docs/federation/TASKS.md @@ -15,20 +15,20 @@ Goal: Gateway runs in `federated` tier with containerized PG+pgvector+Valkey. No federation logic yet. Existing standalone behavior does not regress. -| id | status | description | issue | agent | branch | depends_on | estimate | notes | -| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| FED-M1-01 | done | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | sonnet | feat/federation-m1-tier-config | — | 4K | Shipped in PR #470. Renamed `team` → `standalone`; added `team` deprecation alias; added `DEFAULT_FEDERATED_CONFIG`. | -| FED-M1-02 | done | Author `docker-compose.federated.yml` as an overlay profile: Postgres 17 + pgvector extension (port 5433), Valkey (6380), named volumes, healthchecks. Compose-up should boot cleanly on a clean machine. | #460 | sonnet | feat/federation-m1-compose | FED-M1-01 | 5K | Shipped in PR #471. Overlay defines `postgres-federated`/`valkey-federated`, profile-gated, with pg-init for pgvector extension. | -| FED-M1-03 | done | Add pgvector support to `packages/storage/src/adapters/postgres.ts`: create extension on init (idempotent), expose vector column type in schema helpers. No adapter changes for non-federated tiers. | #460 | sonnet | feat/federation-m1-pgvector | FED-M1-02 | 8K | Shipped in PR #472. `enableVector` flag on postgres StorageConfig; idempotent CREATE EXTENSION before migrations. | -| FED-M1-04 | done | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | sonnet | feat/federation-m1-detector | FED-M1-03 | 8K | Shipped in PR #473. 12 tests; 5s timeouts on probes; pgvector library/permission discrimination; rejects non-bullmq for federated. | -| FED-M1-05 | done | Write `scripts/migrate-to-federated.ts`: one-way migration from `local` (PGlite) / `standalone` (PG without pgvector) → `federated`. Dumps, transforms, loads; dry-run + confirm UX. Idempotent on re-run. | #460 | sonnet | feat/federation-m1-migrate | FED-M1-04 | 10K | Shipped in PR #474. `mosaic storage migrate-tier`; DrizzleMigrationSource (corrects P0 found in review); 32 tests; idempotent. | -| FED-M1-06 | done | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Shipped in PR #475 as `mosaic gateway doctor`. Probes lifted to @mosaicstack/storage; structural TierConfig breaks dep cycle. | -| FED-M1-07 | done | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Shipped in PR #476. 3 test files, 4 tests, gated by FEDERATED_INTEGRATION=1; reserved-port helper avoids host collisions. | -| FED-M1-08 | done | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Shipped in PR #477. Caught P0 in M1-05 (camelCase→snake_case) missed by mocked unit tests; fix in same PR. | -| FED-M1-09 | done | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | sonnet | feat/federation-m1-regression | FED-M1-07 | 4K | Clean canary. 351 gateway tests + 85 storage unit tests + full pnpm test all green; only FEDERATED_INTEGRATION-gated tests skip. | -| FED-M1-10 | done | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | feat/federation-m1-security-review | FED-M1-09 | 8K | 2 review rounds caught 7 issues: credential leak in pg/valkey/pgvector errors + redact-error util; missing advisory lock; SKIP_TABLES rationale. | -| FED-M1-11 | done | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Shipped: `docs/federation/SETUP.md` (119 lines), `docs/guides/migrate-tier.md` (147 lines), README Configuration blurb. | -| FED-M1-12 | done | PR, CI green, merge to main, close #460. | #460 | sonnet | feat/federation-m1-close | FED-M1-11 | 3K | M1 closed. PRs #470-#480 merged across 11 tasks. Issue #460 closed; release tag `fed-v0.1.0-m1` published. | +| id | status | description | issue | agent | branch | depends_on | estimate | notes | +| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| FED-M1-01 | done | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | sonnet | feat/federation-m1-tier-config | — | 4K | Shipped in PR #470. Renamed `team` → `standalone`; added `team` deprecation alias; added `DEFAULT_FEDERATED_CONFIG`. | +| FED-M1-02 | done | Historical shipped-status record: authored a federated Compose overlay with PostgreSQL/pgvector, Valkey, volumes, and healthchecks. It is not a current startup, init, extension, schema, or migration procedure. | #460 | sonnet | feat/federation-m1-compose | FED-M1-01 | 5K | Shipped in PR #471 status only. KBN-101-02 retires its init authority; KBN-101-05 replaces deployment rendering; KBN-101-07 SETUP is non-operative until activation. | +| FED-M1-03 | done | Historical shipped-status record: add pgvector support to `packages/storage/src/adapters/postgres.ts`; no adapter changes for non-federated tiers. | #460 | sonnet | feat/federation-m1-pgvector | FED-M1-02 | 8K | Shipped in PR #472 status only. **KBN-101 supersedes this behavior:** it cannot authorize current runtime extension creation or any DDL; only the runner/external bootstrap contract may do so. | +| FED-M1-04 | done | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | sonnet | feat/federation-m1-detector | FED-M1-03 | 8K | Shipped in PR #473. 12 tests; 5s timeouts on probes; pgvector library/permission discrimination; rejects non-bullmq for federated. | +| FED-M1-05 | done | Historical shipped-status record: prior tier migration implementation. | #460 | sonnet | feat/federation-m1-migrate | FED-M1-04 | 10K | Shipped in PR #474 status only. **KBN-101 supersedes this route:** it cannot authorize current credentials, target connection, or DDL. The future active route requires runner verification plus target URL-file and signed attestation-file binding. | +| FED-M1-06 | done | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Shipped in PR #475 as `mosaic gateway doctor`. Probes lifted to @mosaicstack/storage; structural TierConfig breaks dep cycle. | +| FED-M1-07 | done | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Shipped in PR #476. 3 test files, 4 tests, gated by FEDERATED_INTEGRATION=1; reserved-port helper avoids host collisions. | +| FED-M1-08 | done | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Shipped in PR #477. Caught P0 in M1-05 (camelCase→snake_case) missed by mocked unit tests; fix in same PR. | +| FED-M1-09 | done | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | sonnet | feat/federation-m1-regression | FED-M1-07 | 4K | Clean canary. 351 gateway tests + 85 storage unit tests + full pnpm test all green; only FEDERATED_INTEGRATION-gated tests skip. | +| FED-M1-10 | done | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | feat/federation-m1-security-review | FED-M1-09 | 8K | 2 review rounds caught 7 issues: credential leak in pg/valkey/pgvector errors + redact-error util; missing advisory lock; SKIP_TABLES rationale. | +| FED-M1-11 | done | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Shipped: `docs/federation/SETUP.md` (119 lines), `docs/guides/migrate-tier.md` (147 lines), README Configuration blurb. | +| FED-M1-12 | done | PR, CI green, merge to main, close #460. | #460 | sonnet | feat/federation-m1-close | FED-M1-11 | 3K | M1 closed. PRs #470-#480 merged across 11 tasks. Issue #460 closed; release tag `fed-v0.1.0-m1` published. | **M1 total estimate:** ~74K tokens (over-budget vs 20K PRD estimate — explanation below) diff --git a/docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md b/docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md new file mode 100644 index 00000000..1dda88e2 --- /dev/null +++ b/docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md @@ -0,0 +1,78 @@ +# Fleet Configuration Management — Documentation IA Acceptance Checklist + +**Issue:** #758 · **Scope:** M0 documentation gate for the local fleet declarative-configuration program. + +This checklist is an acceptance contract for documentation and examples. It does not authorize +schema, runtime, systemd, role, profile, or live-fleet changes. An item is complete only when its +named artifact exists, is linked from the fleet documentation entry point, and its evidence is +recorded in the M5 closure report and linked deferral evidence. + +## M0 baseline acceptance + +- [x] `docs/PRD.md` states the roster as desired-state SSOT; generated environment, systemd, tmux, and heartbeat artifacts as non-authoritative projections; and fail-closed handling of unsupported or quarantined legacy input. +- [x] `docs/PRD.md` defines the required classes and authority boundary: `validator` certifies but does not merge; `merge-gate` remains sole approve-to-land/merge authority; `team-leader` capacity is lease-bounded; `interaction` is request/status only; instance names such as Tess and Ultron remain configurable. +- [x] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and observed state, including stopped-state preservation through migration, apply, and reboot. +- [x] `docs/PRD.md` defines the generated-env/local-override boundary, explicitly denies arbitrary command overrides in M1–M5, and requires key-name/hash-only quarantine diagnostics. +- [x] `docs/PRD.md` identifies the M1–M5 local-tmux scope and excludes remote reconciliation, connector mutation, secret references, arbitrary commands/channels, gateway convergence, and UI configuration storage. +- [x] `docs/TASKS.md` contains the complete M0–M5 one-card/one-PR dependency DAG for #758 with agent tier, branch, dependency, estimate, and evidence expectations. +- [x] `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` classifies every current shipped fleet example, profile, and service preset before M1 implementation starts. + +## Required documentation IA for M1–M5 + +| Path | Minimum content | Delivery gate | +| ------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------- | +| `docs/fleet/README.md` | Fleet configuration entry point, desired-vs-observed decision tree, link map | M5 | +| `docs/fleet/concepts/desired-vs-observed-state.md` | SSOT/projection model, drift, generation and ownership | M5 | +| `docs/fleet/concepts/identity-class-runtime.md` | Stable name, display alias, class, runtime/provider/model separation | M5 | +| `docs/fleet/concepts/role-authority-and-leases.md` | Required roles, validator/merge-gate separation, lease limits | M5 | +| `docs/fleet/concepts/generated-env-launch-chain.md` | Generated/local files, precedence, quarantine and non-shell parsing | M5 | +| `docs/fleet/reference/roster-v2.schema.json` | Executable v2 structural contract | M1 | +| `docs/fleet/reference/roster-v2-fields.md` | Every field, default, constraint, compatibility behavior and examples | M1 | +| `docs/fleet/reference/cli.md` | `config`, `agent`, lifecycle, plan/apply, JSON and exit-code contracts | M2–M3 | +| `docs/fleet/reference/role-classes.md` | Canonical classes, aliases, authority matrix and instance-name rule | M1 | +| `docs/fleet/reference/lifecycle-transitions.md` | Create/start/stop/restart/apply/reboot/rollback transition table | M3 | +| `docs/fleet/reference/status-and-drift.md` | Desired/observed/managed state, orphans, revision mismatch, doctor output | M3 | +| `docs/fleet/how-to/create-update-delete-agent.md` | Safe CRUD, expected generation, dry-run and rollback | M2 | +| `docs/fleet/how-to/start-stop-restart.md` | Persisted versus one-shot lifecycle actions | M3 | +| `docs/fleet/how-to/configure-tess-interaction.md` | Configurable interaction instance; no hardcoded identity | M5 | +| `docs/fleet/how-to/configure-ultron-validator.md` | Configurable validator instance; no merge authority | M5 | +| `docs/fleet/how-to/customize-roles.md` | Existing baseline + `roles.local` resolution and validation | M1 | +| `docs/fleet/operations/reconcile-and-recover.md` | Plan/apply failure recovery, generation lock and canary rollout | M3 | +| `docs/fleet/operations/env-quarantine.md` | Legacy-key inventory, private quarantine and redaction behavior | M2 | +| `docs/fleet/operations/systemd-tmux-troubleshooting.md` | Socket ambiguity, ownership proof, systemd/tmux drift | M3 | +| `docs/fleet/operations/backup-restore.md` | Roster/projection backup and rollback boundaries | M4 | +| `docs/fleet/operations/upgrade-assets.md` | Source-vs-installed asset revision detection and safe refresh | M5 | +| `docs/fleet/migration/v1-to-v2.md` | Normative field map, observed-state preservation and rollback | M4 | +| `docs/fleet/migration/example-profile-disposition.md` | Final disposition of every shipped example/profile | M1–M4 | +| `docs/fleet/migration/legacy-class-aliases.md` | Alias, unresolved-class, and retirement rules | M1 | + +## PRD acceptance-criteria mapping + +| PRD acceptance criterion | Owning card(s) | Required evidence | +| ------------------------------------------------------------ | ---------------------------------- | --------------------------------------------------------------------------------------- | +| `AC-FCM-01` schema, semantic validation, canonical rendering | FCM-M1-001, FCM-M1-002 | YAML/JSON positive/negative and schema/parser/resolver parity tests | +| `AC-FCM-02` deterministic plan and no-mutation check | FCM-M3-001 | Stable JSON/exit-code and desired-versus-observed fixture tests | +| `AC-FCM-03` safe generation-guarded CRUD | FCM-M2-002 | Create/update/delete idempotency, expected-generation, dry-run, and recovery tests | +| `AC-FCM-04` generated/local boundary and quarantine | FCM-M2-001 | Launch-chain, shadow, injection, redaction, and forbidden-key tests | +| `AC-FCM-05` lifecycle/reconcile/socket/drift safety | FCM-M3-001, FCM-M3-002 | Isolated systemd/tmux, stopped-state, orphan, socket, and rollback evidence | +| `AC-FCM-06` v1 migration and example/profile disposition | FCM-M4-001, FCM-M4-002, FCM-M1-003 | Preview/canary/rollback fixture plus executable disposition inventory | +| `AC-FCM-07` authority and lease boundaries | FCM-M1-002 | Role/authority/lease denial tests and resolved role contracts | +| `AC-FCM-08` documentation and final release gate | FCM-M5-001, FCM-M5-002 | Checklist closure, link/example validation, reviews, certificate, and terminal-green CI | + +## Cross-cutting evidence gates + +- [x] Every retained or migrated YAML/JSON example, profile, and service preset validates through the same declared executable production parser/resolver path recorded by the disposition inventory; versioned v1 fixtures are not forced through the v2 compiler. +- [x] Every retired example/profile/service preset has a replacement link and deprecation note; no unresolved legacy class or tool-policy alias remains silently shipped. +- [x] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded Tess/Ultron identity. +- [x] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed mosaic agent catalog. +- [x] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of legacy sensitive keys are never printed. +- [x] M5 documentation validation verifies required IA paths, local file and heading-fragment links, the canonical roster through the production compiler/resolver, and fenced/canonical-example safety checks. +- [ ] FCM-M5-001 does not deterministically assert owner/evidence/deferral metadata for every checklist row. Closure and deferral reports provide human-reviewable evidence only; broader assertion coverage remains unclaimed. + +## Held downstream gates + +These unchecked items are intentionally outside FCM-M5-001 and are not authorized by this checklist: + +- [ ] FCM-M4-002 executes and evidences live cutover, canary, stopped-state preservation, and rollback. +- [ ] FCM-M5-002 completes independent exact-head review and issues the validator certificate. +- [ ] The exact PR head reaches terminal-green CI after independent review. diff --git a/docs/fleet/FLEET-LAUNCH.md b/docs/fleet/FLEET-LAUNCH.md index 515aabe6..758a1e99 100644 --- a/docs/fleet/FLEET-LAUNCH.md +++ b/docs/fleet/FLEET-LAUNCH.md @@ -1,114 +1,81 @@ # Fleet Launch Runbook -How every Mosaic fleet agent — workers **and** the orchestrator — is launched, and how to -configure each one. The guiding principle: **one roster-driven launcher**. There is no bespoke -per-agent launch script; the roster plus per-agent `.env` files are the single source of launch -config. +The local fleet roster is the sole writable desired-state authority for membership and launch policy. +Generated environment files are rebuildable projections, not an operator-editable command surface. -## The launch chain +## Launch chain -| Layer | File | Responsibility | -| ---------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| systemd unit | `mosaic-agent@.service` | One templated unit per role; `ExecStart` runs the session launcher with the instance name `%i`. Defaults `MOSAIC_AGENT_RUNTIME=pi`, `MOSAIC_AGENT_NAME=%i`. | -| session launcher | `tools/fleet/start-agent-session.sh ` | Builds the launch command, opens the tmux pane, wires the heartbeat. | -| launch command | `mosaic yolo ` (or a per-agent override) | Replaces the pane's foreground process with the runtime, fully seeded. | -| seeding | `mosaic`'s `composeContract()` | Injects the Constitution/USER/TOOLS/runtime contract, `*.local` overlays, **and** the Fleet-Comms cheat-sheet — all via `--append-system-prompt`. | +| Layer | Responsibility | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. | +| Projection writer | Renders deterministic fleet/agents/.env.generated from the roster. | +| Optional local data | Reads a strict, data-only fleet/agents/.env.local; it cannot shadow generated keys. | +| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. | +| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. | +| runtime launch | Derives the fixed mosaic yolo argument array from validated roster data, then seeds the runtime contract. | -Per-agent overrides live in `fleet/agents/.env`, generated from `roster.yaml` by -`generateAgentEnv` (`packages/mosaic/src/commands/fleet.ts`) and consumed by the launcher. +The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied +command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing, +secret-like key names, duplicate keys, comments, quoted/export syntax, and unsafe values are rejected. -## Worker launch path (default) +## Generated and local files -1. `roster.yaml` carries each agent's `runtime` and optional `model_hint`. -2. `generateAgentEnv` emits `fleet/agents/.env` with `MOSAIC_AGENT_NAME`, - `MOSAIC_AGENT_RUNTIME`, and `MOSAIC_AGENT_MODEL`. -3. `start-agent-session.sh` has no `MOSAIC_AGENT_COMMAND` set, so it falls through to the default - (line ~44): - ```sh - MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}" - ``` -4. The launcher bakes `MOSAIC_AGENT_NAME` into the pane command (line ~118), so `composeContract` - can inject the Fleet-Comms cheat-sheet for that role. +.env.generated is complete, deterministic, and written only by Mosaic. Its ordered keys are: -That is the whole worker path: roster → `.env` → `mosaic yolo ` → seeded pane. - -## Orchestrator fold (PATH A — ships today) - -The orchestrator is **just another roster agent** launched through the canonical path — not a -snowflake script. - -| Piece | Value | -| ------------------ | ----------------------------------- | -| host-side launcher | `orchestrator-launch.sh` | -| systemd unit | `mosaic-fleet-orchestrator.service` | -| tmux session | `orchestrator` (role-named) | - -Set its launch command via `fleet/agents/orchestrator.env`: - -```sh -MOSAIC_AGENT_COMMAND='mosaic yolo claude --channels plugin:discord@' +```dotenv +MOSAIC_AGENT_NAME= +MOSAIC_AGENT_CLASS= +MOSAIC_AGENT_RUNTIME= +MOSAIC_AGENT_MODEL= +MOSAIC_AGENT_REASONING= +MOSAIC_AGENT_TOOL_POLICY= +MOSAIC_AGENT_WORKDIR= +MOSAIC_TMUX_SOCKET= ``` -When `MOSAIC_AGENT_COMMAND` is set, `start-agent-session.sh`'s `if [ -z "$MOSAIC_AGENT_COMMAND" ]` -guard (line ~41) is false, so the line-44 default — **including its hardcoded `yolo`** — is skipped -entirely. The override fully controls the runtime and flags. Routing through `mosaic yolo claude` -(rather than a raw `claude` invocation) is what gives the orchestrator the same full -`composeContract` seeding + Fleet-Comms cheat-sheet as every worker, with `--channels` and any -other flags passed straight through to the `claude` binary. +The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add +rejects another runtime before it writes the roster or modifies generated, local, or quarantine state. +The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket; +it has no generated-launch adapter and cannot be added through this path. -## Launch gotchas +.env.local is optional and may contain only non-secret machine data: -1. **Flag conflict.** `mosaic yolo claude` already injects `--dangerously-skip-permissions`. Do - **not** also pass `--permission-mode bypassPermissions` — the `claude` binary would receive both. - Use `mosaic yolo claude …` alone (yolo covers the unattended posture), **or** non-yolo - `mosaic claude --permission-mode bypassPermissions …`. Never mix the two. -2. **`MOSAIC_AGENT_NAME` must reach the pane.** The launcher bakes it from the instance name, and - `composeContract` gates the Fleet-Comms block on it (`launch.ts`, in `composeContract`) — **and** - the role must be a member of `roster.yaml`, or the block resolves empty. -3. **`launchRuntime` guards.** `mosaic yolo claude` runs `checkSoul` / `checkRuntime` / - `checkSequentialThinking`. The host needs `SOUL.md` and the sequential-thinking MCP, or the - launch aborts (a raw `claude` invocation skipped these checks). Dry-run the composed command in a - throwaway tmux session before swapping a live launcher. +- `MOSAIC_RUNTIME_BIN` +- `MOSAIC_HEARTBEAT_RUN_DIR` +- `MOSAIC_HEARTBEAT_INTERVAL` +- `MOSAIC_CLAUDE_JSON` +- `CLAUDE_CONFIG_DIR` -## Why per-agent `.env` survives upgrades (#632) +Paths must be safe absolute paths and the heartbeat interval must be a positive integer. Projection, +local, and quarantine files must be private regular files; the managed directories must be real, +private, non-symlink paths. Violations fail closed before tmux interaction. -`install.sh` `PRESERVE_PATHS` includes `fleet/*.yaml`, `fleet/agents`, and `fleet/run`, so -`mosaic update`'s framework re-seed **preserves** your roster and per-agent `.env` overrides -(glob-aware `cp` fallback; matching TS parity in `file-adapter.ts`). Before #632, an auto re-seed -could wipe them — which is exactly why PATH A's `.env` override is safe to rely on now. +## Legacy input and diagnostics -## Inspecting the comms wiring +A legacy .env is input only during projection generation. Roster-owned keys are regenerated; +valid allowed local data can move to `.env.local`; invalid legacy input is privately retained at +.env.quarantine. Neither legacy nor quarantine files are launch authority. -- `mosaic fleet comms-block ` prints the Fleet-Comms cheat-sheet a given role receives at - launch — its `[host:session]` identity, the exact `agent-send.sh` command for each peer, and the - FLIP / `--verify` conventions. `--host ` previews a cross-host view. An unknown role or missing - roster **fails loud** (stderr + non-zero exit), so a typo is never a silent no-op. -- Versus `mosaic compose-contract `: that emits the **whole** system prompt and reads the - role from `MOSAIC_AGENT_NAME` (a full-prompt smoke test). `comms-block` is the targeted, - explicit-arg, comms-only view — e.g. `mosaic fleet comms-block coder0-0` to preview a peer. +Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command +text, credentials, or other values. -## North Star / future direction +## Launch and stop behavior -**Vision:** a webUI lets the user edit each agent's launch config — switch **harness** -(claude / pi / codex / opencode), toggle **yolo**, pick a **model**, set a **command/channels** -override — with no terminal. +The launcher obtains the agent's socket only from the validated generated projection. It creates or +checks the exact = tmux target; it never uses an ambient socket or fuzzy session match. +The same strict parser runs before exact-stop behavior. A fresh native Pi heartbeat remains authoritative; +the shell sidecar only provides fallback state when the native marker is stale or absent. -**Continuity — this is not a new launch path.** It is a data-model + UI-binding layer over the -existing roster-driven launcher. Field-by-field status today: +mosaic agent comms-block can inspect that exact roster member's resolved Fleet-Comms +block. It is a read-only inspection tool and fails loudly for an unknown exact member or missing roster. +On Linux, the installed roster, TOOLS contract, and executable helper are opened through a held +descriptor chain rooted at `/`; every managed path component uses no-follow traversal, and content plus +execute validation stay bound to the same opened file. Systems without Linux `/proc/self/fd` support +fail closed rather than falling back to pathname revalidation. -| Launch-config field | Roster-native today? | Mechanism / gap | -| ------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **harness** (`runtime`) | ✅ end-to-end | `roster.runtime` → `generateAgentEnv` emits `MOSAIC_AGENT_RUNTIME` → launcher line 44. UI just writes the field. | -| **model** (`model_hint`) | ✅ end-to-end | `roster.model_hint` → `MOSAIC_AGENT_MODEL` → launcher line 44 `--model`. UI just writes the field. | -| **yolo** | ❌ new | Launcher line 44 **hardcodes** `mosaic yolo`. A non-yolo toggle needs a roster `yolo` field → emit `MOSAIC_AGENT_YOLO` → make line 44 conditional. | -| **command / channels** | ❌ new | `MOSAIC_AGENT_COMMAND` is **consumed** (launcher line ~12) but `generateAgentEnv` does not emit it. Needs a roster `command`/`channels` field → emitted. | +## Current M2 boundary -**The arc:** - -- **A** — `.env` `MOSAIC_AGENT_COMMAND` hatch: manual, ships now, kept safe across upgrades by #632. -- **B** — roster-native launch-config: harness + model are already there; add the **yolo** toggle - (line-44 conditional) and **command/channels** emission to complete the data model. -- **webUI** — binds dropdowns/toggles directly to those four roster fields. - -PATH A's `.env` override is the **manual form** of exactly what PATH B makes roster-native and the -webUI edits — one continuous arc, not three separate features. PATH B is tracked as #636. +FCM-M2-001 supplies generated/local parsing, validation, projection, quarantine, and launch-boundary +evidence only. It does not authorize roster CRUD expansion, reconciliation, lifecycle changes, remote +or connector mutation, site canaries, or migration. M3 must establish the local reconcile/lifecycle +path; M4 separately provides migration preview, canary, and rollback gates. diff --git a/docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md b/docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md new file mode 100644 index 00000000..eff20b42 --- /dev/null +++ b/docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md @@ -0,0 +1,57 @@ +# Fleet Configuration Management — Legacy Example, Profile, and Service Disposition Inventory + +**Issue:** #758 · **Baseline:** `origin/main` `49e8a541` · **Status:** M0 inventory; no source +examples or profiles are changed by this document. + +The v2 compiler may not silently accept an unresolved class. Before M1 exits, every shipped file +below must be either migrated and executable, retained as an explicitly versioned v1 fixture, or +retired with a replacement/deprecation note. Class resolution must use the existing +profile/persona/provision baseline-plus-`roles.local` resolver; this inventory does not create a +parallel resolver. The current executable implementation and per-artifact outcomes are recorded in +[the disposition evidence](./migration/example-profile-disposition.md). + +## Examples + +| Shipped file | Current class evidence | M0 disposition decision | Required M1/M4 evidence | +| ---------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `framework/fleet/examples/coding.yaml` | `orchestrator`, `enhancer`, `implementer`, `reviewer` | Migrate: implementer → code, reviewer → review; retain orchestration/enhancer intent | v2 fixture validates; role aliases and authority matrix tested | +| `framework/fleet/examples/general.yaml` | `orchestrator`, `enhancer`, `worker` | Migrate only after operator chooses a concrete canonical role for `worker`; no implicit conversion | Explicit replacement class, or versioned v1 fixture/retirement note | +| `framework/fleet/examples/hybrid.yaml` | `orchestrator`, `enhancer`, `implementer`, `researcher`, `reviewer` | Migrate aliases; resolve `researcher` through existing role resolver or retain/version | Shared resolver validation; no ad-hoc class scanner | +| `framework/fleet/examples/local-canary.yaml` | `orchestrator`, `implementer`, `reviewer` | Migrate aliases; preserve its local-tmux canary purpose | v2 fixture validates and preserves safe stopped/running behavior | +| `framework/fleet/examples/minimal.yaml` | `canary` | Retire or version as v1 unless an existing canonical role contract is selected deliberately | Replacement link/deprecation note or CI-valid v1 fixture | +| `framework/fleet/examples/operator-interaction.yaml` | `operator-interaction` | Migrate alias to `interaction`; preserve instance/display name as configuration, not schema identity | v2 interaction fixture validates; no Tess literal is required | +| `framework/fleet/examples/research.yaml` | `orchestrator`, `enhancer`, `researcher`, `analyst` | Resolve `researcher`/`analyst` through baseline + `roles.local`, or version/retire | Resolver evidence and explicit disposition for each unresolved class | + +## Profiles + +| Shipped file | Current class evidence | M0 disposition decision | Required M1/M4 evidence | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | +| `framework/fleet/profiles/business.yaml` | `ceo`, `coo`, `cfo`, `product-manager`, `marketing-lead`, `sales-lead`, `operations-manager`, `customer-success-manager`, `code`, `review` | Retain only if every class resolves through the existing role library/`roles.local`; otherwise version/retire rather than weakening validation | Shared resolver CI result for every class; documented role source or replacement | +| `framework/fleet/profiles/marketing.yaml` | `marketing-lead`, `content-strategist`, `copywriter`, `seo-specialist`, `social-media-manager`, `brand-strategist`, `growth-marketer`, `ux-designer` | Same resolver-or-version/retire rule | Per-class resolver CI result and replacement/deprecation record if unresolved | +| `framework/fleet/profiles/personal-assistant.yaml` | `personal-assistant`, `executive-assistant`, `scheduler`, `inbox-manager`, `researcher` | Same resolver-or-version/retire rule | Per-class resolver CI result; do not infer `interaction` equivalence | +| `framework/fleet/profiles/research.yaml` | `lead-researcher`, `researcher`, `data-analyst`, `data-scientist`, `market-analyst`, `documentation`, `review` | Same resolver-or-version/retire rule | Per-class resolver CI result and explicit compatibility posture | +| `framework/fleet/profiles/software-delivery.yaml` | `orchestrator`, `board`, `planner`, `decomposition`, `code`, `review`, `security-review`, `site-tester`, `documentation`, `merge-gate`, `rebase`, `operator`, `session-review`, `enhancer` | Retain as the governance reference; add `validator`, `team-leader`, and `interaction` only through approved role/profile work, not silent substitution | CI validates all current classes; separate fixture proves required M1 authority seats | + +## Service presets + +| Shipped file | Current policy evidence | M0 disposition decision | Required M1/M4 evidence | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `framework/fleet/services/operator-interaction.yaml` | Generic policy only: runtime: pi, model: openai/gpt-5.6-sol, reasoning: high, tool_policy: operator-interaction; provisioning supplies the agent name as data | Retain as a generic service policy, not a Tess identity. Migrate tool_policy: operator-interaction only through the approved interaction tool-policy alias/semantic resolver; do not infer a class or machine name from this file. | Service-policy fixture validates runtime/model/reasoning and alias behavior; generic provisioning proves a configured interaction instance is supplied without a hardcoded Tess name. | + +## Required disposition controls + +1. **No silent aliasing:** only implementer → code, reviewer → review, and + operator-interaction → interaction are approved deterministic aliases in this M0 baseline. + `worker`, `analyst`, `canary`, and domain-specific classes require resolver evidence or an + explicit version/retirement decision. +2. **No identity hardcoding:** Tess and Ultron are optional instance/display names. An example/profile + may demonstrate the capability but must not make a product name a required class or machine ID. +3. **No lifecycle inference from an example:** examples describe desired configuration only; migration + of an installed v1 roster separately preserves observed stopped/running state. +4. **No secret or command migration:** examples/profiles must not introduce credential values or + `MOSAIC_AGENT_COMMAND`; those legacy keys are M2 quarantine inputs, never v2 authoring fields. +5. **Service presets are included:** service policies are inventoried alongside examples/profiles. + They may express launch/tool policy, but do not create a class, a canonical agent identity, or a + second validation path. +6. **Evidence is executable:** M1/M4 CI must enumerate these exact files, validate retained/migrated + inputs through the shared resolver, and fail if a file lacks its documented disposition. diff --git a/docs/fleet/NORTH_STAR.md b/docs/fleet/NORTH_STAR.md index 193559ec..1c92b341 100644 --- a/docs/fleet/NORTH_STAR.md +++ b/docs/fleet/NORTH_STAR.md @@ -33,7 +33,7 @@ The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's - **AC-NS-4** — TTL is enforced on claims; token caps remain advisory until a real meter exists. - **AC-NS-5** — Flipping fleet/run/PAUSED halts dispatch and merges within one tick. - **AC-NS-6** — A user can declare a system type and the fleet provisions the matching persona roster + topology from the baseline library, with no code change. -- **AC-NS-7** — A user-customized persona (edited or added via the orchestrator) survives `mosaic update`: baseline reseed never clobbers user overrides. +- **AC-NS-7** — A user-customized persona (edited or added via the orchestrator) survives mosaic update: baseline reseed never clobbers user overrides. ## Workstreams diff --git a/docs/fleet/NORTH_STAR.yaml b/docs/fleet/NORTH_STAR.yaml index 3e10b203..79af87cd 100644 --- a/docs/fleet/NORTH_STAR.yaml +++ b/docs/fleet/NORTH_STAR.yaml @@ -97,7 +97,7 @@ success_criteria: - id: AC-NS-7 text: >- A user-customized persona (edited or added via the orchestrator) survives - `mosaic update`: baseline reseed never clobbers user overrides. + mosaic update: baseline reseed never clobbers user overrides. workstreams: - id: A diff --git a/docs/fleet/PRD-fleet-suite.md b/docs/fleet/PRD-fleet-suite.md index bba8ee9a..1cac0589 100644 --- a/docs/fleet/PRD-fleet-suite.md +++ b/docs/fleet/PRD-fleet-suite.md @@ -8,7 +8,7 @@ ## Mission Turn the proven fleet primitives into a **user-installable, AI-free-configurable fleet product**: -a user runs `mosaic fleet init`, answers a few questions (general / coding / research / hybrid), +a user runs mosaic fleet init, answers a few questions (general / coding / research / hybrid), gets a recommended set of agents plus one always-on orchestrator wired for chat-ops, and can operate, mutate, re-create, and observe the fleet — over tmux today and Matrix tomorrow — from CLI/TUI and (designed-for) the webUI. @@ -21,25 +21,25 @@ functional, we use the fleet itself to continue the work. ### A. Configure-without-AI CLI -| ID | Requirement | -| --- | ------------------------------------------------------------------------------------------------------------- | -| R1 | `mosaic fleet` command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). | -| R2 | `mosaic fleet init` is an interactive, **AI-free** CLI wizard. | -| R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). | -| R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). | -| R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. | -| R10 | A set of **recommended configurations (presets)** ships for easy duplication. | -| R8 | User can **re-create** the fleet when config needs change (idempotent re-init / reconfigure). | -| R17 | Fleet controls are **simple and intuitive**. | +| ID | Requirement | +| --- | ----------------------------------------------------------------------------------------------------------- | +| R1 | mosaic fleet command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). | +| R2 | mosaic fleet init is an interactive, **AI-free** CLI wizard. | +| R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). | +| R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). | +| R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. | +| R10 | A set of **recommended configurations (presets)** ships for easy duplication. | +| R8 | User can **re-create** the fleet when config needs change (idempotent re-init / reconfigure). | +| R17 | Fleet controls are **simple and intuitive**. | ### B. Comms & orchestrator chat-ops -| ID | Requirement | -| --- | --------------------------------------------------------------------------------------------------------------------------------- | -| R6 | Init can wire the orchestrator to a chat connector — **Telegram / Discord / Matrix / Slack** — for command + comms. | -| R7 | Designed with the end-goal of **Matrix comms on a locally-controlled server**. | -| R16 | Fleet supports **tmux AND Matrix** comms, **user-configurable** at init or any time. Not all users want Matrix. | -| R19 | **"Mos" orchestrator on Discord** (`chan 1517622518662434996` / `srv 1112631390438166618`) on `w-jarvis` — the first live target. | +| ID | Requirement | +| --- | ----------------------------------------------------------------------------------------------------------------------------- | +| R6 | Init can wire the orchestrator to a chat connector — **Telegram / Discord / Matrix / Slack** — for command + comms. | +| R7 | Designed with the end-goal of **Matrix comms on a locally-controlled server**. | +| R16 | Fleet supports **tmux AND Matrix** comms, **user-configurable** at init or any time. Not all users want Matrix. | +| R19 | **"Mos" orchestrator on Discord** (chan 1517622518662434996 / srv 1112631390438166618) on `w-jarvis` — the first live target. | ### C. Runtime, health, lifecycle @@ -64,46 +64,46 @@ functional, we use the fleet itself to continue the work. - **Orchestrator agent:** always present; carries the chat connector config (connector type + target IDs) so it can be commanded over chat. tmux is the substrate; the connector bridges chat ↔ the orchestrator session. - **Comms layers (R16):** (1) **tmux** inter-agent (`agent-send`, proven) — default, always available. (2) **chat connector** for human↔orchestrator (Discord now; Matrix the strategic target). (3) **Matrix** as the locally-controlled cross-agent bus (future). Connector is pluggable + reconfigurable. - **Heartbeat (R15):** runtime-agnostic launcher sidecar already covers pi/claude/codex (#584). Refine per-runtime (native HB) with the **custom Pi harness** (R14) + a Claude path. -- **Updates (R13):** `mosaic update` (CLI) + a fleet-aware harness-update step that refreshes pi/claude/codex and re-launches agents safely (drain → update → relaunch via the durable launcher). -- **webUI (R18):** the fleet exposes machine-readable state (`fleet ps --json` already carries tenant/host/heartbeat/managed) + control verbs (start/stop/watch/send); webUI consumes these (control plane rides federation per north star). Ensure a stable JSON contract + a terminate/attach(butt-in) path. +- **Updates (R13):** mosaic update (CLI) + a fleet-aware harness-update step that refreshes pi/claude/codex and re-launches agents safely (drain → update → relaunch via the durable launcher). +- **webUI (R18):** the fleet exposes machine-readable state (fleet ps --json already carries tenant/host/heartbeat/managed) + control verbs (start/stop/watch/send); webUI consumes these (control plane rides federation per north star). Ensure a stable JSON contract + a terminate/attach(butt-in) path. ## Phases (incremental, each shippable) -| Phase | Deliverable | Notes | -| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | -| **F1 Presets + init wizard** | preset rosters (general/coding/research/hybrid) + always-orchestrator + AI-free `fleet init` selecting a preset; re-init idempotent | R1–R5, R8, R10, R17 | -| **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord `1517…`/`1112…`** on w-jarvis | R6, R19, partial R16 | -| **F3 Heartbeat + harness** | HB confirmed for claude + pi/gpt; **custom Pi harness** (tool usage, native HB, model self-report); graceful harness updates | R13, R14, R15 | -| **F4 Matrix + comms toggle** | Matrix connector (local server) + user toggle tmux/Matrix at init/anytime | R7, R16 | -| **F5 Orchestrator-mutable fleet** | orchestrator can add/remove agents at runtime | R9 | -| **F6 webUI hooks** | stable JSON contract + terminate/attach surface for webUI view/monitor/terminate/butt-in | R18 | -| **F7 Test + docs** | install+test on w-jarvis AND dragon-lin; user docs (install/configure/use) | R11, R12 (runs alongside every phase) | +| Phase | Deliverable | Notes | +| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | +| **F1 Presets + init wizard** | preset rosters (general/coding/research/hybrid) + always-orchestrator + AI-free fleet init selecting a preset; re-init idempotent | R1–R5, R8, R10, R17 | +| **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord 1517…/1112…** on w-jarvis | R6, R19, partial R16 | +| **F3 Heartbeat + harness** | HB confirmed for claude + pi/gpt; **custom Pi harness** (tool usage, native HB, model self-report); graceful harness updates | R13, R14, R15 | +| **F4 Matrix + comms toggle** | Matrix connector (local server) + user toggle tmux/Matrix at init/anytime | R7, R16 | +| **F5 Orchestrator-mutable fleet** | orchestrator can add/remove agents at runtime | R9 | +| **F6 webUI hooks** | stable JSON contract + terminate/attach surface for webUI view/monitor/terminate/butt-in | R18 | +| **F7 Test + docs** | install+test on w-jarvis AND dragon-lin; user docs (install/configure/use) | R11, R12 (runs alongside every phase) | ## Work division (proposed — confirm with dragon-lin) - **Jarvis @ w-jarvis (Lead):** F1 presets+wizard, F2 connector+Mos-on-Discord, F5 mutability, F6 webUI hooks; merge authority + dual-engine reviews; co-testing on w-jarvis. -- **coder @ dragon-lin:** F3 custom Pi harness + harness-update flow (pi/codex-savvy); plus its in-flight constitution P4–P6 (P4 installer rework underpins `fleet init`/updates — coordinate the install path). Co-testing on dragon-lin (R11). +- **coder @ dragon-lin:** F3 custom Pi harness + harness-update flow (pi/codex-savvy); plus its in-flight constitution P4–P6 (P4 installer rework underpins fleet init/updates — coordinate the install path). Co-testing on dragon-lin (R11). - **Shared:** F4 Matrix (whoever has bandwidth); F7 testing/docs continuous. ## Immediate target: Mos on Discord (F2 first slice) -The discord plugin is available (`~/.claude.json`). Path: configure the **orchestrator** as a durable +The discord plugin is available (~/.claude.json). Path: configure the **orchestrator** as a durable fleet session running Claude Code with the discord plugin bridged to channel `1517622518662434996` (server `1112631390438166618`) on w-jarvis, with the existing Discord Bridge Protocol (ack within ~3s, reply via `mcp__discord__reply`, no `AskUserQuestion`). Heartbeat via the launcher sidecar. ## Success criteria -- A non-AI user can `mosaic fleet init`, pick a type, and get a working fleet + orchestrator. -- **Mos answers in Discord `1517…`** on w-jarvis. -- Fleet runs + is observable (`fleet ps`) on **both** w-jarvis and dragon-lin. +- A non-AI user can mosaic fleet init, pick a type, and get a working fleet + orchestrator. +- **Mos answers in Discord 1517…** on w-jarvis. +- Fleet runs + is observable (fleet ps) on **both** w-jarvis and dragon-lin. - Harness updates handled gracefully; HB healthy for claude + pi/gpt agents. - Docs let a new operator install/configure/use the fleet. - Re-init + orchestrator mutation work. ## Assumptions (veto-able) -- `ASSUMPTION:` presets ship as example rosters under the framework (`fleet/examples/*.yaml`), selected by `init`. +- `ASSUMPTION:` presets ship as example rosters under the framework (fleet/examples/\*.yaml), selected by `init`. - `ASSUMPTION:` chat connectors are pluggable; Discord first (target exists), Matrix is the strategic default later. - `ASSUMPTION:` "Mos" = a Claude Code orchestrator session with the discord plugin (reuses the documented Discord Bridge Protocol). - `ASSUMPTION:` per north star, runtimes default to Codex/pi-on-Codex for workers; the orchestrator "Mos" runs Claude Code (in Claude Code, which is allowed). diff --git a/docs/fleet/PRD.md b/docs/fleet/PRD.md index 6bd52e9f..c33cc4d8 100644 --- a/docs/fleet/PRD.md +++ b/docs/fleet/PRD.md @@ -10,8 +10,8 @@ The durable tmux fleet runs on the isolated `mosaic-fleet` socket. That isolation (which protects the operator's default tmux) makes the fleet **invisible** to default tooling, and truth is split across three planes no single command joins — systemd -(`systemctl --user`), tmux (`-L mosaic-fleet`), and the process tree (`pstree`). -`agent tail` (`capture-pane`) returns **blank for full-screen TUIs**, and `agent send` +(systemctl --user), tmux (-L mosaic-fleet), and the process tree (`pstree`). +agent tail (`capture-pane`) returns **blank for full-screen TUIs**, and agent send confirms only keystroke injection, not acceptance. Net: the operator has near-zero observability and no safe way to watch a session. @@ -32,22 +32,22 @@ observability and no safe way to watch a session. ## Functional requirements -| ID | Requirement | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| FR-1 | `mosaic fleet ps [--json]` prints one row per roster agent joining: name · tenant · host · runtime · systemd(active/enabled) · pane(alive/dead) · pid · idle · **last-heartbeat age** · **drift** flag (roster runtime ≠ actual pane command) · **boot-enable** warning (active but `UnitFileState=disabled`). | -| FR-2 | **Heartbeat protocol v1** (see below); `dogfood-agent.py` implements the responder. `fleet ps` issues probes (or reads last-seen) and reports health per FR-1. | -| FR-3 | `mosaic agent watch ` opens a **read-only** view of the pane (grouped session or `tmux attach -r`) that cannot send keystrokes and does not shrink the agent's window. | -| FR-4 | `mosaic agent attach ` remains the **explicit** interactive-takeover path (separate verb, documented as the only one that can type). | -| FR-5 | `mosaic agent send --verify` confirms the message was **accepted** (not left as an unsubmitted draft) and returns non-zero if delivery cannot be verified. | -| FR-6 | All structured output (`--json`) includes `tenant_id` and `host` fields. | +| ID | Requirement | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| FR-1 | mosaic fleet ps [--json] prints one row per roster agent joining: name · tenant · host · runtime · systemd(active/enabled) · pane(alive/dead) · pid · idle · **last-heartbeat age** · **drift** flag (roster runtime ≠ actual pane command) · **boot-enable** warning (active but `UnitFileState=disabled`). | +| FR-2 | **Heartbeat protocol v1** (see below); `dogfood-agent.py` implements the responder. fleet ps issues probes (or reads last-seen) and reports health per FR-1. | +| FR-3 | mosaic agent watch opens a **read-only** view of the pane (grouped session or tmux attach -r) that cannot send keystrokes and does not shrink the agent's window. | +| FR-4 | mosaic agent attach remains the **explicit** interactive-takeover path (separate verb, documented as the only one that can type). | +| FR-5 | mosaic agent send --verify confirms the message was **accepted** (not left as an unsubmitted draft) and returns non-zero if delivery cannot be verified. | +| FR-6 | All structured output (`--json`) includes `tenant_id` and `host` fields. | ## Heartbeat protocol v1 -- **Probe:** operator/`fleet ps` writes a sentinel line to the agent's input or a - well-known per-agent heartbeat file path `~/.config/mosaic/fleet/run/.hb`. -- **Response:** the runtime updates `.hb` with `ts= pid= status=` +- **Probe:** operator/fleet ps writes a sentinel line to the agent's input or a + well-known per-agent heartbeat file path ~/.config/mosaic/fleet/run/.hb. +- **Response:** the runtime updates .hb with ts= pid= status= on a fixed interval (default 15s) and on demand when probed. -- **Health rule:** `healthy` if `now - ts <= 3 × interval`; else `stale`; missing file = `unknown`. +- **Health rule:** `healthy` if now - ts <= 3 × interval; else `stale`; missing file = `unknown`. - **Contract:** every runtime (dogfood stub now; claude/codex/pi/opencode in Phase 3) MUST emit the heartbeat. The protocol is file-based so it works for headless stubs and full-screen TUIs alike (no `capture-pane` dependency). @@ -56,15 +56,15 @@ observability and no safe way to watch a session. ## Acceptance criteria -- `mosaic fleet ps` shows all 5 live sessions on `mosaic-fleet` with correct +- mosaic fleet ps shows all 5 live sessions on `mosaic-fleet` with correct pane/pid/idle and flags the dogfood **drift** (`canary-pi` runtime=pi but pane runs `dogfood-agent.py`) and the **boot-enable** gap (active but disabled). - Killing one agent's pane flips its row to dead/stale within one `interval`. -- `agent watch` shows live output and provably cannot type into the pane; detaching +- agent watch shows live output and provably cannot type into the pane; detaching leaves the agent's window size unchanged. -- `agent send --verify` returns success on an accepting pane and non-zero on a wedged/draft pane. -- Quality gates green: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, plus - `pnpm --filter @mosaicstack/mosaic test`. +- agent send --verify returns success on an accepting pane and non-zero on a wedged/draft pane. +- Quality gates green: pnpm typecheck, pnpm lint, pnpm format:check, plus + pnpm --filter @mosaicstack/mosaic test. - Independent review passed; dogfood evidence captured against the live fleet. ## Test plan @@ -72,18 +72,18 @@ observability and no safe way to watch a session. - Unit/CLI specs in `packages/mosaic/src/commands/fleet.spec.ts` (and a new `fleet-ps`/`watch`/`send-verify` spec) using the injected `CommandRunner` to assert exact tmux/systemd command construction and JSON shape (tenant+host present). -- Situational: run against the live `mosaic-fleet` fleet; capture `fleet ps` output, - a kill-and-detect cycle, a read-only `watch`, and a `send --verify` pass/fail pair. +- Situational: run against the live `mosaic-fleet` fleet; capture fleet ps output, + a kill-and-detect cycle, a read-only `watch`, and a send --verify pass/fail pair. ## Known limitations -- **Verify heuristic is best-effort:** `agent send --verify` uses a `>` -prefix draft +- **Verify heuristic is best-effort:** agent send --verify uses a > -prefix draft heuristic that is specific to pi/claude TUIs. Draft detection for codex and opencode TUIs is best-effort only; those runtimes may not use the same input-line indicator. - **Pane-change check is the best Phase-2 signal; verify now polls up to a bounded - timeout:** `agent send --verify` captures a BEFORE snapshot, sends the message, then + timeout:** agent send --verify captures a BEFORE snapshot, sends the message, then polls `capture-pane` every ~400 ms up to a configurable total timeout (default ~6 s, - controlled by `--verify-timeout `). On each poll it runs classifySendResult: if + controlled by --verify-timeout ). On each poll it runs classifySendResult: if the pane shows 'accepted' or 'draft' the loop exits immediately; while the result is 'unverifiable' (no pane change yet) it keeps polling. After the timeout with no definitive result, it fails closed: exit 1 with "no pane change after send". This @@ -92,15 +92,15 @@ observability and no safe way to watch a session. requires a runtime acknowledgement (Phase-3 heartbeat-ack); the bounded pane-change poll is the best signal available against an opaque TUI for Phase-2. - **Blank AFTER capture fails closed:** Full-screen TUIs (claude, codex, opencode, pi) - render blank for `tmux capture-pane`. When the AFTER snapshot is empty, `send --verify` + render blank for tmux capture-pane. When the AFTER snapshot is empty, send --verify returns non-zero with an "unverifiable" message rather than silently succeeding. This is an intentional fail-closed design (FR-5). -- **`agent watch` uses a grouped viewer session:** `tmux attach -r` directly against the - agent session lets the viewer terminal shrink the agent's window. `agent watch` instead - creates a throwaway grouped session (`tmux new-session -d -t '=' -s -'-watch-'`), attaches read-only to that session, and kills it on detach. +- **agent watch uses a grouped viewer session:** tmux attach -r directly against the + agent session lets the viewer terminal shrink the agent's window. agent watch instead + creates a throwaway grouped session (tmux new-session -d -t '=' -s + '-watch-'), attaches read-only to that session, and kills it on detach. The grouped session shares the agent's windows but has independent sizing, so the - agent's window is never affected. `tmux attach` is still interactive and requires + agent's window is never affected. tmux attach is still interactive and requires inherited stdio; the `interactiveRunner` handles TTY passthrough. ## Surfaces & parity (MVP-X1) diff --git a/docs/fleet/README.md b/docs/fleet/README.md new file mode 100644 index 00000000..690e3814 --- /dev/null +++ b/docs/fleet/README.md @@ -0,0 +1,63 @@ +# 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](../PRD.md#fleet-declarative-configuration-management-workstream-fcm-758), not the older fleet-suite or observability planning pages. + +## Authority boundary + +/fleet/roster.yaml is the sole writable desired-state authority for local fleet membership, launch policy, and persisted lifecycle. Generated environment files, systemd enablement, tmux sessions, heartbeat files, and status output are derived or observed. Rebuild projections from the roster; never edit them as desired state. + +This control plane is local tmux/systemd only. Remote/SSH entries and connectors are inventory, not reconciliation targets. Arbitrary commands, channels, secret references, gateway catalog convergence, and UI configuration storage are outside this workstream. `mos-comms` is temporary transport glue, not permanent fleet architecture. + +## Choose the right workflow + +1. **Need to inspect intent?** Read the roster and use mosaic fleet get; see [desired versus observed state](concepts/desired-vs-observed-state.md). +2. **Need to inspect reality?** Use `status` or `doctor`; use `verify` for a strict non-zero drift/ownership gate. These commands do not repair anything. +3. **Need to change membership or persisted policy?** Use generation-guarded `plan`, `create`, `update`, or `delete`; see [safe CRUD](how-to/create-update-delete-agent.md). +4. **Need a one-time runtime action?** Use `start`, `stop`, or `restart`. These do not change persisted desired state. +5. **Need convergence?** Review apply --dry-run, resolve blockers, then use `apply` with the same current generation; see [reconcile and recover](operations/reconcile-and-recover.md). +6. **Need v1 migration evidence?** Use preview only. Cutover, canary, and rollback remain held for FCM-M4-002. +7. **Need the gateway-backed agent catalog?** That is the separate mosaic agent surface, not local fleet desired state. + +## Concepts + +- [Desired versus observed state](concepts/desired-vs-observed-state.md) +- [Identity, class, runtime, provider, and model](concepts/identity-class-runtime.md) +- [Role authority and leases](concepts/role-authority-and-leases.md) +- [Generated environment launch chain](concepts/generated-env-launch-chain.md) + +## Operator how-to + +- [Create, inspect, update, and delete](how-to/create-update-delete-agent.md) +- [Start, stop, restart, and reconcile](how-to/start-stop-restart.md) +- [Configure an interaction instance](how-to/configure-tess-interaction.md) +- [Configure a validator instance](how-to/configure-ultron-validator.md) +- [Customize roles](how-to/customize-roles.md) + +## Operations and recovery + +- [Reconcile and recover](operations/reconcile-and-recover.md) +- [Environment quarantine](operations/env-quarantine.md) +- [Systemd/tmux troubleshooting](operations/systemd-tmux-troubleshooting.md) +- [Backup and restore boundary](operations/backup-restore.md) +- [Upgrade and asset-drift hold](operations/upgrade-assets.md) + +## Reference and migration + +- [Roster v2 fields](reference/roster-v2-fields.md) · [executable JSON Schema](reference/roster-v2.schema.json) · [validated example](examples/roster-v2.yaml) +- [CLI and exit codes](reference/cli.md) +- [Role classes](reference/role-classes.md) +- [Lifecycle transitions](reference/lifecycle-transitions.md) +- [Status and drift](reference/status-and-drift.md) +- [Generated environment boundary](reference/generated-env-boundary.md) +- [v1-to-v2 preview](migration/v1-to-v2.md) +- [Example/profile dispositions](migration/example-profile-disposition.md) +- [Legacy class aliases](migration/legacy-class-aliases.md) + +## Acceptance evidence and holds + +- [M0/M5 IA checklist](FLEET-CONFIG-DOCS-IA-CHECKLIST.md) +- [Legacy example/profile inventory](LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) +- [M5 closure evidence](../reports/documentation/758-fleet-config-ia-closure.md) +- [Approved-existing deferrals and live-action holds](../reports/deferred/758-fleet-config-deferrals.md) + +The canonical publishing source remains this repository. This card does not publish externally, run a migration, operate a live fleet, or close parent issue #758. diff --git a/docs/fleet/TASKS.md b/docs/fleet/TASKS.md index 7cd9447c..16c852c9 100644 --- a/docs/fleet/TASKS.md +++ b/docs/fleet/TASKS.md @@ -7,21 +7,21 @@ > Mission: `mvp-20260312` · PRD: [docs/fleet/PRD.md](./PRD.md) · North star: [docs/fleet/north-star.md](./north-star.md) > Status: `not-started` | `in-progress` | `done` | `blocked` | `failed` -| id | status | description | depends_on | agent | pr | notes | -| ------------- | ----------- | ------------------------------------------------------------------------------------------------------------------ | --------------------- | ----------- | --- | --------------------------------------------------------------------------------------------------------------------------- | -| FLEET-OBS-000 | done | Plan: north-star + Phase-2 PRD + workstream scaffolding | — | lead | — | persisted 2026-06-20 on `feat/fleet-observability` | -| FLEET-OBS-001 | done | Heartbeat protocol v1 spec finalized in PRD + framework doc | FLEET-OBS-000 | lead | — | file-based `~/.config/mosaic/fleet/run/.hb`; spec in PRD | -| FLEET-OBS-002 | in-progress | Implement heartbeat responder in `dogfood-agent.py` | FLEET-OBS-001 | fleet-coder | — | dispatched to ad-hoc `mosaic yolo` fleet agent (dogfood) | -| FLEET-OBS-003 | done | `mosaic fleet ps` — join systemd+tmux+proc+idle+heartbeat; tenant+host tagged; drift + boot-enable flags; `--json` | FLEET-OBS-001 | worker | — | commit ab47831; LIVE-verified on mosaic-fleet; caught canary-pi DRIFT + BOOT-ENABLE. Polish: idleSeconds parse returns null | -| FLEET-OBS-004 | done | `mosaic agent watch ` — read-only join (no resize, no keystrokes) | FLEET-OBS-000 | worker | — | `attach -r`; verb wired | -| FLEET-OBS-005 | done | `mosaic agent send --verify` — delivery/acceptance receipt | FLEET-OBS-000 | worker | — | --verify flag; draft-heuristic verify | -| FLEET-OBS-006 | done | CLI specs for ps/watch/send-verify (tenant+host shape, command construction) | FLEET-OBS-003,004,005 | worker | — | 62 tests green (31 new); re-verified by lead | -| FLEET-OBS-007 | not-started | Framework doc: fleet observability guide + verbs | FLEET-OBS-003,004,005 | lead | — | `docs/guides/` or `framework/tools/.../README` | -| FLEET-OBS-008 | not-started | Independent review + dogfood verification on live fleet | FLEET-OBS-002..007 | reviewer | — | author ≠ reviewer; capture evidence in scratchpad | -| FLEET-OBS-009 | not-started | Open PR → green CI (queue guard) → squash-merge → close `fleet-observability-1` | FLEET-OBS-008 | lead | — | trunk merge; no direct push to main | +| id | status | description | depends_on | agent | pr | notes | +| ------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | --------------------- | ----------- | --- | --------------------------------------------------------------------------------------------------------------------------- | +| FLEET-OBS-000 | done | Plan: north-star + Phase-2 PRD + workstream scaffolding | — | lead | — | persisted 2026-06-20 on `feat/fleet-observability` | +| FLEET-OBS-001 | done | Heartbeat protocol v1 spec finalized in PRD + framework doc | FLEET-OBS-000 | lead | — | file-based ~/.config/mosaic/fleet/run/.hb; spec in PRD | +| FLEET-OBS-002 | in-progress | Implement heartbeat responder in `dogfood-agent.py` | FLEET-OBS-001 | fleet-coder | — | dispatched to ad-hoc mosaic yolo fleet agent (dogfood) | +| FLEET-OBS-003 | done | mosaic fleet ps — join systemd+tmux+proc+idle+heartbeat; tenant+host tagged; drift + boot-enable flags; `--json` | FLEET-OBS-001 | worker | — | commit ab47831; LIVE-verified on mosaic-fleet; caught canary-pi DRIFT + BOOT-ENABLE. Polish: idleSeconds parse returns null | +| FLEET-OBS-004 | done | mosaic agent watch — read-only join (no resize, no keystrokes) | FLEET-OBS-000 | worker | — | attach -r; verb wired | +| FLEET-OBS-005 | done | mosaic agent send --verify — delivery/acceptance receipt | FLEET-OBS-000 | worker | — | --verify flag; draft-heuristic verify | +| FLEET-OBS-006 | done | CLI specs for ps/watch/send-verify (tenant+host shape, command construction) | FLEET-OBS-003,004,005 | worker | — | 62 tests green (31 new); re-verified by lead | +| FLEET-OBS-007 | not-started | Framework doc: fleet observability guide + verbs | FLEET-OBS-003,004,005 | lead | — | `docs/guides/` or `framework/tools/.../README` | +| FLEET-OBS-008 | not-started | Independent review + dogfood verification on live fleet | FLEET-OBS-002..007 | reviewer | — | author ≠ reviewer; capture evidence in scratchpad | +| FLEET-OBS-009 | not-started | Open PR → green CI (queue guard) → squash-merge → close `fleet-observability-1` | FLEET-OBS-008 | lead | — | trunk merge; no direct push to main | ## Proposed MVP rollup row (for the MVP orchestrator — not written by this workstream) -``` +```text-table | W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) | ``` diff --git a/docs/fleet/backlog-conventions.md b/docs/fleet/backlog-conventions.md index 02513068..874b8aa6 100644 --- a/docs/fleet/backlog-conventions.md +++ b/docs/fleet/backlog-conventions.md @@ -2,10 +2,10 @@ The **backlog** is Mosaic's native backlog-of-record for fleet work. It is built end-to-end on Mosaic's own storage layer (`@mosaicstack/db`, drizzle/Postgres) -and surfaced as `mosaic fleet backlog --json`. +and surfaced as mosaic fleet backlog --json. > **Mosaic-native, no Hermes.** This backlog REPLACES the former Hermes adapter. -> There is **no** runtime dependency on Hermes, `hermes kanban`, or `~/.hermes` +> There is **no** runtime dependency on Hermes, hermes kanban, or ~/.hermes > anywhere in this feature. Anything previously delegated to Hermes is recreated > here on Mosaic's own Postgres storage layer. @@ -14,24 +14,23 @@ and surfaced as `mosaic fleet backlog --json`. The backlog uses the existing Mosaic storage layer; there is **no** new database engine (no sqlite, no raw client). -| Condition | Tier | Data location | -| ------------------------------ | -------------------- | -------------------------------- | -| `DATABASE_URL` set | Full server Postgres | the configured database | -| `PGLITE_DATA_DIR` set (no URL) | Embedded PGlite | that directory | -| neither (default) | Embedded PGlite | `~/.config/mosaic/fleet/backlog` | +| Condition | Tier | Data location | +| ---------------------------------- | -------------------- | ---------------------------------------------------------------- | +| `DATABASE_URL` injected at runtime | Full server Postgres | the verified runtime database; it never authorizes migration/DDL | +| `PGLITE_DATA_DIR` set (no URL) | Embedded PGlite | that directory | +| neither (default) | Embedded PGlite | ~/.config/mosaic/fleet/backlog | PGlite is real Postgres semantics in-process — including the row locks the atomic claim relies on — so the **same code** runs on a laptop (embedded, single-host default) and on a full Postgres deployment. Switching tiers is config-only. -The schema (`backlog` table) is created automatically on first CLI use: -`runMigrations()` for Postgres, `runPgliteMigrations()` for embedded PGlite. +For embedded PGlite only, the local backlog routine may prepare its local schema on first use. **Current operator behavior is PGlite-only.** The PostgreSQL path is held until KBN-101 activation; no current PostgreSQL CLI route, runner, or first-use migration is available or authorized. A future activated PostgreSQL runtime may connect only after its separately certified readiness gate. ### Update safety -The embedded PGlite store lives under `~/.config/mosaic/fleet/backlog`, which is +The embedded PGlite store lives under ~/.config/mosaic/fleet/backlog, which is listed in `PRESERVE_PATHS` in `packages/mosaic/framework/install.sh`. This means -`mosaic update` (which runs the framework sync with `rsync --delete`) will **not** +mosaic update (which runs the framework sync with rsync --delete) will **not** wipe the operator's backlog — same protection as the roster, per-agent env, and heartbeat run dir. @@ -47,10 +46,10 @@ A card is one row in the `backlog` table: | `phase` | text (nullable) | Board/phase grouping (see below). | | `priority` | int (default 0) | **Higher = sooner.** Claim picks the max-priority ready card. | | `status` | enum | `ready` \| `claimed` \| `blocked` \| `done`. | -| `depends_on` | jsonb `string[]` | DAG edges — ids of cards this one depends on. | +| `depends_on` | jsonb string[] | DAG edges — ids of cards this one depends on. | | `claim_owner` | text (nullable) | Owner token of the active claim. | | `claim_ttl_seconds` | int (nullable) | TTL of the active claim. | -| `claimed_at` | timestamptz (null) | When the claim was taken. `claimed_at + ttl` = expiry. | +| `claimed_at` | timestamptz (null) | When the claim was taken. claimed_at + ttl = expiry. | | `attempts` | int (default 0) | Incremented each time the card is claimed. | | `idempotency_key` | text (unique, null) | Dedups `create`; NULLs are distinct in Postgres. | | `acceptance` | jsonb (nullable) | Acceptance criteria (array of strings or object). | @@ -66,12 +65,12 @@ would add ceremony without benefit. ### Board / phase convention `phase` is a free-form grouping string used as the board column / milestone label -(e.g. `M1`, `fleet`, `infra`). `list --phase ` filters to one board lane. +(e.g. `M1`, `fleet`, `infra`). list --phase filters to one board lane. `priority` orders cards **within** the ready pool regardless of phase. ## Status lifecycle -``` +```text-diagram create │ ▼ @@ -88,51 +87,49 @@ would add ceremony without benefit. - **blocked** — explicitly parked; never auto-claimed. - **done** — completed; satisfies dependents. -## Atomic claim (`FOR UPDATE SKIP LOCKED`) + TTL +## Atomic claim (FOR UPDATE SKIP LOCKED) + TTL `claim` is atomic. Inside a single transaction it locks candidate `ready` rows -with `SELECT ... FOR UPDATE SKIP LOCKED` (via the drizzle `sql` operator), picks +with SELECT ... FOR UPDATE SKIP LOCKED (via the drizzle `sql` operator), picks the highest-priority deps-satisfied card, and flips it to `claimed`. Because a row already locked by a concurrent claimer is **skipped**, two claimers can **never** both win the same card — the loser falls through to the next candidate or gets `null`. (Proven by the concurrency tests in `packages/db/src/backlog.spec.ts`.) - **Deps gate:** a card is only claimable when every id in `depends_on` is `done`. -- **TTL:** `claim --ttl ` (default **900s**) records `claim_ttl_seconds`. -- **reclaim:** releases claims whose `claimed_at + ttl` is in the past (expired) - back to `ready`, clearing the claim fields. `reclaim --id ` force-releases a +- **TTL:** claim --ttl (default **900s**) records `claim_ttl_seconds`. +- **reclaim:** releases claims whose claimed_at + ttl is in the past (expired) + back to `ready`, clearing the claim fields. reclaim --id force-releases a specific card regardless of expiry. This is how a crashed worker's card returns to the pool. -## CLI — `mosaic fleet backlog --json` +## CLI — mosaic fleet backlog --json All subcommands support `--json`. -| Subcommand | Purpose | -| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `create --id --title [--body --phase --priority --depends-on --acceptance --idempotency-key]` | Create a card; `idempotency_key` dedups (repeat returns the existing card). | -| `list [--status --phase --ready-only]` | List cards. `--ready-only` = status `ready` AND all deps `done`. | -| `claim --owner [--ttl --id ]` | Atomically claim the highest-priority ready card (or `--id`). Returns the card or `null`. | -| `reclaim [--id ]` | Release expired claims (or a specific card) back to `ready`. | -| `link --from --to` | Add a `depends_on` edge (`--from` depends on `--to`). | -| `stats` | Counts by status, oldest-ready age, expired-claim count. | -| `block --id` | Set a card to `blocked`. | -| `complete --id` | Set a card to `done` (releases any claim). | +| Subcommand | Purpose | +| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| create --id --title [--body --phase --priority --depends-on --acceptance --idempotency-key] | Create a card; `idempotency_key` dedups (repeat returns the existing card). | +| list [--status --phase --ready-only] | List cards. `--ready-only` = status `ready` AND all deps `done`. | +| claim --owner [--ttl --id ] | Atomically claim the highest-priority ready card (or `--id`). Returns the card or `null`. | +| reclaim [--id ] | Release expired claims (or a specific card) back to `ready`. | +| link --from --to | Add a `depends_on` edge (`--from` depends on `--to`). | +| `stats` | Counts by status, oldest-ready age, expired-claim count. | +| block --id | Set a card to `blocked`. | +| complete --id | Set a card to `done` (releases any claim). | ### Example -```sh -# Seed two cards, the second depends on the first. +Seed two cards; the second depends on the first. Because A2 is gated on A1, claim returns A1 first. Finish A1, then list A2 as ready. Recover stalled work. + +```fleet-command mosaic fleet backlog create --id A1 --title "schema" --priority 5 mosaic fleet backlog create --id A2 --title "service" --depends-on A1 --priority 9 -# A2 is gated on A1, so claim returns A1 first. mosaic fleet backlog claim --owner worker-1 --ttl 600 --json -# Finish A1; now A2 is ready. mosaic fleet backlog complete --id A1 mosaic fleet backlog list --ready-only --json -# Recover stalled work. mosaic fleet backlog reclaim --json ``` diff --git a/docs/fleet/concepts/desired-vs-observed-state.md b/docs/fleet/concepts/desired-vs-observed-state.md new file mode 100644 index 00000000..46a8f52d --- /dev/null +++ b/docs/fleet/concepts/desired-vs-observed-state.md @@ -0,0 +1,42 @@ +# Desired, Derived, and Observed Fleet State + +## One writable authority + +The canonical local v2 roster at /fleet/roster.yaml is desired state. Membership, stable identity, class, runtime/provider/model selection, launch policy, enablement, and persisted `running`/`stopped` intent are written only through generation-guarded roster mutations. + +Derived projections are reproducible consequences of that authority: + +- .env.generated; +- exact roster-named tmux sessions on the configured socket after reconciliation; +- systemd service targets managed by installation/reconciliation. + +Current systemd unit enablement is not yet lifecycle-conformant at boot: installation can enable every +agent unit, and the launcher projection does not carry `enabled` or `desired_state`. Therefore reboot +preservation for stopped/disabled agents remains an FCM-M3-002 acceptance hold, not a guaranteed +projection behavior. + +Observed evidence available to current roster-v2 status commands includes systemd active state, tmux +presence, holder ownership, and unmanaged sessions. Heartbeat files are observational in the wider fleet, +but roster-v2 `status`, `doctor`, and `verify` do not currently read them. Observation never writes back +to the roster. + +## Generation and ownership + +`generation` is a positive integer concurrency fence. A mutating request must provide the current value. Successful changed CRUD increments it exactly once; stale or concurrent writers fail before mutation. Apply/reconcile rereads the canonical roster under a private exclusive lock and uses only that generation and content for effects. + +Ownership is exact, never fuzzy. Reconciliation is limited to roster names, the configured socket, the exact holder session, a private installation identity, and private managed paths. An ownership mismatch, unmanaged session, unsafe path, stale generation, or ambiguous lock fails closed. + +## Drift decisions + +| Observation | Interpretation | Safe response | +| ---------------------------------------- | ------------------------ | ---------------------------------------------------------------------- | +| Generated file differs or is missing | Derived projection drift | Review apply --dry-run; regenerate from the roster. | +| Desired `running`, exact session missing | `missing-session` | Diagnose ownership/runtime, then reconcile if safe. | +| Desired `stopped`, exact session present | `unexpected-session` | Inspect; reconciliation may stop only the proven roster target. | +| Disabled agent running | `disabled-running` | Inspect; disabled state wins during explicit safe reconciliation. | +| Unknown session on the configured socket | Unmanaged state | Report only. Do not adopt, rename, or kill it. | +| Heartbeat stale in the wider fleet | Liveness evidence | Diagnose separately; current roster-v2 status does not read heartbeat. | + +`status` and `doctor` classify. `verify` is also observational but exits non-zero when ownership, drift, or unmanaged-state checks fail. `plan`/apply --dry-run validates proposed projection and lifecycle work without mutation. `apply` and `reconcile` converge only after all preconditions pass. + +A partial projection failure does not roll the roster back. Treat the committed roster as authority and regenerate. A lifecycle failure after projection completion preserves both roster and projections for inspection. Sensitive legacy values are never printed; diagnostics are bounded to stable codes, key names where applicable, and hashes. diff --git a/docs/fleet/concepts/generated-env-launch-chain.md b/docs/fleet/concepts/generated-env-launch-chain.md new file mode 100644 index 00000000..65cb582a --- /dev/null +++ b/docs/fleet/concepts/generated-env-launch-chain.md @@ -0,0 +1,23 @@ +# Generated Environment Launch Chain + +The launcher consumes validated data, not shell configuration. + +1. Read and validate the canonical roster. +2. Render deterministic .env.generated data from that roster. +3. Parse optional .env.local through a strict allowlist. +4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides. +5. Derive the runtime command from validated runtime/model/reasoning data. +6. Target only the exact configured tmux socket and roster session after ownership checks. + +## File precedence and ownership + +| File | Owner | Use | +| ----------------- | ------------------------ | --------------------------------------------------------------------------- | +| `.env.generated` | Mosaic projection writer | Complete deterministic roster projection. Rebuild; do not edit. | +| `.env.local` | Operator | Optional, private, strict machine-local data. Cannot shadow generated keys. | +| `.env` | Legacy input | One-time migration input only; never launch authority. | +| `.env.quarantine` | Private quarantine | Retained unsafe legacy evidence; never loaded by the launcher. | + +Neither systemd nor the launcher sources these files. No `eval`, shell expansion, arbitrary `MOSAIC_AGENT_COMMAND`, channel, or secret-reference compatibility path exists. Safe legacy generated keys are regenerated, allowed local keys are relocated, and unsafe material is quarantined. + +Diagnostics never expose the rejected value, credential material, or command text. They are bounded to stable rule code, key name where safe, and SHA-256 content identity. See [generated environment reference](../reference/generated-env-boundary.md) and [quarantine operations](../operations/env-quarantine.md). diff --git a/docs/fleet/concepts/identity-class-runtime.md b/docs/fleet/concepts/identity-class-runtime.md new file mode 100644 index 00000000..f94bfdf5 --- /dev/null +++ b/docs/fleet/concepts/identity-class-runtime.md @@ -0,0 +1,20 @@ +# Fleet Identity, Class, and Runtime + +Each roster field has one job. Do not use names or model strings as authority shortcuts. + +| Concern | Field | Contract | +| ----------------------- | ----------------------------- | -------------------------------------------------------------------------------------------- | +| Stable machine identity | agents[].name | Unique, immutable mutation target and exact service/session name. | +| Display identity | agents[].alias | Human-facing label only; may be changed and grants no authority. | +| Behavioral contract | agents[].class | Resolves through the shared baseline plus `roles.local` persona library. | +| Tool boundary | agents[].tool_policy | Must match protected canonical classes; cannot independently grant authority. | +| Harness | agents[].runtime | One of `claude`, `codex`, `opencode`, or `pi`, declared in `runtimes`. | +| Backend selection | agents[].provider and `model` | Explicit non-empty data; capability validity is not inferred from the display name or class. | +| Effort | agents[].reasoning | `low`, `medium`, or `high`. | +| Local placement | `working_directory` | Explicit safe local work path; not remote placement authority. | + +Tess and Ultron are conventional instance/display names only. They are not products, required machine identities, role aliases, or authority-bearing classes. A configurable interaction instance uses class: interaction; a configurable validation instance uses class: validator. Any stable name and alias satisfying the structural contract may be used. + +Class aliases are deliberately narrow: implementer → code, reviewer → review, and operator-interaction → interaction. No runtime, provider, model, persona prose, or instance name changes this mapping. See [role classes](../reference/role-classes.md) and the [validated generic example](../examples/roster-v2.yaml). + +Roster v2 is local-only. It contains no host/SSH placement, connector, channel, secret-reference, arbitrary-command, per-agent socket, or gateway mapping fields. Those concerns require separate requirements and threat models. diff --git a/docs/fleet/concepts/role-authority-and-leases.md b/docs/fleet/concepts/role-authority-and-leases.md new file mode 100644 index 00000000..8551e009 --- /dev/null +++ b/docs/fleet/concepts/role-authority-and-leases.md @@ -0,0 +1,22 @@ +# Fleet Role Authority and Leases + +Role content describes behavior; protected authority is immutable code metadata derived only from the canonical class. + +## Required workstream classes + +`code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction` are required FCM classes. `merge-gate` is additionally protected because it remains the sole approve-to-land and merge authority. + +| Class | Authority | Boundary | +| -------------------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `merge-gate` | Approve-to-land and merge | Sole merge authority. | +| `validator` | Issue independent validation evidence/certificate | Never approves landing or merges. | +| `orchestrator` | Orchestrate topology and issue bounded leases | Does not gain merge authority. | +| `team-leader` | Use explicitly leased capacity | Cannot issue leases or mutate roster, credentials, topology authority, or merge state. | +| `interaction` | Receive requests and report status | Cannot orchestrate, issue leases, mutate configuration, or merge. | +| `code`, `review`, `enhancer`, custom classes | No protected authority by default | Persona prose cannot grant protected powers. | + +A lease is capacity authorization from an orchestrator, not ownership. It must identify a bounded task or period and does not alter the leased agent's roster identity, role contract, credentials, authority, or persisted lifecycle. Expiry/revocation returns capacity; it does not rewrite the roster. + +Semantic validation rejects protected class/tool-policy mismatch in either direction. An instance named Ultron with class: validator remains validation-only. An instance named Tess with class: interaction remains request/status-only. Renaming either instance changes no authority. + +For resolver layering and safe customization, see [role classes](../reference/role-classes.md) and [customize roles](../how-to/customize-roles.md). diff --git a/docs/fleet/examples/roster-v2.yaml b/docs/fleet/examples/roster-v2.yaml new file mode 100644 index 00000000..d88053e2 --- /dev/null +++ b/docs/fleet/examples/roster-v2.yaml @@ -0,0 +1,61 @@ +version: 2 +generation: 1 +transport: tmux +tmux: + socket_name: mosaic-fleet + holder_session: _holder +defaults: + working_directory: ~/src + runtime: pi +runtimes: + pi: + reset_command: /new +agents: + - name: code-example + alias: Code Example + class: code + runtime: pi + provider: example-provider + model: example-model + reasoning: medium + tool_policy: code + working_directory: ~/src + persistent_persona: false + reset_between_tasks: true + lifecycle: + enabled: true + desired_state: stopped + launch: + yolo: false + - name: interaction-example + alias: Interaction Example + class: interaction + runtime: pi + provider: example-provider + model: example-model + reasoning: low + tool_policy: interaction + working_directory: ~/src + persistent_persona: true + reset_between_tasks: false + lifecycle: + enabled: true + desired_state: stopped + launch: + yolo: false + - name: validator-example + alias: Validator Example + class: validator + runtime: pi + provider: example-provider + model: example-model + reasoning: high + tool_policy: validator + working_directory: ~/src + persistent_persona: false + reset_between_tasks: true + lifecycle: + enabled: true + desired_state: stopped + launch: + yolo: false diff --git a/docs/fleet/f4-matrix-connector.md b/docs/fleet/f4-matrix-connector.md index 031192cd..4a50273a 100644 --- a/docs/fleet/f4-matrix-connector.md +++ b/docs/fleet/f4-matrix-connector.md @@ -15,7 +15,7 @@ core. Connectors implement one small, uniform interface (`src/fleet/connectors/types.ts`): -```ts +```typescript interface OrchestratorConnector { readonly kind: 'tmux' | 'discord' | 'matrix'; send(message: OutboundMessage): Promise; // orchestrator → human @@ -25,11 +25,11 @@ interface OrchestratorConnector { ``` - **send / subscribe / health** — the only surface fleet core depends on. `SendResult` is the - ack half; `health()` is the liveness half. + ack half; health() is the liveness half. - **Thread-aware by metadata** — `OutboundMessage.threadId` / `InboundMessage.threadId` are optional, so thread-capable connectors (Matrix rooms/threads, the future first-party Mosaic Discord plugin) fit **without an interface change**. -- **Registry** (`registry.ts`) — implementations register a factory by kind; `createConnector(config)` +- **Registry** (`registry.ts`) — implementations register a factory by kind; createConnector(config) resolves one from roster config. Phase 1 ships the registry + `resolveConnectorKind` (defaults `tmux` when a roster declares no connector — **back-compat**); the factories land in Phase 2. @@ -39,7 +39,7 @@ A roster may carry an optional `connector` block (`roster.schema.json`); absent ```yaml connector: - kind: matrix # tmux | discord | matrix + kind: matrix matrix: homeserver_url: https://matrix.example.internal user_id: '@mos:example.internal' @@ -55,12 +55,12 @@ reject a token committed to a shipped file anyway. The connector speaks the **Matrix client-server API** directly over HTTPS (`fetch` — no SDK needed for MVP), so it is **homeserver-agnostic**: -| Op | Matrix CS-API | -| ----------- | ------------------------------------------------------------------------ | -| `send` | `PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}` | -| `subscribe` | `GET /_matrix/client/v3/sync` (long-poll, `since` token) → room timeline | -| `health` | `GET /_matrix/client/versions` (reachable) + `…/account/whoami` (authed) | -| threads | `m.thread` relations ↔ `threadId` | +| Op | Matrix CS-API | +| ----------- | ----------------------------------------------------------------------- | +| `send` | PUT /\_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId} | +| `subscribe` | GET /\_matrix/client/v3/sync (long-poll, `since` token) → room timeline | +| `health` | GET /\_matrix/client/versions (reachable) + …/account/whoami (authed) | +| threads | `m.thread` relations ↔ `threadId` | ## Local homeserver (infra, not connector code) @@ -79,7 +79,7 @@ homeserver choice is a **deployment** concern (a Phase-2 deploy guide), not conn | ----- | --------------------------------------------------------------------------------------- | ------- | | **1** | Connector interface + types, registry + kind resolution, roster `connector` schema, doc | ✅ yes | | 2 | Matrix CS-API client (fetch-based send/sync/health) + registered factory + tests | follow | -| 2 | `fleet init` / `configure` connector-selection UX; roster parse wires the block | follow | +| 2 | fleet init / `configure` connector-selection UX; roster parse wires the block | follow | | 2 | systemd launch wiring so the orchestrator starts on the chosen connector | follow | | 3 | Conduit deploy guide; first-party Mosaic Discord (threads) registers as a connector | follow | diff --git a/docs/fleet/how-to/configure-tess-interaction.md b/docs/fleet/how-to/configure-tess-interaction.md new file mode 100644 index 00000000..4e2969f5 --- /dev/null +++ b/docs/fleet/how-to/configure-tess-interaction.md @@ -0,0 +1,21 @@ +# Configure an Interaction Instance + +An interaction instance is a configurable local roster member with canonical class: interaction and matching tool_policy: interaction. “Tess” may be used as a display alias, but neither that alias nor the stable name is required or authority-bearing. + +Use the [validated generic roster](../examples/roster-v2.yaml) as the safe shape. Choose a unique stable `name`, any descriptive `alias`, a supported declared runtime, explicit provider/model/reasoning, and a safe work directory. Start with: + +```yaml +name: interaction-example +alias: Interaction Example +class: interaction +tool_policy: interaction +lifecycle: + enabled: true + desired_state: stopped +``` + +Plan the complete agent payload with the current roster generation, then create it without `--persisted-start`. Creation defaults to enabled/stopped and performs no runtime action. Review the resulting roster and projection plan before any later lifecycle decision. + +The interaction class is request/status only. It cannot orchestrate, issue leases, mutate the roster/configuration, grant credentials, certify validation, approve landing, or merge. Connector and channel configuration are outside roster v2; do not add connector, channel, secret, command, remote-host, or gateway fields. + +See [safe CRUD](create-update-delete-agent.md), [identity separation](../concepts/identity-class-runtime.md), and [role authority](../concepts/role-authority-and-leases.md). diff --git a/docs/fleet/how-to/configure-ultron-validator.md b/docs/fleet/how-to/configure-ultron-validator.md new file mode 100644 index 00000000..8704e5e4 --- /dev/null +++ b/docs/fleet/how-to/configure-ultron-validator.md @@ -0,0 +1,21 @@ +# Configure a Validator Instance + +A validator instance is a configurable local roster member with canonical class: validator and matching tool_policy: validator. “Ultron” may be used as a display alias, but it is not a required identity, class alias, product name, or source of authority. + +Use the [validated generic roster](../examples/roster-v2.yaml) as the safe shape. Choose a unique stable name and explicit supported runtime/provider/model/reasoning values. Start stopped: + +```yaml +name: validator-example +alias: Validator Example +class: validator +tool_policy: validator +lifecycle: + enabled: true + desired_state: stopped +``` + +Plan the full payload with the current generation and create without `--persisted-start`. Creation writes desired state and projections only; it does not launch a validator. + +`validator` may issue independent validation evidence or a certificate. It has no approve-to-land or merge authority. `merge-gate` remains the sole protected merge authority, and changing the validator's name, alias, persona prose, runtime, provider, model, or tool-policy text cannot elevate it. + +Certificate consumption and final release evidence remain FCM-M5-002 gates. This page does not create a certificate or authorize merge. See [safe CRUD](create-update-delete-agent.md) and [role authority](../concepts/role-authority-and-leases.md). diff --git a/docs/fleet/how-to/create-update-delete-agent.md b/docs/fleet/how-to/create-update-delete-agent.md new file mode 100644 index 00000000..2591efdb --- /dev/null +++ b/docs/fleet/how-to/create-update-delete-agent.md @@ -0,0 +1,74 @@ +# Create, Inspect, Update, and Delete a Local Fleet Agent + +Use the local roster-v2 control plane only. These commands change desired state and derived environment projections; they never start, stop, reconcile, inspect, or otherwise act on systemd, tmux, sessions, or runtimes. + +## Read and plan first + +```fleet-synopsis +mosaic fleet get +mosaic fleet plan create --expected-generation --agent '' +mosaic fleet plan update --expected-generation --agent '' +mosaic fleet plan delete --expected-generation +``` + +plan create takes the name from `--agent`. plan update and plan delete require the target name immediately after the operation. A plan is deterministic and side-effect free: it validates the complete proposed roster and projection targets without changing files. Use `--dry-run` on `create`, `update`, or `delete` for the same no-write result. + +Every successful command prints JSON. `get` returns { "generation", "agent" }; mutation results contain `plan`, `applied`, `authoritativeRoster`, and `projections`. + +## Create safely + +```fleet-command +mosaic fleet create --expected-generation 7 --agent '{ + "name":"coder0", + "alias":"Coder 0", + "className":"code", + "runtime":"pi", + "provider":"openai", + "model":"gpt-5.6-sol", + "reasoning":"high", + "toolPolicy":"code", + "workingDirectory":"/srv/mosaic", + "persistentPersona":false, + "resetBetweenTasks":true, + "launch":{"yolo":true} +}' +``` + +Create defaults to enabled: true and desired_state: stopped. It does not start a process. Add `--persisted-start` only to persist desired_state: running; that still does not start a runtime in this M2 command. The JSON payload is an allowlist of the roster-v2 fields shown above plus `launch.yolo`; command, channel, secret-reference, and other unknown keys are rejected rather than ignored. The JSON error exposes only a stable code, never the rejected value. + +## Update and delete safely + +```fleet-synopsis +mosaic fleet update --expected-generation --agent '' +mosaic fleet delete --expected-generation +``` + +Updates require a complete agent JSON payload and preserve the stable name. Delete removes only the exact roster-owned `coder0.env.generated` projection. It retains `coder0.env.local`, legacy `coder0.env`, `coder0.env.quarantine`, and every unrelated projection. A delete dry-run leaves all of those files byte-identical. + +## Handle generation conflicts + +Every mutation requires the current authoritative `--expected-generation`. A stale value returns JSON error.code: "stale-generation" with a non-zero exit. Reload with mosaic fleet get or reread the roster, plan again using the returned generation, then retry. A concurrent mutation returns `concurrent-mutation`; do not force or bypass the lock. + +## Interpret partial failures + +The roster is authoritative and is written before derived projections. A late projection I/O failure returns non-zero with redacted, actionable JSON: + +```json +{ + "applied": false, + "authoritativeRoster": "committed", + "projections": "incomplete", + "recovery": { + "code": "projection-apply-failed", + "action": "regenerate-projections-from-roster" + } +} +``` + +This is not a rollback and not a no-op: reload the roster because its generation and membership were committed, regenerate projections from that roster, then plan a new mutation. Recovery output never contains environment values, credentials, or command text. + +## Exit and boundary behavior + +Handled validation errors and partial projection failures exit non-zero. `plan`/`--dry-run` and normal mutation JSON make the state explicit; scripts should use both the exit code and `authoritativeRoster`/`projections`, not `applied` alone. + +The commands operate only on /fleet/roster.yaml, the local roster desired-state authority. They do not accept arbitrary commands, channels, secrets, remote/connector actions, migration/canary actions, or runtime lifecycle operations. diff --git a/docs/fleet/how-to/customize-roles.md b/docs/fleet/how-to/customize-roles.md new file mode 100644 index 00000000..646725b6 --- /dev/null +++ b/docs/fleet/how-to/customize-roles.md @@ -0,0 +1,54 @@ +# Customize Fleet Roles + +Mosaic resolves persona contracts through two layers: + +1. fleet/roles/.md — seeded baseline contract. +2. fleet/roles.local/.md — operator override or custom role; this layer wins. + +The same shared resolver is used by profile validation, provisioning, roster-v2 semantic validation, +and launch-time persona injection. + +## Override a baseline role + +Create a readable Markdown contract under `roles.local` with the canonical filename and class marker: + +```markdown +# Code — local role definition + +The local code role (`class: code`) follows the operator's repository conventions. +``` + +Save it as `fleet/roles.local/code.md`. Do not edit generated or seeded baseline assets when the goal +is a durable local customization. + +Legacy aliases canonicalize before lookup. Therefore `roles.local/implementer.md` does not override +`code`; use `roles.local/code.md`. See [Legacy Fleet Class Aliases](../migration/legacy-class-aliases.md). + +## Add a custom class + +A custom class remains supported when a readable contract exists for the exact identifier: + +```markdown +# Release notes — local role definition + +The release-notes role (`class: release-notes`) prepares operator-reviewed release copy. +``` + +Save it as `fleet/roles.local/release-notes.md`, then reference class: release-notes and a matching +tool_policy: release-notes in roster v2. Adding only a `LIBRARY.md` row is insufficient. + +Names such as `worker`, `analyst`, and `canary` are not built-in aliases; they need genuine custom +contracts. agents[].alias, Tess, and Ultron are display names and cannot select a class. + +## Validation and authority boundaries + +Semantic validation reads the winning contract and rejects missing, unreadable, or empty files. +Protected authority is derived from canonical class metadata in code, never from role prose. A custom +contract cannot claim merge, validation-certificate, orchestration, lease, or interaction authority. + +Roster v2 also fails closed when a protected class and tool policy do not match after canonicalization, +or when an unprotected class claims a protected tool policy. The legacy `operator-interaction` policy +canonicalizes to `interaction`. + +Role customization does not issue leases, store validation certificates, mutate credentials, or +change lifecycle state. diff --git a/docs/fleet/how-to/start-stop-restart.md b/docs/fleet/how-to/start-stop-restart.md new file mode 100644 index 00000000..8707ad84 --- /dev/null +++ b/docs/fleet/how-to/start-stop-restart.md @@ -0,0 +1,23 @@ +# Safely Reconcile and Control a Local Fleet Agent + +Use the canonical local roster-v2 command surface: + +```fleet-synopsis +mosaic fleet apply --expected-generation --dry-run +mosaic fleet apply --expected-generation +mosaic fleet reconcile --expected-generation +mosaic fleet start --expected-generation +mosaic fleet stop --expected-generation +mosaic fleet restart --expected-generation +mosaic fleet status [] +mosaic fleet verify +mosaic fleet doctor +``` + +Start with `--dry-run`. It validates roster semantics, deterministic projections, private managed paths, exact holder ownership, and named-socket state without changing files or lifecycle state. Explicit `apply` and `reconcile` rebuild derived projections and enforce persisted roster state: enabled `running` agents may start, while stopped or disabled agents are not started. This guarantee does not extend to reboot/service activation yet; boot preservation remains an FCM-M3-002 hold. + +`start`, `stop`, and `restart` are explicit one-shot exact-service actions. They do not persist a lifecycle change. `update` preserves the agent's existing lifecycle, and no delivered operation changes durable lifecycle after creation. + +Every command prints JSON. Observation commands report drift without mutation; `verify` exits non-zero on ownership mismatch, unmanaged sessions, or drift. A failed apply that wrote some derived projections reports projections: "incomplete" with bounded recovery to regenerate from the roster. A lifecycle failure after projections reports incomplete lifecycle work; it is never represented as a rollback or no-op. + +These commands are local only. Remote/SSH/connector entries are inventory/validation-only. Commands do not accept arbitrary runtime commands, channels, secrets, generated-file desired state, or arbitrary tmux sockets. diff --git a/docs/fleet/migration/example-profile-disposition.md b/docs/fleet/migration/example-profile-disposition.md new file mode 100644 index 00000000..c42b73f3 --- /dev/null +++ b/docs/fleet/migration/example-profile-disposition.md @@ -0,0 +1,69 @@ +# Executable Fleet Example, Profile, and Service-Preset Dispositions + +**Issue:** #758 · **Card:** FCM-M1-003 · **Status:** M1 executable disposition evidence + +This document records the executable disposition for every currently shipped fleet YAML artifact. +The authoritative baseline classification remains the +[legacy inventory](../LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md). The executable guard is +`packages/mosaic/src/fleet/example-profile-dispositions.ts`; its test fails if a shipped YAML +artifact is added, removed, or left without one of the dispositions below. + +## Disposition rules + +- **Explicit v1 fixture:** the artifact is loaded through the existing v1 roster parser and must + declare version: 1. It remains a compatibility fixture; it is not silently treated as a v2 + roster or given inferred aliases. +- **Canonical profile:** the artifact is loaded through `loadProfiles`, which uses the shared + baseline-plus-`roles.local` persona resolver and rejects unreadable or unresolved classes. +- **Canonical service policy:** the artifact is loaded through the operator-interaction service + policy reader and provisioned with a generic supplied identity. It validates its runtime, model, + reasoning, and legacy tool-policy compatibility without hardcoding a product identity. + +No artifact is retired in this card. A later retirement requires both a replacement link and a +visible deprecation note; the executable guard must then record the new disposition before the +artifact can be removed. + +## Shipped artifacts + +| Artifact | Disposition | Executable path | Compatibility notes | +| ------------------------------------ | ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------ | +| `examples/coding.yaml` | Explicit v1 fixture | v1 roster parser | Retains approved `implementer` and `reviewer` compatibility inputs. | +| `examples/general.yaml` | Explicit v1 fixture | v1 roster parser | Retains unresolved `worker` without an inferred canonical role. | +| `examples/hybrid.yaml` | Explicit v1 fixture | v1 roster parser | Retains `implementer`, `reviewer`, and resolver-dependent `researcher`. | +| `examples/local-canary.yaml` | Explicit v1 fixture | v1 roster parser | Retains the local-tmux canary topology. | +| `examples/minimal.yaml` | Explicit v1 fixture | v1 roster parser | Retains `canary` without an inferred canonical role. | +| `examples/operator-interaction.yaml` | Explicit v1 fixture | v1 roster parser | Keeps Tess only as an example instance name; `operator-interaction` remains compatibility input. | +| `examples/research.yaml` | Explicit v1 fixture | v1 roster parser | Retains resolver-dependent `researcher` and `analyst`. | +| `profiles/business.yaml` | Canonical profile | shared profile/persona resolver | Every referenced business class must resolve to a readable contract. | +| `profiles/marketing.yaml` | Canonical profile | shared profile/persona resolver | Every referenced marketing class must resolve to a readable contract. | +| `profiles/personal-assistant.yaml` | Canonical profile | shared profile/persona resolver | No interaction equivalence is inferred. | +| `profiles/research.yaml` | Canonical profile | shared profile/persona resolver | Every research class must resolve to a readable contract. | +| `profiles/software-delivery.yaml` | Canonical profile | shared profile/persona resolver | Retains the governance profile; authority validation remains FCM-M1-002 evidence. | +| `services/operator-interaction.yaml` | Canonical service policy | service-policy reader/provisioner | Generic provisioning supplies the instance name; the policy itself never names Tess. | + +## M4 migration-preview evidence + +FCM-M4-001 layers an executable migration posture over the same 13-entry M1 inventory without +changing the retained artifact classification: + +- every `v1-fixture` is previewed only with explicit class and lifecycle evidence; +- every `canonical-profile` remains validated by the shared baseline-plus-`roles.local` resolver; +- the canonical service policy remains generic and uses only the approved tool-policy alias. + +`validateShippedFleetMigrationDispositions` first runs the existing executable M1 guard, then requires +explicit decisions and lifecycle observations and executes `previewV1ToV2Migration` for every shipped +v1 fixture. `collectShippedFleetMigrationDispositions` derives the 13-entry posture directly from +`SHIPPED_FLEET_ARTIFACT_DISPOSITIONS`, so additions or removals continue to fail the M1 guard rather +than creating a second artifact list. None of these dispositions claims a cutover, canary, or +rollback; those gates belong to FCM-M4-002. See [v1-to-v2 preview](./v1-to-v2.md). + +## Running the guard + +```fleet-command +pnpm --filter @mosaicstack/mosaic test -- v1-v2-migration.spec.ts \ + -t "validates all 13 shipped artifacts and executes ready previews for every v1 fixture" +``` + +The guard is intentionally limited to shipped assets and validation. It does not generate +environment files, mutate a roster, reconcile a fleet, migrate an installed roster, or launch an +agent. diff --git a/docs/fleet/migration/legacy-class-aliases.md b/docs/fleet/migration/legacy-class-aliases.md new file mode 100644 index 00000000..be9bb3e2 --- /dev/null +++ b/docs/fleet/migration/legacy-class-aliases.md @@ -0,0 +1,40 @@ +# Legacy Fleet Class Aliases + +Fleet class compatibility is intentionally narrow. The shared resolver accepts exactly three legacy +class names and converts them to canonical classes before persona lookup: + +| Legacy value | Canonical value | Migration action | +| ---------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `implementer` | `code` | Replace class and tool-policy references with `code`. | +| `reviewer` | `review` | Replace class and tool-policy references with `review`. | +| `operator-interaction` | `interaction` | Replace class and roster-v2 tool-policy references with `interaction`. The legacy service artifact remains compatible. | + +Alias support preserves existing inputs while provisioning and typed semantic output use canonical +identities. Requested and canonical class values remain separately observable during semantic +validation. + +## Lookup and override behavior + +Canonicalization precedes baseline and `roles.local` lookup. A legacy-named override such as +`roles.local/implementer.md` is not a separate authority and is not selected for an `implementer` +request. Customize the canonical role instead, for example `roles.local/code.md`. + +The compatibility file `operator-interaction.md` remains shipped, but `interaction` is the canonical +role class. Tess is an example display name only. + +## Unresolved and custom classes + +No names are inferred from historical usage, instance names, or similar wording. `worker`, `analyst`, +`canary`, Tess, and Ultron are not aliases. An otherwise unknown class is accepted only if the shared +resolver can read an actual baseline or `roles.local` contract for that exact class. A `LIBRARY.md` +row without a readable contract fails semantic validation. + +Custom classes receive no protected authority implicitly. Protected class/tool-policy mismatches +fail closed. + +## Retirement guidance + +New configuration should emit canonical values. Existing inputs may use the three aliases during the +compatibility period, but operators should migrate class and tool-policy fields together. Do not +create new legacy-named role overrides; move their intended content to the canonical filename and +validate the roster/profile before removing the old artifact. diff --git a/docs/fleet/migration/v1-to-v2.md b/docs/fleet/migration/v1-to-v2.md new file mode 100644 index 00000000..49b35295 --- /dev/null +++ b/docs/fleet/migration/v1-to-v2.md @@ -0,0 +1,86 @@ +# Previewing a Fleet Roster v1-to-v2 Migration + +**Issue:** #758 · **Card:** FCM-M4-001 · **Effect boundary:** preview only + +mosaic fleet migrate-v1 preview inventories a v1 roster and emits a canonical v2 candidate plus +recovery evidence. It does not write a roster, apply environment projections, invoke systemd or +`tmux`, contact connectors or remote hosts, launch an agent, run a canary, or execute rollback. +FCM-M4-002 owns reversible cutover and rollback. + +## Inputs + +```fleet-command +mosaic fleet migrate-v1 preview \ + --source roster-v1.yaml \ + --decisions migration-decisions.json \ + --observations reviewed-observations.json +``` + +The command emits one JSON object and exits nonzero when the preview is blocked, including when any of +`--source`, `--decisions`, or `--observations` is omitted, passed without a path value, or passed an empty +path value. These request-shape failures are reported before any input file is read. Decision and +observation JSON is validated fail-closed: unknown fields, malformed values, and records for non-local +agents are rejected. Decisions must supply a positive v2 `generation`, a reviewed `fleetHost` whenever +v1 agents include `host` or `ssh`, explicit `defaultRuntime`, and per-local-agent provider, model, +reasoning, enabled state, and launch policy. The v1 source remains authoritative for socket semantics: +a supported declared socket field, including an explicit empty value for the default tmux server, is +preserved; if both supported root aliases are absent, the production v1 default is the literal empty socket. +A matching `socketName` decision is accepted and an incompatible decision blocks, but a decision never +supplies or repairs a missing source socket. If v1 omitted `tool_policy`, decisions must supply an +explicit replacement; it is never derived from `class`. `model_hint` is never split or treated as +authority. + +Observations are separate reviewed evidence keyed by local agent name: + +```json +{ + "coder0": { "systemd": "inactive", "tmux": "missing" } +} +``` + +Only `active` plus `present` maps to `running`; only `inactive` plus `missing` maps to `stopped`. +Missing, extra, unknown, or contradictory evidence blocks output. An observed-running agent cannot +be marked disabled. Observed-stopped agents always remain stopped. + +## Field disposition + +| v1 field | v2 disposition | +| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `version`, `transport`, `tmux`, `defaults`, `runtimes` | Inventoried and structurally compiled; omitted runtimes retain v1 built-in defaults, while each explicitly declared runtime without a reset field follows the production v1 `/clear` fallback; present-empty holder/work-directory/reset values block | +| agent `name`, `alias`, `runtime`, working directory, persona/reset flags | Copied or explicitly defaulted only when absent; present-empty alias/work-directory values block for explicit disposition. Canonical ~/~/... values stay unchanged in roster evidence and traversal-free forms expand only at the shared production environment-projection boundary before unchanged absolute-path validation | +| `provider`, `model_hint`, `reasoning_level` | Explicit provider/model/reasoning decisions; no model-hint inference | +| `class`, `tool_policy` | Only approved aliases canonicalize automatically; other classes require explicit preserve/replace disposition and shared-resolver validation | +| `kickstart_template` | No v2 field; explicit inventory-only disposition required | +| agent `host`, `ssh` | host != fleetHost is demonstrably remote and inventory-only; host == fleetHost stays local; SSH targets with or without an explicit user must agree with `host`; ssh-only, missing fleet-host evidence, or contradictory targets block | +| agent `socket` | Same-host candidate only when it matches the canonical fleet socket; conflicts block for explicit future disposition | +| root `connector` | Inventory-only; never contacted or reconciled | +| unknown fields or snake/camel synonym collisions | Inventoried and block readiness | +| `.env.generated` | Rebuild from canonical roster data | +| no legacy `.env` | `absent`; no legacy action required | +| legacy `.env` containing generated keys only | `regenerate-only`; replace later from canonical roster data | +| legacy `.env` containing strict local keys | `relocate-local`; preserve those keys in `.env.local` during a later reviewed cutover | +| legacy `.env` containing forbidden/unsafe/sensitive/malformed keys | `quarantine`; private input only, with diagnostics limited to code, key, and SHA-256 | + +The only automatic aliases are implementer → code, reviewer → review, and +operator-interaction → interaction. Similar or domain-specific names are never inferred. Automatic +classes do not accept competing disposition records. Semantic validation delegates to the existing +baseline-plus-`roles.local` resolver after the candidate is compiled by the existing v2 compiler. + +## Evidence and recovery boundary + +Ready output includes source and candidate SHA-256 identities, value-free field inventory, excluded +remote/connector entries, explicit environment dispositions with sanitized diagnostics, and the lifecycle +evidence used for each local candidate. Canonical lifecycle and remote-exclusion evidence ordering compares +Unicode code points directly and does not depend on source-agent order or process locale. Source field +inventory remains position-addressed evidence of the exact input. Recovery is marked non-executable and +assigns the executable gate to FCM-M4-002. + +Before any later cutover, preserve these artifacts: + +1. authoritative v1 roster backup; +2. agent environment backup, including `.env.local` and private quarantine inputs; +3. reviewed lifecycle observations; +4. canonical candidate v2 roster and its SHA-256. + +See [backup and restore](../operations/backup-restore.md). Preview output is migration-readiness +evidence, not proof that migration, canary, or rollback occurred. diff --git a/docs/fleet/north-star.md b/docs/fleet/north-star.md index c3774350..b526a98b 100644 --- a/docs/fleet/north-star.md +++ b/docs/fleet/north-star.md @@ -44,9 +44,9 @@ The Fleet inherits — does not re-invent — the MVP's hard requirements: | MVP req | What it means for the Fleet | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | MVP-X1 three-surface parity | fleet observability/control reachable via **CLI + TUI + webUI** (CLI first; webUI is required for parity, not optional) | -| MVP-X2 multi-tenant isolation | one tenant = one **Linux uid** (own `systemd --user`, socket, `~/.config/mosaic`); no cross-tenant leakage | +| MVP-X2 multi-tenant isolation | one tenant = one **Linux uid** (own systemd --user, socket, ~/.config/mosaic); no cross-tenant leakage | | MVP-X3 auth (BetterAuth/SSO) | operator→fleet and cross-host views are auth-gated through the platform's existing auth | -| MVP-X4 quality gates | `pnpm typecheck`/`lint`/`format:check` green before any push | +| MVP-X4 quality gates | pnpm typecheck/`lint`/`format:check` green before any push | | MVP-X5 federated topology | cross-host fleet visibility rides the **federation** boundary (W1), not a bespoke broker | | MVP-X6 OTEL tracing | heartbeats, sends, and lifecycle events emit spans; `traceparent` crosses the federation boundary | | MVP-X7 trunk merge | branch from `main`, squash-merge via PR, never push to `main` | @@ -55,20 +55,20 @@ The Fleet inherits — does not re-invent — the MVP's hard requirements: One **definition** is the source of truth; the **session** is how it runs. -| Layer | Owner | Phase-2 reality | Destination | -| -------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -| **Definition + identity + auth** | gateway / `mosaic-as` (scoped tokens, #541) | `roster.yaml` (tenant-tagged) | one definition; `mosaic agent --new` materializes it | -| **Tenancy boundary** | **Linux uid per tenant** (linger, own `systemd --user`, own socket, own `~/.config/mosaic`) | one tenant: `jarvis` = tenant zero | uid-per-tenant; federation aggregates across hosts | -| **Runtime** | per-tenant tmux session on isolated socket | dogfood stub sessions (live now on `mosaic-factory`) | claude/codex/pi/opencode TUIs | -| **Liveness** | **heartbeat protocol** every runtime answers | protocol defined + dogfood stub answers it | all runtimes answer; "healthy" ≠ "pane alive" | -| **Observation** | read-only `watch` (native tmux) + `pipe-pane` stream | CLI `watch`/`ps`; explicit opt-in `attach` for control | + auth-gated webUI streams | -| **Control plane** | **federation** across hosts × tenants | records already carry `tenant_id` + `host` | federated gateways expose fleet state; webUI in Phase 5 | -| **Central register** | Postgres `fleet` schema (gateway instance); access via gateway API only | _none in PoC_ (files + `roster.yaml`) | agents, missions, tasks, heartbeats, spend — single network-accessible SSOT; docs = generated projections | -| **Budget / spend governance** | **per-tenant budget policy** ingested by the orchestrator + routing layer | none today (spend is unmetered) | usage-vs-limit feedback ingested; spend auto-paced to the limit window; per-provider/per-account/concurrency/API-$ budgets enforced | +| Layer | Owner | Phase-2 reality | Destination | +| -------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| **Definition + identity + auth** | gateway / `mosaic-as` (scoped tokens, #541) | `roster.yaml` (tenant-tagged) | one definition; mosaic agent --new materializes it | +| **Tenancy boundary** | **Linux uid per tenant** (linger, own systemd --user, own socket, own ~/.config/mosaic) | one tenant: `jarvis` = tenant zero | uid-per-tenant; federation aggregates across hosts | +| **Runtime** | per-tenant tmux session on isolated socket | dogfood stub sessions (live now on `mosaic-factory`) | claude/codex/pi/opencode TUIs | +| **Liveness** | **heartbeat protocol** every runtime answers | protocol defined + dogfood stub answers it | all runtimes answer; "healthy" ≠ "pane alive" | +| **Observation** | read-only `watch` (native tmux) + `pipe-pane` stream | CLI `watch`/`ps`; explicit opt-in `attach` for control | + auth-gated webUI streams | +| **Control plane** | **federation** across hosts × tenants | records already carry `tenant_id` + `host` | federated gateways expose fleet state; webUI in Phase 5 | +| **Central register** | Postgres `fleet` schema (gateway instance); access via gateway API only | _none in PoC_ (files + `roster.yaml`) | agents, missions, tasks, heartbeats, spend — single network-accessible SSOT; docs = generated projections | +| **Budget / spend governance** | **per-tenant budget policy** ingested by the orchestrator + routing layer | none today (spend is unmetered) | usage-vs-limit feedback ingested; spend auto-paced to the limit window; per-provider/per-account/concurrency/API-$ budgets enforced | > **PoC socket hygiene:** the PoC fleet runs on the **default tmux socket** (no `-L`). > The named production-isolation socket is **`mosaic-fleet`** (matches the product brand); -> an absent roster `socket_name` means the default socket everywhere (spawn, `fleet ps`, +> an absent roster `socket_name` means the default socket everywhere (spawn, fleet ps, > onboarding cheat-sheet). The legacy dogfood canary still runs on the old `mosaic-factory` > socket pending migration. @@ -177,22 +177,22 @@ routing flow**, **concurrency** (the spend multiplier), and **hard API-token $-l are enforced at the orchestrator + routing boundary, not inside individual workers (a worker never decides its own budget — see delegation discipline). -**Budget CLI UX (#558):** `mosaic budget set --reset-at` sets the window reset; reset-datetimes +**Budget CLI UX (#558):** mosaic budget set --reset-at sets the window reset; reset-datetimes carry **confidence tags** (`user` / `provider` / `estimated` / `unknown`); and **urgency/criticality is a dispatch-gate modifier** — high-urgency work may override even-spread pacing **within authorization**. (Also feeds the budgeting workstream, not only this doc.) ## Observation model -| Verb | Behavior | -| ----------------------------------- | -------------------------------------------------------------------------------------------------- | -| `mosaic fleet ps` | one table joining systemd + tmux + process + idle + last-heartbeat, with drift + boot-enable flags | -| `mosaic agent watch ` | **read-only** join (grouped session / `-r`), no resize tyranny, no keystrokes | -| `mosaic agent attach ` | explicit interactive takeover (the only path that can type) | -| `mosaic agent send --verify` | confirms message **accepted**, not merely keystroke-injected | +| Verb | Behavior | +| --------------------------------- | -------------------------------------------------------------------------------------------------- | +| mosaic fleet ps | one table joining systemd + tmux + process + idle + last-heartbeat, with drift + boot-enable flags | +| mosaic agent watch | **read-only** join (grouped session / `-r`), no resize tyranny, no keystrokes | +| mosaic agent attach | explicit interactive takeover (the only path that can type) | +| mosaic agent send --verify | confirms message **accepted**, not merely keystroke-injected | > Why the current PoC blocks observation: sessions live on the isolated `mosaic-factory` -> socket (invisible to default `tmux ls`), the only sanctioned read is `capture-pane` +> socket (invisible to default tmux ls), the only sanctioned read is `capture-pane` > (blank for full-screen TUIs), and `attach` is read-write + resizes the session. The > verbs above restore "join and observe" safely. @@ -214,7 +214,7 @@ compromised pane cannot corrupt or exfiltrate the register. | Layer | Responsibility | Implementation | | ---------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Register** | Source of truth: agents, missions, tasks, heartbeats, spend | Postgres `fleet` schema — existing stack instance (`@mosaicstack/db`) | -| **Access** | Typed, auth-gated API | Gateway `fleet/*` routes | +| **Access** | Typed, auth-gated API | Gateway fleet/\* routes | | **Dispatcher** | Brief classification, BOD review, planning/coding/review/test/deploy sequencing + gates → fleet task dispatch | **forge pipeline engine** (`runPipeline`/`resumePipeline`, brief classifier, BOD) **+ thin `forge-exec` adapter → `agent-send.sh`**; NOT a new daemon — forge is reused, only stage→agent dispatch is new | | **Orchestrator (Mos)** | Goals, missions, judgment, user/PA interface | Context-light; sets intent → re-engages only for decisions | @@ -236,7 +236,7 @@ role implementation. `docs/TASKS.md` and `MISSION-MANIFEST.md` are **generated projections** of the DB, not hand-maintained. The dispatcher (or a scheduled job) renders Markdown from -`fleet.*` tables and commits the output. DB is authoritative; docs are for human +fleet.\* tables and commits the output. DB is authoritative; docs are for human reference. ### Spend @@ -266,13 +266,13 @@ re-evaluate if isolation or write-volume demands it. ## Phased roadmap -| Phase | Outcome | Status | -| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| 0–1 | tmux PoC, hardening, published CLI v0.0.34 (#565–#568) | ✅ done | -| **2 — Observability** | `fleet ps` (host+tenant aware join), heartbeat protocol + dogfood stub answers it, `agent watch` (read-only), `agent send --verify` receipts | ▶ now | -| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: **orchestrator + enhancer**; ephemeral workers per lane) | planned | -| 4 — Unified definition | one agent schema in gateway; `mosaic agent --new` → materialized per-tenant session; uid-tenant provisioning; **`fleet` schema migration + `forge-exec` TaskExecutor adapter (forge → `agent-send.sh`)** | planned | -| 5 — Control plane | federation-backed cross-host × cross-tenant fleet view; **webUI** (surface chosen then) for MVP-X1 parity; **central register live (spend ledger, docs-as-projections, multi-host Kanban)** | planned | +| Phase | Outcome | Status | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | +| 0–1 | tmux PoC, hardening, published CLI v0.0.34 (#565–#568) | ✅ done | +| **2 — Observability** | fleet ps (host+tenant aware join), heartbeat protocol + dogfood stub answers it, agent watch (read-only), agent send --verify receipts | ▶ now | +| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: **orchestrator + enhancer**; ephemeral workers per lane) | planned | +| 4 — Unified definition | one agent schema in gateway; mosaic agent --new → materialized per-tenant session; uid-tenant provisioning; **`fleet` schema migration + `forge-exec` TaskExecutor adapter (forge → `agent-send.sh`)** | planned | +| 5 — Control plane | federation-backed cross-host × cross-tenant fleet view; **webUI** (surface chosen then) for MVP-X1 parity; **central register live (spend ledger, docs-as-projections, multi-host Kanban)** | planned | ## Decisions of record (2026-06-20, with Jason) @@ -285,9 +285,9 @@ re-evaluate if isolation or write-volume demands it. - Delivery: **CLI-first now**, dogfood against the live stub fleet; webUI deferred to Phase 5. - Runtimes: fleet agents default to **Codex / pi-on-Codex**; **Claude is reserved for Claude Code only** (avoid alternate-harness API pricing). Validated durable recipe: - `mosaic yolo pi --model openai-codex/gpt-5.5:high`. Durable detached launch requires the + mosaic yolo pi --model openai-codex/gpt-5.5:high. Durable detached launch requires the runtime-bin on PATH (baked into the pane command) + boot-survival (`enable` + linger), - which `fleet init` should automate. + which fleet init should automate. ## Decisions of record (2026-06-22, with Jason) @@ -304,19 +304,18 @@ re-evaluate if isolation or write-volume demands it. - **Session context cap = 200k tokens (GLOBAL to all Claude sessions):** Claude Code sessions are capped at a **max 200k-token context window**. Long-running sessions extended toward 1M tokens have proven **worse in practice** (degraded steering, off-plan divergence); 200k is the standard. - **Enforcement split:** the _window_ lives in **`~/.claude/settings.json`** (host-global) as - `"autoCompactWindow": 200000` + `"autoCompactEnabled": true`; the _1M-disable_ lives in **launch + **Enforcement split:** the _window_ lives in **~/.claude/settings.json** (host-global) as + "autoCompactWindow": 200000 + "autoCompactEnabled": true; the _1M-disable_ lives in **launch ENV** (`CLAUDE_CODE_DISABLE_1M_CONTEXT=1`, plus `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000`) wherever - a `[1m]` model can be selected (`mos-claude.service` + the fleet Claude launcher), so every Claude + a [1m] model can be selected (`mos-claude.service` + the fleet Claude launcher), so every Claude agent is capped at spawn. (settings = window; env = 1M-disable.) - **Worker context bound (#8):** workers are kept context-bounded via the **ephemeral-per-lane lifecycle + native compaction**, not via the 200k knob. The explicit `autoCompactWindow` 200k knob **stays Claude-specific** — the _principle_ (bounded context) extends to workers, the _knob_ does not. - **Orchestrator delegation discipline:** the orchestrator **delegates all delivery work** to - subagents / workflows / ultracode / coder agents and confines its own context to \*\*orchestration - - the personal-assistant lane\*\*. Keeping delivery out of the orchestrator's window keeps its - context unpolluted and measurably reduces off-plan divergence. The orchestrator coordinates and - decides; it does not implement. + subagents / workflows / ultracode / coder agents and confines its own context to the personal-assistant + lane. Keeping delivery out of the orchestrator's window keeps its context unpolluted and measurably + reduces off-plan divergence. The orchestrator coordinates and decides; it does not implement. - **Budget governance is fleet doctrine:** token/API-dollar budgeting is a first-class fleet concern (see "Budget & token governance"). OAuth-sub usage-vs-limit feedback is ingested per account, spend is **auto-paced EVEN-SPREAD over remaining time** (rapid/overspend only on explicit authorization), @@ -344,7 +343,7 @@ re-evaluate if isolation or write-volume demands it. ### Control plane & central register - **Store:** Postgres (existing stack instance, dedicated `fleet` schema via `@mosaicstack/db`). SQLite rejected: (1) it is a local file — structurally incompatible with a multi-host fleet; (2) concurrent multi-agent writes caused repeated corruption in Hermes. "SQLite + access service" rejected as reinventing a DB server badly; "LLM agent gating DB access" rejected as slow, expensive, and a single point of failure. -- **Access:** gateway API only (`apps/gateway`, `fleet/*` routes). No raw DB credentials in any agent/dispatcher pane — directly mitigates the tmux attack-surface concern. +- **Access:** gateway API only (`apps/gateway`, fleet/\* routes). No raw DB credentials in any agent/dispatcher pane — directly mitigates the tmux attack-surface concern. - **Dispatcher = forge (reuse, not a new build):** the dispatcher IS `@mosaicstack/forge`'s pipeline engine (`runPipeline`/`resumePipeline` + brief classifier + BOD persona loader), a fully-implemented software-factory pipeline (brief → BOD review → 3 planning stages → coding → review/remediation → testing → deploy). We do **not** design/build a new dispatcher and do **not** re-implement sequencing, gate logic, or brief classification. The only new fleet-owned piece is a thin **`forge-exec` TaskExecutor adapter** (suggested package `packages/forge-exec`) mapping a `ForgeTask` → `agent-send.sh` dispatch to a named fleet agent — forge's single missing piece. It is tracked as a Gitea issue and built **post-PoC** (not now). - **Register backs forge:** the Postgres `fleet` register is genuinely new (neither forge nor the fleet has cross-project state). It BACKS forge's pipeline state (durable `resumePipeline`, cross-host) plus cross-project missions/tasks/Kanban. - **'board' role = forge BOD:** the north-star role-library 'board' role IS forge's Board-of-Directors — reused, not reinvented. @@ -357,9 +356,9 @@ re-evaluate if isolation or write-volume demands it. - **Per-agent model switch (operator-configurable, NOT a global lock):** model selection is **per-agent**, never a host-global pin. Claude sessions MUST NOT be locked to a single model in - `~/.claude/settings.json`; each agent chooses its model independently. The plumbing already exists — - roster `model_hint` → `MOSAIC_AGENT_MODEL` → `start-agent-session.sh` appends `--model ` to that - agent's harness (claude or pi); settable today via `mosaic fleet add|edit --model `. + ~/.claude/settings.json; each agent chooses its model independently. The plumbing already exists — + roster `model_hint` → `MOSAIC_AGENT_MODEL` → `start-agent-session.sh` appends --model to that + agent's harness (claude or pi); settable today via mosaic fleet add|edit --model . **North-star target:** surface this as a **per-agent model switch in the webUI** (with CLI/TUI parity per MVP-X1) — read the roster, expose a per-agent model dropdown, write `model_hint` back, and restart that one agent to apply. Unset = inherit the harness default. This **composes with** the budget @@ -385,7 +384,7 @@ re-evaluate if isolation or write-volume demands it. self-hosted homeserver (Conduit default, Synapse alt). Matrix is named here as the strategic future transport — peer to tmux/Discord, not superseded by them. - **tmux fleet attack-surface hardening.** Many always-on tmux sessions are an attack surface; - `tmux send-keys` / socket access could enable malicious action against agents directly. + tmux send-keys / socket access could enable malicious action against agents directly. Mitigations to build toward: socket ownership/perms, per-tenant socket isolation (already an invariant), authenticated `agent-send`, and an audit of who can write to any pane. **Post-MVP unless a P0 surfaces.** The control-plane register reinforces this (gateway-API access = no raw @@ -418,9 +417,9 @@ re-evaluate if isolation or write-volume demands it. --- -> **Release procedure (drift re-capture, 2026-06-22):** `mosaic update` only propagates new fleet +> **Release procedure (drift re-capture, 2026-06-22):** mosaic update only propagates new fleet > commands when the **CLI version is bumped** — without a version bump, fleet command changes never -> reach installed hosts. The release/version-bump procedure (bump → publish → `mosaic update` +> reach installed hosts. The release/version-bump procedure (bump → publish → mosaic update > [→ `--relaunch`]) must be documented so fleet changes actually land. (Also feeds the budgeting > workstream.) > diff --git a/docs/fleet/operations/backup-restore.md b/docs/fleet/operations/backup-restore.md new file mode 100644 index 00000000..2bfd94f2 --- /dev/null +++ b/docs/fleet/operations/backup-restore.md @@ -0,0 +1,40 @@ +# Fleet Configuration Backup and Restore Boundary + +**Issue:** #758 · **Card:** FCM-M4-001 + +This page defines evidence that must exist before a roster v1-to-v2 cutover. FCM-M4-001 lists these +prerequisites in non-executable recovery evidence but does not validate that backups exist and performs +no backup, migration, canary, or restore. FCM-M4-002 owns the executable reversible canary and rollback +gates. + +## Preserve before cutover + +- The authoritative v1 roster, byte-for-byte, with a SHA-256 identity. +- Existing per-agent legacy `.env`, strict `.env.local`, and quarantine files under private + permissions. +- Reviewed per-local-agent systemd and exact-socket tmux observations. +- The canonical v2 candidate and its SHA-256 identity. +- Inventory-only remote agents and connector configuration as evidence, not local control-plane input. + +`.env.generated` is a rebuildable projection and is not restored as authority. It must be regenerated +from the selected authoritative roster. `.env.local` is operator-owned strict data and must not be +overwritten or absorbed into generated output. Quarantined source remains private evidence; public +diagnostics expose only rule code, key name, and SHA-256. + +## Restore requirements + +A later rollback implementation must restore the authoritative roster and operator-owned environment +files, regenerate managed projections, and preserve each reviewed pre-cutover stopped/running state. +It must never start an agent observed stopped and must never reconcile an inventory-only remote or +connector entry. + +The preview evidence deliberately records: + +- executable: false; +- required backup artifacts; +- source and candidate identities; +- lifecycle observations and resulting desired states; +- environment relocation/quarantine dispositions; +- FCM-M4-002 as the executable rollback gate owner. + +Do not interpret a ready preview as a completed backup, migration, canary, or rollback. diff --git a/docs/fleet/operations/env-quarantine.md b/docs/fleet/operations/env-quarantine.md new file mode 100644 index 00000000..8d08a5c5 --- /dev/null +++ b/docs/fleet/operations/env-quarantine.md @@ -0,0 +1,20 @@ +# Environment Quarantine Operations + +Legacy .env is input evidence, never current launch authority. Projection preparation classifies it deterministically: + +- generated roster keys → discard and regenerate; +- allowed strict local keys → relocate to private `.env.local`; +- malformed, duplicate, unknown, sensitive-looking, shell-bearing, unsafe, or command-override entries → move the legacy input to private `.env.quarantine`. + +## Safe response + +1. Stop and read the stable error code and reported key name/hash. Do not request or paste the value. +2. Confirm the canonical roster contains the intended non-sensitive desired state. +3. If the key is an allowed local machine-data field, place only its validated data form in `.env.local` under private permissions. +4. Remove unsupported intent rather than translating it into commands, channels, secret references, or unknown MOSAIC*AGENT*\* keys. +5. Regenerate `.env.generated` from the roster and rerun a dry-run/verification gate. +6. Retain quarantine evidence privately until the operator's normal retention process permits removal. + +The launcher never reads quarantine. Public/JSON diagnostics expose stable code, key name where safe, and SHA-256 only—never a legacy sensitive value, credential, rejected command, or full line. Quarantine does not prove remediation, backup, migration, or rollback. + +See [generated launch chain](../concepts/generated-env-launch-chain.md), [generated environment boundary](../reference/generated-env-boundary.md), and [migration field disposition](../migration/v1-to-v2.md#field-disposition). diff --git a/docs/fleet/operations/reconcile-and-recover.md b/docs/fleet/operations/reconcile-and-recover.md new file mode 100644 index 00000000..0b0f7215 --- /dev/null +++ b/docs/fleet/operations/reconcile-and-recover.md @@ -0,0 +1,28 @@ +# Reconcile and Recover a Local Fleet + +## Safe sequence + +1. Read mosaic fleet doctor and mosaic fleet status. +2. Run mosaic fleet apply --expected-generation --dry-run. +3. Resolve stale generation, ownership mismatch, unsafe path, projection validation, or unmanaged-session findings before applying. +4. Run mosaic fleet apply --expected-generation only after the plan is understood. + +This is per-generation convergence, not a rolling canary. Executable canary cutover/rollback remains held for FCM-M4-002; rolling local release evidence remains FCM-M5-002. Do not approximate either with repeated live apply commands. + +The reconciler uses the exact roster tmux socket, exact holder session, private installation holder identity, and the complete expected global environment. For mutations it acquires its exclusive lock before rereading the canonical roster and fencing its generation; only that under-lock roster drives validation, planning, projections, and lifecycle effects. Before effects, its exclusive lock proves real private `MOSAIC_HOME` and `fleet` ancestors, uses a private `0600` lock leaf, and binds cleanup to the created file identity and ownership token. A fake holder, contaminated global environment, missing identity, unsafe lock path, or unmanaged session fails closed. It does not adopt, kill, or rename any unproven session. A crash can leave a stale lock for explicit operator inspection; reconciliation deliberately does not guess ownership or remove it. + +## Partial results + +The roster is never changed by reconciliation. If derived projection application partially fails, JSON reports: + +```json +{ + "applied": false, + "authoritativeRoster": "unchanged", + "projections": "incomplete", + "lifecycle": "not-applied", + "recovery": { "code": "projection-apply-failed", "action": "regenerate-projections-from-roster" } +} +``` + +If projections completed but lifecycle work failed, JSON reports projections: "complete", lifecycle: "incomplete", and the bounded action `rerun-after-inspecting-owned-resources`. If lock cleanup cannot be proven after an effect result, it adds cleanup: { "code": "lock-cleanup-failed", "action": "inspect-lock-before-retry" } without changing the known projection, lifecycle, or primary recovery truth. Inspect the retained lock before retrying; no rollback, release, or stale-lock removal is implied. Results do not include environment values, secrets, or privileged command content. diff --git a/docs/fleet/operations/systemd-tmux-troubleshooting.md b/docs/fleet/operations/systemd-tmux-troubleshooting.md new file mode 100644 index 00000000..988217fc --- /dev/null +++ b/docs/fleet/operations/systemd-tmux-troubleshooting.md @@ -0,0 +1,24 @@ +# Systemd and tmux Troubleshooting + +Start with read-only mosaic fleet status, `doctor`, and `verify`. Do not manually adopt, rename, terminate, or recreate sessions while ownership is ambiguous. + +## Decision table + +| Finding | Meaning | Safe next step | +| ------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Empty roster `tmux.socket_name` | Literal default tmux server | Do not substitute the named `mosaic-fleet` socket. Use roster-derived commands only. | +| Non-empty socket | Exact named socket | Never target another socket or infer a per-agent socket. | +| holder: missing | Required exact holder absent | Inspect installation/projection readiness; do not create an unproven holder manually. | +| `ownership-mismatch` | Holder identity or global environment differs | Stop. Verify private install identity and managed paths before retry. | +| `missing-session` | Desired-running roster agent lacks exact session | Check service/runtime preconditions; review apply dry-run. | +| `unexpected-session` | Desired-stopped roster agent still has exact session | Confirm ownership; only reconciler may target the exact proven roster member. | +| `disabled-running` | Disabled roster member is observed running | Inspect and reconcile only after ownership proof. | +| `unmanagedSessions` | Unknown session exists on configured named socket | Report and investigate separately. Reconciler will not kill or adopt it. | +| stale/concurrent generation | Desired state changed since plan | Reload roster/generation and recompute the plan. | +| stale or ambiguous lock | Prior writer/cleanup cannot be proven | Inspect ownership; do not blindly remove the lock. | +| projection failure | Derived files incomplete | Keep roster as authority and regenerate projections. | +| lifecycle failure | Projections complete, runtime convergence incomplete | Inspect the exact owned resource, then rerun with current generation. | + +Systemd state, tmux state, heartbeat, and generated files are observations/projections, not alternate desired state. Explicit apply/reconcile honors stopped/disabled intent, but current unit enablement and launcher projections do not yet prove lifecycle-safe reboot; inspect unit enablement before reboot and treat stopped/disabled boot preservation as an FCM-M3-002 hold. Current roster-v2 status commands also do not read heartbeat files. Executable gates do not provide site cutover/rollback or package asset-revision repair. + +Errors and troubleshooting output never print legacy sensitive values, credential contents, or privileged command text. Use stable codes, key names/hashes, exact roster identities, and bounded recovery actions. See [status and drift](../reference/status-and-drift.md) and [reconcile and recover](reconcile-and-recover.md). diff --git a/docs/fleet/operations/upgrade-assets.md b/docs/fleet/operations/upgrade-assets.md new file mode 100644 index 00000000..aeb9e848 --- /dev/null +++ b/docs/fleet/operations/upgrade-assets.md @@ -0,0 +1,18 @@ +# Upgrade and Installed-Asset Drift + +Fleet source assets and installed assets can differ after an update, but FCM-M5-001 does not add a trustworthy source-versus-installed revision detector or refresh command. Do not infer freshness from checkout presence, timestamps, generated environment files, running sessions, or a ready migration preview. + +## Current safe boundary + +- The canonical roster remains authority and must survive package/framework refresh. +- Generated projections are rebuilt from that roster after the installed contract is independently verified. +- Operator `roles.local`, `.env.local`, and private quarantine evidence are not generated assets and must not be overwritten. +- Baseline roles, schemas, examples, service presets, launcher helpers, and systemd templates must move as one reviewed release set. +- Remote/connector inventory and `mos-comms` are not promoted into permanent architecture by an update. +- No update may start an agent persisted stopped, adopt an unmanaged session, or bypass generation/ownership checks. + +## Explicit hold + +FCM-M5-002 owns deterministic asset-drift checks, safe package/update refresh evidence, rolling local canary, independent validation certificate, and release evidence. Until that card lands, this page is an operational hold rather than an executable procedure: use the repository/release review path, preserve backups, and do not claim source/installed parity without exact revision evidence from the future validator. + +See [approved deferrals](../../reports/deferred/758-fleet-config-deferrals.md) and [backup/restore boundary](backup-restore.md). diff --git a/docs/fleet/reference/agent-mutations.md b/docs/fleet/reference/agent-mutations.md new file mode 100644 index 00000000..1d59e9f5 --- /dev/null +++ b/docs/fleet/reference/agent-mutations.md @@ -0,0 +1,43 @@ +# Local Fleet Agent Mutations + +FCM-M2-002 provides local roster-v2 create, get, update, delete, and plan operations. They only change desired state and derived environment projections. They never start, stop, inspect, reconcile, or otherwise act on runtimes, systemd units, tmux sessions, or heartbeats. + +## CLI contract + +The commands operate only on the canonical /fleet/roster.yaml v2 authority and print one JSON object to stdout. `--agent` is a JSON object with the roster agent fields expressed as `className`, `toolPolicy`, `workingDirectory`, `persistentPersona`, `resetBetweenTasks`, and launch: { "yolo": boolean }. + +```fleet-synopsis +mosaic fleet get +mosaic fleet plan [] --expected-generation [--agent ''] [--persisted-start] +mosaic fleet create --expected-generation --agent '' [--dry-run] [--persisted-start] +mosaic fleet update --expected-generation --agent '' [--dry-run] +mosaic fleet delete --expected-generation [--dry-run] +``` + +`get` returns the authoritative generation and the selected agent. plan create derives its name from `--agent`; plan update and plan delete require the target name. `--agent` accepts only the documented roster-v2 request fields and `launch.yolo`; unknown keys such as commands, channels, or secret references are rejected. Rejection diagnostics return only the stable `invalid-request` code and never echo a rejected value. `plan` and `--dry-run` validate the complete proposed roster and projections but write neither the roster nor projections. `--persisted-start` is available only for a create request: it records desired_state: running, but does not start a process. Without it, create records enabled: true and desired_state: stopped. Handled failures return JSON with `error.code` and exit non-zero; unclassified validation/projection failures use the redacted `mutation-failed` code. + +## Generation, validation, and idempotency + +Each create, update, or delete request includes `expectedGeneration`. A request whose expected value differs from the authoritative roster generation fails with `stale-generation`; reload and retry with a newly computed plan. A private mutation lock rejects concurrent writers with `concurrent-mutation`. + +`planFleetAgentMutation` is deterministic and side-effect free. `executeFleetAgentMutation` validates the complete proposed roster through the existing structural and shared persona resolver, prepares generated/local/quarantine projections, and writes the roster authority atomically before applying derived projections. Equivalent create retries and delete requests for an already-absent agent are idempotent no-ops. + +Delete removes only the exact .env.generated projection for the removed roster entry. Operator-owned .env.local, legacy .env, quarantine records, and unrelated projections remain untouched. An already-absent generated projection is treated as stale derived state, not as a failed mutation. + +## Result and recovery + +Mutation results are JSON-safe objects with `applied`, `authoritativeRoster`, `projections`, `plan`, and—only if a derived projection write fails after the authoritative roster write—a recovery object. `applied` is true only when every roster and derived-projection write completed. The explicit state fields prevent a partial result from being mistaken for a rollback or a no-op: + +```json +{ + "applied": false, + "authoritativeRoster": "committed", + "projections": "incomplete", + "recovery": { + "code": "projection-apply-failed", + "action": "regenerate-projections-from-roster" + } +} +``` + +Dry-runs and idempotent no-ops report authoritativeRoster: "unchanged" and projections: "not-applied"; a complete mutation reports "committed" and "complete". Recovery output identifies the authoritative roster path and regeneration action only. It never contains generated/local/quarantine values, credentials, or command text. A recovery result exits non-zero because the authoritative roster was persisted but derived projections require regeneration. Regenerate projections from the roster before attempting another mutation. diff --git a/docs/fleet/reference/cli.md b/docs/fleet/reference/cli.md new file mode 100644 index 00000000..4d12599f --- /dev/null +++ b/docs/fleet/reference/cli.md @@ -0,0 +1,46 @@ +# Fleet Control-Plane CLI + +The local desired-state surface is mosaic fleet. It is distinct from the gateway-backed mosaic agent catalog and from legacy compatibility commands that act on roster v1. + +## Roster-v2 desired-state commands + +| Command | Effect | Generation | Output | +| ---------------------------------------------------------- | ---------------------------------------------- | ---------- | -------------------------- | +| mosaic fleet get | Read one authoritative agent | no | One JSON object | +| mosaic fleet plan ... | Validate proposed CRUD and projections | required | One JSON object; no writes | +| mosaic fleet create ... [--dry-run] [--persisted-start] | Add desired state; default enabled/stopped | required | One JSON object | +| mosaic fleet update ... [--dry-run] | Replace mutable agent fields | required | One JSON object | +| mosaic fleet delete ... [--dry-run] | Remove roster member/generated projection | required | One JSON object | +| mosaic fleet apply ... [--dry-run] | Plan or converge projections/lifecycle | required | One JSON object | +| mosaic fleet reconcile ... [--dry-run] | Alias of the same convergence contract | required | One JSON object | +| mosaic fleet start\|stop\|restart [] ... [--dry-run] | Exact one-shot lifecycle action | required | One JSON object | +| mosaic fleet status [] | Observe desired/managed/runtime state | no | One JSON object | +| mosaic fleet verify | Strict observational drift/ownership gate | no | One JSON object | +| mosaic fleet doctor | Classify local drift and recovery context | no | One JSON object | +| mosaic fleet migrate-v1 preview ... | Non-mutating field-complete migration evidence | no | One JSON object | + +CRUD syntax and full payload shape are documented in [agent mutations](agent-mutations.md). Reconciliation syntax: + +```fleet-synopsis +mosaic fleet apply --expected-generation +mosaic fleet reconcile --expected-generation +mosaic fleet start [] --expected-generation [--dry-run] +mosaic fleet stop [] --expected-generation [--dry-run] +mosaic fleet restart [] --expected-generation [--dry-run] +mosaic fleet status [] +mosaic fleet verify +mosaic fleet doctor +mosaic fleet migrate-v1 preview --source --decisions --observations +``` + +`get` is the read/show operation for one v2 agent. Full roster parsing and semantic validation occur on every v2 mutation/reconcile path; there is no separate mutable “config store.” The executable JSON Schema and validated example provide offline structural evidence. The PRD requires an explicit programmatic mosaic fleet validate operation, but the current CLI does not expose one; do not substitute another command or claim that requirement is delivered. This remains an implementation gap for #758. + +## JSON and exit behavior + +Roster-v2 CRUD and reconciler precondition failures emit { "error": { "code": "..." } } and exit non-zero. Migration preview has its own result envelope: a non-ready preview emits { "status": "blocked", "blockers": [...] } and exits non-zero rather than using the CRUD/reconciler error object. Use both exit status and command-specific state fields. A partial reconciliation result distinguishes `authoritativeRoster`, `projections`, `lifecycle`, `recovery`, and optional `cleanup`; it never claims automatic rollback. `verify` exits non-zero for drift, ownership failure, or unmanaged sessions. Sensitive legacy values, credentials, and rejected command text are never printed. + +## Compatibility and scope + +Roster-v1 initialization, provisioning, profiles/personas, and historical fleet add/remove remain compatibility surfaces, not roster-v2 CRUD aliases. New v2 automation should use the table above. migrate-v1 preview writes nothing and has no cutover, canary, or rollback option. + +mosaic agent is a separate catalog/transport surface; it does not own /fleet/roster.yaml desired state. Remote/SSH reconciliation, connector mutation, arbitrary commands/channels, secret references, and gateway convergence are rejected or outside scope. diff --git a/docs/fleet/reference/generated-env-boundary.md b/docs/fleet/reference/generated-env-boundary.md new file mode 100644 index 00000000..5894da5e --- /dev/null +++ b/docs/fleet/reference/generated-env-boundary.md @@ -0,0 +1,96 @@ +# Fleet Generated Environment Boundary + +**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** merged contract + +The local fleet roster is the desired-state authority. A launch reads a deterministic, +roster-derived generated projection and an optional strictly data-only local file; neither file is +a second roster or a command configuration surface. + +## Paths and ownership + +For agent under /fleet/agents/: + +| Path | Owner | Purpose | +| --------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| .env.generated | Mosaic projection writer | Complete deterministic launch data rendered from the authoritative roster. | +| .env.local | Operator | Optional, constrained local machine data. It cannot shadow generated keys. | +| .env | Legacy input only | Read once during projection generation, then regenerated/relocated or privately quarantined. It is never a launch authority. | +| .env.quarantine | Mosaic quarantine | Mode-`0600` private record of forbidden legacy input; it is never read by the launcher. | + +The systemd templates do not load either environment file. They invoke Bash with a fixed, cleared +bootstrap environment; the launcher reads and validates `.env.generated` and `.env.local` itself before +it queries, creates, or stops an exact tmux session. It does not `source`, `eval`, or execute an +environment-supplied command. Exact stop derives its socket from the same validated generated projection, +not from systemd or ambient environment data. + +All projection, local, and quarantine files must be regular files with no group or world permissions. +The agent environment directory must also be a real, non-symlink private directory; it is validated +before either environment file is read or tmux is queried. Unsafe paths, symlinks, or permissions fail +closed. Diagnostics identify only a rule code, key name, and SHA-256 content hash; they never print +values, credential material, or command text. + +## Allowed data + +`.env.generated` is complete and ordered exactly as follows: + +```dotenv +MOSAIC_AGENT_NAME= +MOSAIC_AGENT_CLASS= +MOSAIC_AGENT_RUNTIME= +MOSAIC_AGENT_MODEL= +MOSAIC_AGENT_REASONING= +MOSAIC_AGENT_TOOL_POLICY= +MOSAIC_AGENT_WORKDIR= +MOSAIC_TMUX_SOCKET= +``` + +The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add +uses that same runtime authority and rejects any other runtime before it writes the roster or changes +projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory` +socket remains an observability canary; it has no generated-launch adapter and cannot be added through +this projection path. + +`.env.local` may contain only these non-secret data keys: + +- `MOSAIC_RUNTIME_BIN` +- `MOSAIC_HEARTBEAT_RUN_DIR` +- `MOSAIC_HEARTBEAT_INTERVAL` +- `MOSAIC_CLAUDE_JSON` +- `CLAUDE_CONFIG_DIR` + +Local paths must be safe absolute paths and the interval must be a positive integer. Comments, +quoted/export syntax, duplicate keys, unknown keys, generated-key shadowing, sensitive key names, +and `MOSAIC_AGENT_COMMAND` are rejected. The launcher derives the only executable command from the +validated runtime, model, and reasoning data; no arbitrary command compatibility path exists. When a +Pi runtime writes a fresh .hb.native marker, its native heartbeat remains authoritative; the +shell sidecar resumes its `status=ok` fallback only after that marker is stale or absent. + +## Legacy disposition + +During projection generation, legacy roster-derived keys are regenerated from the roster. A valid +allowed local value is relocated to `.env.local`; forbidden, malformed, duplicate, sensitive, and +unknown legacy entries cause the legacy file to be moved to `.env.quarantine` and are represented by +sanitized diagnostics. This is deterministic and idempotent after the legacy file has been consumed. + +## USC interface packet + +This card does not add a USC site file, write a USC roster, or run a site canary. The following is the +consolidated downstream interface packet. Status is deliberately separated from checkout presence: no +product release version has been evidenced for this interface set. + +| Interface | Canonical public path and version | Tracker/release status | Downstream limit | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| M1 structural compiler | `parseRosterV2` in `packages/mosaic/src/fleet/roster-v2.ts`; schema `docs/fleet/reference/roster-v2.schema.json`; roster version: 2 | FCM-M1-001 is recorded done, merged as #764 (`aa5b43b`); no released product version is asserted here. | Parse YAML/JSON and canonicalize a supplied v2 site roster without writes. | +| M1 semantic resolver | `validateRosterV2Semantics` in `packages/mosaic/src/fleet/roster-v2.ts`; baseline `framework/fleet/roles/` plus `roles.local/` | FCM-M1-002 merged as #768 (`a5e8e55`); no released product version is asserted here. | Reuse the shared resolver only; no parallel role resolver or lifecycle action. | +| M1 disposition evidence | `packages/mosaic/src/fleet/example-profile-dispositions.ts`; `docs/fleet/migration/example-profile-disposition.md`; retained fixture version: 1 | FCM-M1-003 merged as #770 (`e9c4aa3`); checkout evidence remains validation, not migration authorization. | Inspect fixture/profile/service disposition evidence only; it is not migration authorization. | +| M2 generated boundary | `packages/mosaic/src/fleet/generated-env-boundary.ts`; generated projection contract in this document | FCM-M2-001 merged as #772 (`191efae`); no released product version is asserted here. | Render/write a roster-derived projection; local input is never authority. | + +The canonical source remains /fleet/roster.yaml for the current local fleet path. +Generated environment data is a rebuildable projection, not an operator-editable source of membership, +runtime policy, or lifecycle state. + +**Corrected downstream gates:** M2 supplies only parse/validation/projection evidence and does not +permit a USC site canary, reconciliation, or lifecycle mutation. M3 must first define and validate the +canonical local reconcile/lifecycle path. M4 then supplies preview/migration and its separate +canary/rollback gates; only after those M3 and M4 gates may a site migration or canary be considered. +This card authorizes none of those actions. diff --git a/docs/fleet/reference/lifecycle-transitions.md b/docs/fleet/reference/lifecycle-transitions.md new file mode 100644 index 00000000..b5eb36e1 --- /dev/null +++ b/docs/fleet/reference/lifecycle-transitions.md @@ -0,0 +1,21 @@ +# Local Fleet Lifecycle Transitions + +Roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` are the only persisted lifecycle authority. Systemd, tmux, generated environment, and heartbeat state are derived or observed. + +| Event | Desired-state write | Runtime effect | Safety boundary | +| ------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| fleet create | Adds enabled/stopped by default; `--persisted-start` records running | None | Generation-guarded; validates full roster/projections. | +| fleet update | Preserves the existing enabled/desired state; updates other mutable fields | None | Generation-guarded; stable name and lifecycle are immutable on this path. | +| fleet delete | Removes exact roster member | None | Removes only generated projection; retains local/quarantine evidence. | +| fleet apply / `reconcile` | Never | Rebuilds projections; starts only enabled/running; stops disabled or stopped roster members | Current generation, private lock/paths, semantic validity, holder ownership, no unmanaged named-socket sessions. | +| fleet start | Never | One-shot exact service start | Exact enabled roster name and proven ownership. | +| fleet stop | Never | One-shot exact service stop | Exact roster name and proven ownership. | +| fleet restart | Never | One-shot exact service restart | Exact enabled roster name and proven ownership. | +| Reboot/service activation | Never | Current installation may activate enabled units without honoring roster lifecycle | **Held for FCM-M3-002:** boot preservation for stopped/disabled agents is not yet proven; inspect/disable units rather than assuming lifecycle-safe reboot. | +| v1 migration preview | Never | None | Observed active+present maps running; inactive+missing maps stopped; ambiguity blocks. | +| Cutover/canary | Held for FCM-M4-002 | Not implemented by preview | Must preserve every observed stopped state. | +| Rollback | Held for FCM-M4-002 | Not implemented | Must restore selected authority/projections without surprise starts or unmanaged targeting. | + +Explicit apply/reconcile never starts a stopped roster agent. Direct lifecycle commands are explicit one-shot actions and do not persist intent. The current update operation preserves `existing.lifecycle`; there is no delivered generation-guarded CRUD operation for changing durable lifecycle after creation. Reboot preservation for stopped/disabled agents is not yet guaranteed because current enabled units and launcher projections do not carry the persisted lifecycle fence; that acceptance evidence remains FCM-M3-002. + +Missing/stale generation, concurrent writer, unsafe path, ownership mismatch, unmanaged session, unsupported runtime, invalid projection, and lifecycle precondition failures return stable redacted JSON and non-zero status. No command targets fuzzy names, arbitrary sockets/commands/channels/secrets, or generated files as authority. Legacy sensitive values are never printed. diff --git a/docs/fleet/reference/role-classes.md b/docs/fleet/reference/role-classes.md new file mode 100644 index 00000000..dd146e78 --- /dev/null +++ b/docs/fleet/reference/role-classes.md @@ -0,0 +1,45 @@ +# Fleet Role Classes and Authority + +A fleet role class is a machine identity resolved from the persona library. Resolution uses the +canonical class before consulting the baseline `fleet/roles/` and operator `fleet/roles.local/` +layers. A readable role contract is required; an index entry alone is not semantic success. + +## Canonicalization + +Only these legacy class aliases are recognized: + +| Requested class | Canonical class | +| ---------------------- | --------------- | +| `implementer` | `code` | +| `reviewer` | `review` | +| `operator-interaction` | `interaction` | + +No other alias is inferred. In particular, `worker`, `analyst`, and `canary` are custom classes only +when an operator supplies a readable contract for that exact class. Tess and Ultron are instance +names, not classes. agents[].alias is display-only and cannot grant authority. + +Canonicalization happens before role lookup. For example, requesting `implementer` resolves +`code.md`; a separate `roles.local/implementer.md` cannot redefine the legacy alias. A canonical +`roles.local/code.md` still overrides the baseline `roles/code.md` contract. + +## Protected authority + +Protected authority is immutable metadata derived only from canonical class. Role prose, instance +name, display alias, tool policy, runtime, and custom role files cannot grant it. + +| Canonical class | Granted authority | Explicit limits | +| ----------------- | -------------------------------------------------- | --------------------------------------------------------------------------------- | +| `merge-gate` | Sole approve-to-land and merge authority | No authority is inferred by similarly named custom roles or policies. | +| `validator` | May issue a validation certificate | Cannot approve-to-land or merge. | +| `orchestrator` | May orchestrate, manage topology, and issue leases | Cannot approve-to-land or merge. | +| `team-leader` | May use orchestrator-leased capacity | Cannot issue leases or mutate roster, configuration, credentials, or merge state. | +| `interaction` | Request and status surface | Cannot orchestrate, issue leases, mutate roster/configuration, or merge. | +| all other classes | No protected authority implicitly | Custom contracts do not acquire protected powers from prose. | + +Roster-v2 semantic validation requires a protected class and its canonical tool policy to match. It +also rejects an unprotected class paired with a protected tool policy. The legacy tool-policy name +`operator-interaction` canonicalizes to `interaction`. + +This mapping describes authority metadata only. Lease issuance, validation-certificate storage or +workflow, lifecycle reconciliation, credentials, roster mutation, and merge execution are outside +this resolver contract. diff --git a/docs/fleet/reference/roster-v2-fields.md b/docs/fleet/reference/roster-v2-fields.md new file mode 100644 index 00000000..613be1de --- /dev/null +++ b/docs/fleet/reference/roster-v2-fields.md @@ -0,0 +1,126 @@ +# Fleet Roster v2 Structural Contract + +**Status:** FCM-M1-001 local-tmux structural compiler contract. This document describes parsing, +strict structural validation, normalized in-memory representation, and deterministic rendering only. +It does not authorize role resolution, lifecycle reconciliation, mutation, migration, remote +placement, connector configuration, secret references, arbitrary commands, channels, gateway +mapping, or any live-fleet change. + +The executable schema is [`roster-v2.schema.json`](./roster-v2.schema.json). The compiler exports +the same schema and its test parses this file and compares it structurally with the executable contract. + +## Format and canonical shape + +The compiler accepts YAML or JSON. It reads only snake_case source fields and renders canonical, +snake_case YAML. Rendering sorts runtime keys and agents by stable name. Agent names, class names, +and tool-policy names are structural identifiers; whether a class or policy resolves is a later +shared-resolver concern. + +```yaml +version: 2 +generation: 1 +transport: tmux +tmux: + socket_name: mosaic-fleet + holder_session: _holder +defaults: + working_directory: ~/src + runtime: pi +runtimes: + pi: + reset_command: /new +agents: + - name: coder0 + alias: Coder 0 + class: code + runtime: pi + provider: openai + model: gpt-5.6-sol + reasoning: high + tool_policy: code + working_directory: ~/src + persistent_persona: false + reset_between_tasks: true + lifecycle: + enabled: true + desired_state: stopped + launch: + yolo: true +``` + +## Root fields + +| Field | Required | Default | Constraint | Meaning | +| ------------ | -------- | ------- | --------------------- | ------------------------------------------------------------------------------------------------- | +| `version` | yes | none | integer constant `2` | Identifies this contract. Version `1` stays on the compatibility path pending explicit migration. | +| `generation` | yes | none | positive safe integer | Desired-state generation and mutation/reconcile concurrency fence. | +| `transport` | yes | none | constant `tmux` | M1–M5 support local tmux only. | +| `tmux` | yes | none | strict object | Explicit local socket and holder-session configuration. | +| `defaults` | yes | none | strict object | Default work directory and one supported local runtime. | +| `runtimes` | yes | none | non-empty object | Declared local runtime reset policy map. | +| `agents` | yes | none | non-empty array | Local fleet entries. Duplicate stable names are rejected. | + +## Nested fields + +All nested fields in the v2 schema are required and have no implicit default. CRUD `create` is the only higher-level convenience: it records lifecycle.enabled: true and desired_state: stopped unless `--persisted-start` explicitly records running. That convenience still performs no runtime action. + +| Path | Required | Constraint | +| -------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------ | +| `tmux.socket_name` | yes | [A-Za-z0-9_.-]\*; empty string means the literal default tmux server, while a non-empty value names a socket | +| `tmux.holder_session` | yes | non-empty [A-Za-z0-9_.-]+ | +| `defaults.working_directory` | yes | non-empty string | +| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` | +| runtimes..reset_command | yes | non-empty string; runtime key must be a supported local runtime | +| agents[].name | yes | unique [A-Za-z0-9][A-Za-z0-9_.-]\* stable machine identity | +| agents[].alias | yes | non-empty display string | +| agents[].class | yes | [a-z][a-z0-9-]\*; structural only in M1, semantic role resolution is FCM-M1-002 | +| agents[].runtime | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` | +| agents[].provider, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card | +| agents[].reasoning | yes | `low`, `medium`, or `high` | +| agents[].tool_policy | yes | [a-z][a-z0-9-]\*; structural only in M1 | +| agents[].persistent_persona, `reset_between_tasks` | yes | booleans | +| agents[].lifecycle.enabled | yes | boolean; stored now, reconciled in FCM-M3-001 | +| agents[].lifecycle.desired_state | yes | `running` or `stopped` | +| agents[].launch.yolo | yes | boolean; structured data only, not an arbitrary command escape hatch | + +## Semantic handoff + +`parseRosterV2` and `normalizeRosterV2` remain synchronous and structural. After structural success, +call the asynchronous `validateRosterV2Semantics` handoff before using persona identity or authority. +That validator batches the baseline `fleet/roles/` and operator `fleet/roles.local/` scans, then +delegates every agent to the shared persona resolver. + +Semantic validation: + +- requires the winning role contract to be readable and non-empty; `LIBRARY.md` membership alone does + not resolve a class; +- retains `requestedClass` separately from `canonicalClass` in typed output; +- canonicalizes only `implementer` to `code`, `reviewer` to `review`, and + `operator-interaction` to `interaction`; +- canonicalizes `tool_policy` with the same exact alias table; +- rejects protected class/tool-policy mismatches in either direction, while accepting + class: operator-interaction with tool_policy: operator-interaction as canonical + `interaction`; +- derives immutable protected authority only from canonical class; and +- accepts custom baseline or `roles.local` classes without granting protected authority. + +agents[].alias remains display-only. Tess and Ultron are instance names, never semantic classes. +Canonicalization happens before role-layer lookup, so a legacy-named override cannot redefine an +alias as separate authority. See [Role Classes and Authority](./role-classes.md) and +[Customize Fleet Roles](../how-to/customize-roles.md). + +This handoff performs no filesystem, systemd, tmux, roster, credential, lease, certificate, or +lifecycle mutation. + +## Fail-closed boundary + +Every object is additionalProperties: false. The compiler rejects unknown, missing, malformed, +and wrong-type fields before producing a model. It specifically rejects remote/SSH/host/socket +per-agent fields, connector blocks, secret references, channel fields, arbitrary command fields, +and gateway fields because they are unsupported in the local-tmux M1 contract. It does not silently +ignore v1 camelCase input, version `1`, or a source that does not parse to an object. + +The v2 compiler is intentionally isolated from the existing v1 loader. Existing v1 rosters and +current examples/profiles continue on their current path; FCM-M4 owns explicit inventory, preview, +migration, and rollback. FCM-M2 owns generated-file/local-override quarantine, and FCM-M3 owns +runtime lifecycle and reconciliation. diff --git a/docs/fleet/reference/roster-v2.schema.json b/docs/fleet/reference/roster-v2.schema.json new file mode 100644 index 00000000..0e5deaa2 --- /dev/null +++ b/docs/fleet/reference/roster-v2.schema.json @@ -0,0 +1,156 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mosaicstack.dev/schemas/fleet/roster-v2.schema.json", + "title": "Mosaic local tmux fleet roster v2", + "type": "object", + "additionalProperties": false, + "required": ["version", "generation", "transport", "tmux", "defaults", "runtimes", "agents"], + "properties": { + "version": { + "const": 2 + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "transport": { + "const": "tmux" + }, + "tmux": { + "type": "object", + "additionalProperties": false, + "required": ["socket_name", "holder_session"], + "properties": { + "socket_name": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]*$" + }, + "holder_session": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+$" + } + } + }, + "defaults": { + "type": "object", + "additionalProperties": false, + "required": ["working_directory", "runtime"], + "properties": { + "working_directory": { + "type": "string", + "minLength": 1 + }, + "runtime": { + "enum": ["claude", "codex", "opencode", "pi"] + } + } + }, + "runtimes": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "enum": ["claude", "codex", "opencode", "pi"] + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["reset_command"], + "properties": { + "reset_command": { + "type": "string", + "minLength": 1 + } + } + } + }, + "agents": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "alias", + "class", + "runtime", + "provider", + "model", + "reasoning", + "tool_policy", + "working_directory", + "persistent_persona", + "reset_between_tasks", + "lifecycle", + "launch" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*$" + }, + "alias": { + "type": "string", + "minLength": 1 + }, + "class": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "runtime": { + "enum": ["claude", "codex", "opencode", "pi"] + }, + "provider": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "reasoning": { + "enum": ["low", "medium", "high"] + }, + "tool_policy": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "working_directory": { + "type": "string", + "minLength": 1 + }, + "persistent_persona": { + "type": "boolean" + }, + "reset_between_tasks": { + "type": "boolean" + }, + "lifecycle": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "desired_state"], + "properties": { + "enabled": { + "type": "boolean" + }, + "desired_state": { + "enum": ["running", "stopped"] + } + } + }, + "launch": { + "type": "object", + "additionalProperties": false, + "required": ["yolo"], + "properties": { + "yolo": { + "type": "boolean" + } + } + } + } + } + } + } +} diff --git a/docs/fleet/reference/status-and-drift.md b/docs/fleet/reference/status-and-drift.md new file mode 100644 index 00000000..dcb3d99f --- /dev/null +++ b/docs/fleet/reference/status-and-drift.md @@ -0,0 +1,25 @@ +# Local Fleet Status and Drift + +mosaic fleet status [], `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, mutate desired state, operate lifecycle, or change tmux. + +## State dimensions + +- **Desired:** roster membership, generation, enabled flag, and persisted running/stopped target. +- **Managed/derived:** generated environment and expected exact service/session topology. +- **Observed by current roster-v2 commands:** systemd active state, tmux presence, exact holder ownership, and unmanaged sessions. + +Implemented drift classifications include: + +- `missing-session`: enabled/desired-running agent lacks its exact session; +- `unexpected-session`: desired-stopped agent has its exact session; +- `disabled-running`: disabled roster agent has its exact session; +- `unmanagedSessions`: named-socket sessions that are neither exact holder nor roster agent; +- `holder`: `owned`, `missing`, or `ownership-mismatch` after private identity and global environment checks. + +Generated projection failures/staleness are surfaced by plan/apply preparation and bounded recovery fields rather than adopted as configuration. Heartbeat remains wider-fleet observational evidence, never desired state, but the current roster-v2 `status`, `doctor`, and `verify` commands do not read heartbeat files. A provable removed-agent projection may be treated as stale derived state during deletion, but general projection-orphan classification and installed source-versus-asset revision mismatch remain FCM-M4-002/M5-002 holds; current commands must not claim those future checks. + +## Command behavior + +`status` and `doctor` classify rather than adopt, destroy, or repair. `verify` is observational too, but exits non-zero if ownership cannot be proven, unmanaged sessions exist, or drift is present. Reconciliation fails closed under those conditions and never kills or adopts an unmanaged session. + +Doctor/error output uses stable codes and bounded recovery context. Migration, quarantine, lifecycle, status, and troubleshooting output never prints a legacy sensitive value, credential, or privileged command text. diff --git a/docs/guides/admin-guide.md b/docs/guides/admin-guide.md index 4f7c6a1e..e08cfd8a 100644 --- a/docs/guides/admin-guide.md +++ b/docs/guides/admin-guide.md @@ -223,10 +223,10 @@ external clients. Authentication requires a valid BetterAuth session (cookie or ### Required -| Variable | Description | -| -------------------- | ----------------------------------------------------------------------------------------- | -| `BETTER_AUTH_SECRET` | Secret key for BetterAuth session signing. Must be set or gateway will not start. | -| `DATABASE_URL` | PostgreSQL connection string. Default: `postgresql://mosaic:mosaic@localhost:5433/mosaic` | +| Variable | Description | +| -------------------- | ----------------------------------------------------------------------------------------------------------- | +| `BETTER_AUTH_SECRET` | Secret key for BetterAuth session signing. Must be set or gateway will not start. | +| `DATABASE_URL` | Runtime-only PostgreSQL connection injected from the dedicated deployment secret; no default or inline DSN. | ### Gateway @@ -293,13 +293,68 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe ### Plugins -| Variable | Description | -| ---------------------- | -------------------------------------------------------------------------- | -| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | -| `DISCORD_GUILD_ID` | Discord guild/server ID | -| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) | -| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) | -| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call | +| Variable | Description | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | +| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only | +| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata | +| `DISCORD_GUILD_ID` | Discord guild/server ID | +| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) | +| `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny | +| `DISCORD_ALLOWED_CHANNEL_IDS` | Required comma-separated Discord channel snowflake allowlist; default-deny | +| `DISCORD_ALLOWED_USER_IDS` | Required comma-separated Discord user snowflake allowlist; default-deny | +| `DISCORD_INTERACTION_BINDINGS` | Required JSON bindings from guild/channel to logical agent and paired Discord users with `viewer`, `operator`, or `admin` roles | +| `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Optional positive integer; authorized turns per guild/channel/user each minute (default: `30`) | +| `DISCORD_THREAD_RATE_LIMIT_PER_MINUTE` | Optional positive integer; mention-thread routes per guild/channel/user each minute (default: `5`) | +| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) | +| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call | + +### Discord ingress security + +When `DISCORD_BOT_TOKEN` is configured, `DISCORD_SERVICE_TOKEN`, `DISCORD_SERVICE_USER_ID`, and all three Discord allowlists are required. Gateway startup fails rather than enabling a broad or unauthenticated remote-control surface. The service user ID identifies a provisioned Mosaic service principal for persistence; the original Discord user ID is retained in ingress audit metadata. The service token is a secret supplied by the approved runtime secret mechanism and is never committed or logged. + +Inbound Discord messages must originate from an allowed guild and configured parent channel, come from an allowed and paired user whose role permits sending, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. Attachment references are limited in count, metadata size, field length, and declared size; only query-free HTTPS URLs without credentials or fragments are accepted, so bearer or presigned URLs never reach persistence or an agent prompt. The gateway validates the service identity, envelope signature, allowlists, pairing, and role again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state. + +Configured channels are dedicated agent interaction surfaces. An authorized untagged message routes to the bound logical agent and the response returns in that channel. Mentioning the bot on a normal channel message creates a public Discord thread, or reuses the thread already attached to that same message; the response and later thread messages stay in that thread without repeated mentions. A normal channel's category is not an authorization parent—only a Discord thread inherits authorization from its configured parent channel. Runtime control commands such as `/approve` and `/stop ` remain on the current channel/thread because they target that durable session rather than opening a new topic. + +Authorization and per-user/channel rate limits are evaluated before thread creation, so an unlisted guild/channel/user, unpaired user, `viewer`, or rate-limited sender cannot create bot threads or dispatch gateway work. The bot needs Discord permissions to view/send in configured channels and create/send in public threads. If thread creation fails, the turn is not dispatched because the requested response destination cannot be honored. + +Conversation handles use the configured logical agent plus Discord channel/thread identity. They do not contain a Claude, Codex, Pi, OpenCode, model, process, or runtime-provider identifier; changing the runtime behind the logical session therefore does not require reconnecting the Discord bot. + +#### Interaction binding format + +`DISCORD_INTERACTION_BINDINGS` must be a non-empty JSON array. Each item requires `instanceId`, trusted `agentConfigId`, `guildId`, `channelId`, and a non-empty `pairedUsers` object. `instanceId` is the configured logical-agent name; its trusted database `agentConfigId` must resolve to an agent configuration with exactly that name, preserving provider/model/prompt/tool selection per binding. The guild/channel must also appear in their corresponding allowlists. IDs below are placeholders: + +```json +[ + { + "instanceId": "interaction-agent", + "agentConfigId": "agent-config-id", + "guildId": "guild-id", + "channelId": "channel-id", + "pairedUsers": { + "discord-user-id": { + "role": "operator", + "mosaicUserId": "mosaic-user-id" + } + } + } +] +``` + +| Pairing role | Send message | Create/continue thread | Approve | Stop | +| ------------ | ------------ | ---------------------- | ------- | ---- | +| `viewer` | No | No | No | No | +| `operator` | Yes | Yes | No | No | +| `admin` | Yes | Yes | Yes | Yes | + +A legacy role-only value such as `"discord-user-id": "operator"` remains valid for non-privileged ingress. Approval and stop require the object form with a provisioned `mosaicUserId`; gateway policy checks that Mosaic identity and consumes one exact-action approval once. Do not make the Discord service principal an approving administrator. + +After changing bindings or allowlists, restart the gateway/plugin through the normal service manager and verify both Discord and gateway connectivity. The adapter reports `connected` only when both links are ready, `degraded` when one is ready, and `disconnected` when neither is ready. Test one authorized untagged channel turn, one mention-created thread, one thread follow-up, and one unauthorized user denial without using production credential values in logs or evidence. + +### Session retention and garbage collection + +Session cleanup is scoped to one session identifier and only removes that session's Valkey keys and demotes that session's hot logs. Gateway startup and scheduled jobs do not perform global session cleanup; startup removes legacy repeatable `session-gc` schedules created by older deployments. The `/gc` command is intentionally disabled until a distinct global-retention job supplies explicit authorization and audit evidence. This prevents one tenant or session's cleanup from changing another's retained data. ### Observability diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index 1ea0f6c2..12b45d16 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -1,384 +1,67 @@ # Deployment Guide -This guide covers deploying Mosaic in two modes: **Docker Compose** (recommended for quick setup) and **bare-metal** (production, full control). +> **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. ---- +## Current safe local route -## Prerequisites - -| Dependency | Minimum version | Notes | -| ---------------- | --------------- | ---------------------------------------------- | -| Node.js | 22 LTS | Required for ESM + `--experimental-vm-modules` | -| pnpm | 9 | `npm install -g pnpm` | -| PostgreSQL | 17 | Must have the `pgvector` extension | -| Valkey | 8 | Redis-compatible; Redis 7+ also works | -| Docker + Compose | v2 | For the Docker Compose path only | - ---- - -## Docker Compose Deployment (Quick Start) - -The `docker-compose.yml` at the repository root starts PostgreSQL 17 (with pgvector), Valkey 8, an OpenTelemetry Collector, and Jaeger. - -### 1. Clone and configure +Use PGlite only for current in-process data-layer work; it requires no PostgreSQL. A Gateway/Web +local process is held because its unguarded dotenv loader can inherit a daemon PostgreSQL DSN and +reach runtime DDL. If a local queue service is useful, start only Valkey: ```bash -git clone mosaic -cd mosaic -cp .env.example .env +docker compose up -d valkey ``` -Edit `.env`. The minimum required change is: +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 route, or +start Gateway/Web until KBN-101-02 supplies fail-closed local-tier/DSN isolation. -```dotenv -BETTER_AUTH_SECRET= -``` +## Held future procedure -### 2. Start infrastructure services +PostgreSQL local, federated, Compose, and bare-metal production activation are held until these +artifacts land and pass their independent gates: -```bash -docker compose up -d -``` +1. **KBN-101-00** external privileged bootstrap artifact; +2. **KBN-101-03** sole `mosaic-db-migrator` runner and verified-readiness artifact; and +3. **KBN-101-05** Vault/secret-renderer-backed deployment and consumer-isolation artifact. -Services and their ports: +The required future order is external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. -| Service | Default port | -| --------------------- | ------------------------ | -| PostgreSQL | `localhost:5433` | -| Valkey | `localhost:6380` | -| OTEL Collector (HTTP) | `localhost:4318` | -| OTEL Collector (gRPC) | `localhost:4317` | -| Jaeger UI | `http://localhost:16686` | +This is a held, non-operative future activation specification with no current command authority. Do not invoke the named +runner, start PostgreSQL, or substitute a Compose/init/manual-SQL route until the owned artifacts +are implemented and reviewed. -Override host ports via `PG_HOST_PORT` and `VALKEY_HOST_PORT` in `.env` if the defaults conflict. +## Future production secret and unit boundary (schematic only) -### 3. Install dependencies +No current bare-metal production unit or command is published. KBN-101-05 must supply a reviewed, +generation-pinned Vault renderer and a process-exec or systemd `LoadCredential` interface before +production units can exist. The interface must preserve these exact consumer boundaries: -```bash -pnpm install -``` +| Consumer | May receive | Must never receive | +| ----------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Gateway/runtime | Its own runtime URL and DB client CA at process exec | Migrator URL, importer URL/version, attestation material, signing key, PostgreSQL private key | +| One-shot migrator | Its own migration URL, DB client CA, and runner-only signing capability | Runtime URL, importer consumer copy, Gateway/private PostgreSQL keys | +| Data importer | Its own immutable URL/version copies, importer CA, pinned public key, and sealed attestation | Runtime/migrator URLs, signing key, shared writable mount | +| PostgreSQL | Its own server certificate/key and only its approved server material | Application, migrator, importer, or Gateway secrets | -### 4. Initialize the database +A future unit specification is non-executable until KBN-101-05 supplies it. It must obtain +credentials through the renderer’s Vault generation and process-exec/`LoadCredential` boundary; +it must not place credentials in a production environment file, a monorepo auto-load path, a shell +export, command arguments, logs, or a manual secret-activation lifecycle instruction. Rotation and +process replacement semantics must be delivered by the reviewed renderer/interface with generation, +consumer-isolation, mode/owner, and no-mixed-generation evidence—not improvised in this guide. -```bash -pnpm --filter @mosaicstack/db db:migrate -``` +## Readiness and troubleshooting status -### 5. Build all packages +Until the future procedure is implemented, do not diagnose PostgreSQL with ad hoc SQL, connection +strings, or initialization scripts. The future sanitized runner-verification readiness artifact is +the required PostgreSQL readiness authority after its bootstrap/TLS prerequisites pass. +For local PGlite development, diagnose application behavior without introducing a PostgreSQL +connection. -```bash -pnpm build -``` - -### 6. Start the gateway - -```bash -pnpm --filter @mosaicstack/gateway dev -``` - -Or for production (after build): - -```bash -node apps/gateway/dist/main.js -``` - -### 7. Start the web app - -```bash -# Development -pnpm --filter @mosaicstack/web dev - -# Production (after build) -pnpm --filter @mosaicstack/web start -``` - -The web app runs on port `3000` by default. - ---- - -## Bare-Metal Deployment - -Use this path when you want to manage PostgreSQL and Valkey yourself (e.g., existing infrastructure, managed cloud databases). - -### Step 1 — Install system dependencies - -```bash -# Node.js 22 via nvm -curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash -nvm install 22 -nvm use 22 - -# pnpm -npm install -g pnpm - -# PostgreSQL 17 with pgvector (Debian/Ubuntu example) -sudo apt-get install -y postgresql-17 postgresql-17-pgvector - -# Valkey -# Follow https://valkey.io/download/ for your distribution -``` - -### Step 2 — Create the database - -```sql --- Run as the postgres superuser -CREATE USER mosaic WITH PASSWORD 'change-me'; -CREATE DATABASE mosaic OWNER mosaic; -\c mosaic -CREATE EXTENSION IF NOT EXISTS vector; -``` - -### Step 3 — Clone and configure - -```bash -git clone /opt/mosaic -cd /opt/mosaic -cp .env.example .env -``` - -Edit `/opt/mosaic/.env`. Required fields: - -```dotenv -DATABASE_URL=postgresql://mosaic:@localhost:5432/mosaic -VALKEY_URL=redis://localhost:6379 -BETTER_AUTH_SECRET= -BETTER_AUTH_URL=https://your-domain.example.com -GATEWAY_CORS_ORIGIN=https://your-domain.example.com -NEXT_PUBLIC_GATEWAY_URL=https://your-domain.example.com -``` - -### Step 4 — Install dependencies and build - -```bash -pnpm install -pnpm build -``` - -### Step 5 — Run database migrations - -```bash -pnpm --filter @mosaicstack/db db:migrate -``` - -### Step 6 — Start the gateway - -```bash -node apps/gateway/dist/main.js -``` - -The gateway reads `.env` from the monorepo root automatically (via `dotenv` in `main.ts`). - -### Step 7 — Start the web app - -```bash -# Next.js standalone output -node apps/web/.next/standalone/server.js -``` - -The standalone build is self-contained; it does not require `node_modules` to be present at runtime. - -### Step 8 — Configure a reverse proxy - -#### Nginx example - -```nginx -# /etc/nginx/sites-available/mosaic - -# Gateway API -server { - listen 443 ssl; - server_name your-domain.example.com; - - ssl_certificate /etc/ssl/certs/your-domain.crt; - ssl_certificate_key /etc/ssl/private/your-domain.key; - - # WebSocket support (for chat.gateway.ts / Socket.IO) - location /socket.io/ { - proxy_pass http://127.0.0.1:14242; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } - - # REST + auth - location / { - proxy_pass http://127.0.0.1:14242; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} - -# Web app (optional — serve on a subdomain or a separate server block) -server { - listen 443 ssl; - server_name app.your-domain.example.com; - - ssl_certificate /etc/ssl/certs/your-domain.crt; - ssl_certificate_key /etc/ssl/private/your-domain.key; - - location / { - proxy_pass http://127.0.0.1:3000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } -} -``` - -#### Caddy example - -```caddyfile -# /etc/caddy/Caddyfile - -your-domain.example.com { - reverse_proxy /socket.io/* localhost:14242 { - header_up Upgrade {http.upgrade} - header_up Connection {http.connection} - } - reverse_proxy localhost:14242 -} - -app.your-domain.example.com { - reverse_proxy localhost:3000 -} -``` - ---- - -## Production Considerations - -### systemd Services - -Create a service unit for each process. - -**Gateway** — `/etc/systemd/system/mosaic-gateway.service`: - -```ini -[Unit] -Description=Mosaic Gateway -After=network.target postgresql.service - -[Service] -Type=simple -User=mosaic -WorkingDirectory=/opt/mosaic -EnvironmentFile=/opt/mosaic/.env -ExecStart=/usr/bin/node apps/gateway/dist/main.js -Restart=on-failure -RestartSec=5 -StandardOutput=journal -StandardError=journal - -[Install] -WantedBy=multi-user.target -``` - -**Web app** — `/etc/systemd/system/mosaic-web.service`: - -```ini -[Unit] -Description=Mosaic Web App -After=network.target mosaic-gateway.service - -[Service] -Type=simple -User=mosaic -WorkingDirectory=/opt/mosaic/apps/web -EnvironmentFile=/opt/mosaic/.env -ExecStart=/usr/bin/node .next/standalone/server.js -Environment=PORT=3000 -Environment=HOSTNAME=127.0.0.1 -Restart=on-failure -RestartSec=5 -StandardOutput=journal -StandardError=journal - -[Install] -WantedBy=multi-user.target -``` - -Enable and start: - -```bash -sudo systemctl daemon-reload -sudo systemctl enable --now mosaic-gateway mosaic-web -``` - -### Log Management - -Gateway and web app logs go to systemd journal by default. View with: - -```bash -journalctl -u mosaic-gateway -f -journalctl -u mosaic-web -f -``` - -Rotate logs by configuring `journald` in `/etc/systemd/journald.conf`: - -```ini -SystemMaxUse=500M -MaxRetentionSec=30day -``` - -### Security Checklist - -- Set `BETTER_AUTH_SECRET` to a cryptographically random value (`openssl rand -base64 32`). -- Restrict `GATEWAY_CORS_ORIGIN` to your exact frontend origin — do not use `*`. -- Run services as a dedicated non-root system user (e.g., `mosaic`). -- Firewall: only expose ports 80/443 externally; keep 14242 and 3000 bound to `127.0.0.1`. -- Set `AGENT_FILE_SANDBOX_DIR` to a directory outside the application root to prevent agent tools from accessing source code. -- If using `AGENT_USER_TOOLS`, enumerate only the tools non-admin users need. - ---- - -## Troubleshooting - -### Gateway fails to start — "BETTER_AUTH_SECRET is required" - -`BETTER_AUTH_SECRET` is missing or empty. Set it in `.env` and restart. - -### `DATABASE_URL` connection refused - -Verify PostgreSQL is running and the port matches. The Docker Compose default is `5433`; bare-metal typically uses `5432`. - -```bash -psql "$DATABASE_URL" -c '\conninfo' -``` - -### pgvector extension missing - -```sql -\c mosaic -CREATE EXTENSION IF NOT EXISTS vector; -``` - -### Valkey / Redis connection refused - -Check the URL in `VALKEY_URL`. The Docker Compose default is port `6380`. - -```bash -redis-cli -u "$VALKEY_URL" ping -``` - -### WebSocket connections fail in production - -Ensure your reverse proxy forwards the `Upgrade` and `Connection` headers. See the Nginx/Caddy examples above. - -### Ollama models not appearing - -Set `OLLAMA_BASE_URL` to the URL where Ollama is running (e.g., `http://localhost:11434`) and set `OLLAMA_MODELS` to a comma-separated list of model IDs you have pulled. - -```bash -ollama pull llama3.2 -``` - -### OTEL traces not appearing in Jaeger - -Verify the collector is reachable at `OTEL_EXPORTER_OTLP_ENDPOINT`. With Docker Compose the default is `http://localhost:4318`. Check `docker compose ps` and `docker compose logs otel-collector`. - -### Summarization / embedding features not working - -These features require `OPENAI_API_KEY` to be set, or you must point `SUMMARIZATION_API_URL` / `EMBEDDING_API_URL` to an OpenAI-compatible endpoint (e.g., a local Ollama instance with an embeddings model). +Non-database local services may be inspected with their ordinary local health/log tools. Those +checks do not certify PostgreSQL, federated deployment, or production readiness. diff --git a/docs/guides/dev-guide.md b/docs/guides/dev-guide.md index 936c86a4..901ff0fd 100644 --- a/docs/guides/dev-guide.md +++ b/docs/guides/dev-guide.md @@ -8,8 +8,9 @@ 4. [Adding New Agent Tools](#adding-new-agent-tools) 5. [Adding New MCP Tools](#adding-new-mcp-tools) 6. [Database Schema and Migrations](#database-schema-and-migrations) -7. [API Endpoint Reference](#api-endpoint-reference) -8. [Local Fleet Canary](./fleet-local-canary.md) +7. [Claude Code Skill Bridge](#claude-code-skill-bridge) +8. [API Endpoint Reference](#api-endpoint-reference) +9. [Local Fleet Canary](./fleet-local-canary.md) --- @@ -39,7 +40,7 @@ mosaic-mono-v1/ │ ├── queue/ # Valkey-backed task queue │ └── types/ # Shared TypeScript types ├── docker/ # Dockerfile(s) for containerized deployment -├── infra/ # Infra config (OTEL collector, pg-init scripts) +├── infra/ # Infrastructure configuration (for example, OTEL collector) ├── docker-compose.yml # Local services (Postgres, Valkey, OTEL, Jaeger) └── CLAUDE.md # Project conventions for AI coding agents ``` @@ -86,71 +87,54 @@ cd mosaic-mono-v1 pnpm install ``` -### 2. Start Infrastructure Services +### 2. Use the local PGlite tier + +The supported local tier is in-process PGlite and requires no PostgreSQL service. Leave +`DATABASE_URL` unset for this route. Its default local configuration uses PGlite and performs no +external database probe. + +If a local queue service is useful, start only that non-PostgreSQL service: ```bash -docker compose up -d +docker compose up -d valkey ``` -This starts: +Do not use the current Compose PostgreSQL service: it mounts legacy `infra/pg-init` SQL and is +not qualified for KBN-101. Start OTEL Collector or Jaeger individually only when needed and +without starting PostgreSQL. -| Service | Port | Description | -| ------------------------ | -------------- | -------------------- | -| PostgreSQL 17 + pgvector | `5433` (host) | Primary database | -| Valkey 8 | `6380` (host) | Queue and cache | -| OpenTelemetry Collector | `4317`, `4318` | OTEL gRPC and HTTP | -| Jaeger | `16686` | Distributed trace UI | +### 3. Gateway/Web local process (held) -### 3. Configure Environment +Do not start the current Gateway or web process as a local PGlite route. Gateway first loads the +daemon configuration and then project environment files without a tier guard; a pre-existing +`DATABASE_URL` can select PostgreSQL, where current startup still reaches runtime DDL/migrations. +Creating a root `.env` that omits `DATABASE_URL` does not make this safe, so neither a local +credential file nor a web environment file is a current developer procedure. -Create a `.env` file in the monorepo root: +PGlite remains the supported in-process data-layer implementation, and the optional Valkey command +above remains safe because it does not start PostgreSQL. A safe Gateway/Web local procedure is held +until KBN-101-02 rejects a daemon, inherited, root, or app-local PostgreSQL DSN and any non-local +tier before connection or DDL; KBN-101-05 then supplies the production renderer/Vault process-exec +or `LoadCredential` boundary. -```env -# Database (matches docker-compose defaults) -DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic +### Held future procedure -# Auth (required — generate a random 32+ char string) -BETTER_AUTH_SECRET=change-me-to-a-random-secret +PostgreSQL local and federated deployment are held until KBN-101-00 (external bootstrap), +KBN-101-03 (runner), and KBN-101-05 (renderer-backed deployment) land. The following is the +**held, non-operative future activation order with no current command authority**: -# Gateway -GATEWAY_PORT=14242 -GATEWAY_CORS_ORIGIN=http://localhost:3000 +external bootstrap → TLS/roles → `mosaic-db-migrator --run` → +`mosaic-db-migrator --verify` → Gateway/Compose readiness. -# Web -NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242 +Neither current Compose nor this development guide authorizes PostgreSQL initialization SQL, +manual DDL, or a pre-runner start. -# Optional: Ollama -OLLAMA_BASE_URL=http://localhost:11434 -OLLAMA_MODELS=llama3.2 -``` +### 5. Gateway/Web start (held) -The gateway loads `.env` from the monorepo root via `dotenv` at startup -(`apps/gateway/src/main.ts`). - -### 4. Push the Database Schema - -```bash -pnpm --filter @mosaicstack/db db:push -``` - -This applies the Drizzle schema directly to the database (development only; use -migrations in production). - -### 5. Start the Gateway - -```bash -pnpm --filter @mosaicstack/gateway exec tsx src/main.ts -``` - -The gateway starts on port `14242` by default. - -### 6. Start the Web App - -```bash -pnpm --filter @mosaicstack/web dev -``` - -The web app starts on port `3000` by default. +No Gateway/Web start command is currently authorized for the local PGlite route. Do not use root +`pnpm dev` as a workaround: it additionally starts configured integrations and cannot establish the +required local-tier/DSN isolation. Resume this section only after KBN-101-02 provides its +fail-closed local-startup evidence. --- @@ -311,26 +295,13 @@ Implement a standard MCP server that exposes tools via the streamable HTTP transport or SSE transport. The server must accept connections at a `/mcp` endpoint. -### 2. Configure `MCP_SERVERS` +### 2. Gateway MCP configuration (held) -In your `.env`: - -```env -MCP_SERVERS='[{"name":"my-server","url":"http://localhost:3001/mcp"}]' -``` - -With authentication: - -```env -MCP_SERVERS='[{"name":"secure-server","url":"http://my-server/mcp","headers":{"Authorization":"Bearer token"}}]' -``` - -### 3. Restart the Gateway - -On startup, `McpClientService` (`apps/gateway/src/mcp-client/mcp-client.service.ts`) -connects to each configured server, calls `tools/list`, and bridges the results -to Pi SDK `ToolDefinition` format. These tools become available in all new agent -sessions. +Do not configure MCP endpoint credentials, write them to a local environment file, or restart the +Gateway from this guide. Gateway/Web startup is held until KBN-101-02 supplies fail-closed +local-tier/DSN isolation and KBN-101-05 supplies the renderer/Vault process-exec or +`LoadCredential` secret-consumer interface. The future authenticated MCP route requires verified +HTTPS and certificate validation; plaintext bearer-token examples are forbidden. ### Tool Naming @@ -366,45 +337,65 @@ The schema lives in a single file: The `insights` table uses a `vector(1536)` column (pgvector) for semantic search. -### Development: Push Schema +### PostgreSQL schema work (held) -Apply schema changes directly to the dev database (no migration files created): +Do not prepare or run a PostgreSQL target from this branch. The sole runner, bootstrap, and +renderer are future KBN-101 artifacts, not current commands. When KBN-101-00/-03/-05 land, the +owned activation documentation will require external bootstrap → TLS/roles → runner `--run` → +runner `--verify` → Gateway/Compose readiness. -```bash -pnpm --filter @mosaicstack/db db:push -``` +### Generating migration artifacts -### Generating Migrations - -For production-safe, versioned changes: - -```bash -pnpm --filter @mosaicstack/db db:generate -``` - -This creates a new SQL migration file in `packages/db/drizzle/`. - -### Running Migrations - -```bash -pnpm --filter @mosaicstack/db db:migrate -``` +`pnpm --filter @mosaicstack/db db:generate` is an offline artifact-generation command. It does +not authorize connecting to or initializing PostgreSQL. A future reviewed PostgreSQL procedure +will determine when its output is applied. ### Drizzle Config -Config is at `packages/db/drizzle.config.ts`. The schema file path and output -directory are defined there. +Config is at `packages/db/drizzle.config.ts`. The schema file path and output directory are +defined there. ### Adding a New Table 1. Add the table definition to `packages/db/src/schema.ts`. 2. Export it from `packages/db/src/index.ts`. -3. Run `pnpm --filter @mosaicstack/db db:push` (dev) or - `pnpm --filter @mosaicstack/db db:generate && pnpm --filter @mosaicstack/db db:migrate` - (production). +3. Generate the offline artifact with `pnpm --filter @mosaicstack/db db:generate`. +4. Do not apply it to PostgreSQL until the future KBN-101 activation artifacts and their owned + procedure are available. Direct schema push is not a production-like workflow. --- +## Claude Code Skill Bridge + +The framework's canonical skill root is `~/.config/mosaic/skills/`; Claude Code +requires registrations under `~/.claude/skills/`. The implementation in +`packages/mosaic/src/commands/skill.ts` owns only direct-child symlinks whose +resolved target remains inside the canonical root. + +Security invariants: + +1. Validate the user-supplied name before filesystem access against + `[A-Za-z0-9][A-Za-z0-9._-]*`. Separators, control characters, whitespace, + `..`, absolute paths, and leading `-` are invalid; filesystem-derived invalid + names are escaped before terminal output. +2. Never replace a real file, directory, foreign symlink, or live misdirected + symlink in the Claude skill directory. +3. Repair a dangling link only when its lexical target is inside the canonical + Mosaic skills root. +4. Unregister only a symlink pointing inside that root. +5. Enumerate canonical directories at runtime; never hardcode framework skill + names. + +`finalizeStage` reconciles after wizard/framework synchronization, and +`runFrameworkReseed` reconciles after the sync-only `mosaic update` path. A +foreign conflict is reported but does not prevent unrelated canonical skills +from registering. Filesystem tests use injected temporary roots in +`skill.spec.ts`, `finalize-skills.spec.ts`, and `update-checker.reseed.spec.ts`. + +M1 intentionally manages Claude Code only. Pi's Mosaic launcher can discover the +canonical root directly. Codex still relies on the existing full skill-sync +linker and needs separate parity analysis before this lifecycle API is extended. + ## API Endpoint Reference All endpoints are served by the gateway at `http://localhost:14242` by default. diff --git a/docs/guides/fleet-local-canary.md b/docs/guides/fleet-local-canary.md index 9350aab4..c98fdba0 100644 --- a/docs/guides/fleet-local-canary.md +++ b/docs/guides/fleet-local-canary.md @@ -98,6 +98,39 @@ Expected results: that means the unit ran, not that an agent pane is live. Treat tmux `has-session`, `list-panes`, process tree, and logs as the liveness evidence. +## Recovery — rebuild generated env projections + +Each agent's `~/.config/mosaic/fleet/agents/.env.generated` is a +deterministic projection of `roster.yaml` (the SSOT) that the launcher +(`start-agent-session.sh`) sources at start. If an upgrade or a manual mistake +wipes or diverges those projections, rebuild them from the roster with +`mosaic fleet regen` — do NOT restart the affected unit first. + +```bash +mosaic fleet regen # dry-run (default): show create/rebuild plan per agent +mosaic fleet regen --json # same plan, machine-readable +mosaic fleet regen --write # rebuild fleet/agents/.env.generated on disk +``` + +`regen` is projection-only and **never restarts an agent** — it has no path to +systemd lifecycle. It is dry-run by default, deterministic/idempotent, uses the +same roster→env mapping as `mosaic fleet reconcile`, and emits paths and counts +only (never the projected `KEY=value` body). After `--write`, verify each unit +resolves the intended values before restarting one unit at a time. The unit sets +no `EnvironmentFile=` — `start-agent-session.sh` sources `.env.generated` itself — +so verify the generated file directly and the launcher path, not a nonexistent +`EnvironmentFile` property: + +```bash +test -f ~/.config/mosaic/fleet/agents/.env.generated +systemctl --user cat mosaic-agent@ | grep ExecStart +systemctl --user restart mosaic-agent@ +``` + +Full recovery runbook and the three-layer #791 protection model (manifest +ownership → pre-update snapshot/restore → regen): see +[Upgrade Safety & Recovery](./upgrade-safety-and-recovery.md). + ## Release Preflight Run this checklist before cutting or dogfooding a fleet release: diff --git a/docs/guides/lease-broker-operations.md b/docs/guides/lease-broker-operations.md new file mode 100644 index 00000000..ee7caf57 --- /dev/null +++ b/docs/guides/lease-broker-operations.md @@ -0,0 +1,44 @@ +# Lease broker operations + +Place the socket and state file in a dedicated directory with mode `0700`. Start the packaged daemon with: + +```bash +python3 "$MOSAIC_HOME/tools/lease-broker/daemon.py" \ + --socket /run/user/1000/mosaic-lease/broker.sock \ + --state /run/user/1000/mosaic-lease/state.json +``` + +The broker refuses an existing parent directory whose mode is not exactly `0700`, an existing state file not at `0600`, corrupt/incompatible state, or an already-existing socket path. After bind it sets the socket to `0600`. It never silently unlinks a pre-existing socket. On normal termination it unlinks only the socket inode it created, so it does not remove a replacement path. + +Before launching Claude, Claudex, or Pi, export the socket path; `mosaic` then runs the runtime through the packaged register-and-exec wrapper: + +```bash +export MOSAIC_LEASE_BROKER_SOCKET=/run/user/1000/mosaic-lease/broker.sock +mosaic claude # or: mosaic claudex, mosaic yolo claudex, mosaic pi +``` + +The wrapper obtains a broker-minted session ID, creates a private `generation-.state` file beside the socket, and `exec`s the runtime without changing its PID/starttime anchor. The all-tools Claude `PreToolUse` hook and Pi `tool_call` handler inherit that identity and read the current generation from the file. Claudex retains its isolated proxy environment and config directory; Mosaic merges the mandatory all-tools and compaction-lifecycle hooks into that isolated `settings.json` before invoking the same wrapper. PRDY init/update, QA remediation, coord, orchestrator, and fleet launchers also converge on this boundary. Broker registration failure, unsafe isolated settings, unsafe generation state, or missing identity denies launch/tool execution fail-closed; broker timeout/unavailability and malformed replies also block tools. + +Claude `PreCompact` and `SessionStart(compact)` hooks and Pi pre-/post-compaction handlers invoke `revoke-lease.py`. Pi `session_start` reload/new/resume/fork and Claude resume/clear advance the locked generation before revocation, so a replacement session inherits no lease even when PID/starttime stay unchanged. Do not invoke the revoker manually as a way to restore authority; it only removes authority. If a lifecycle hook reports failure, stop consequential work and repair broker/generation-state availability before re-verification. + +Run the permanent launch inventory locally with: + +```bash +python3 packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py --root . +``` + +The same check runs in the Mosaic package test suite and therefore in root CI. Any direct Claude/Pi binary launch must be replaced with `launch-runtime.py`, `execLeaseGatedRuntime`, or the gated `mosaic` runtime command; do not add static allowlist exceptions. + +Clients must complete the request boundary before waiting for a reply. After sending the single JSON object and its terminating newline, the client **MUST half-close the socket's write side** (`shutdown(SHUT_WR)` in POSIX clients; `socket.end()` in Node) and only then await the response. Merely calling `write()` and waiting is invalid: the broker waits for EOF to enforce the exact-one-frame contract and fails closed at its one-second deadline. Do not replace `end()` with `write()` in client helpers. A delayed second frame remains malformed and is rejected. + +`mosaic_context_recover` is the only unverified mutator class. Its durable `mosaic-context-refresh` skill is a thin wrapper over `tools/lease-broker/recover-context.py`: `begin` has the broker rebuild the validated `B_payload`/`H_payload`, revoke first, and mint a new `PENDING_DELIVERY` receipt challenge; `complete` accepts neither receipt text nor a challenge argument. Claude maps only the exact direct recovery executable/validated arguments to this exempt tool identity; ordinary `Bash` remains gated. Pi exposes only the `mosaic_context_recover` custom tool; ordinary `bash` and all other tools remain gated. A normal-path receipt cannot be replayed through recovery because each retry begins a distinct recovery cycle and recovery completion cannot receive caller-presented evidence. + +Production daemon startup creates a separate private observer socket unless a test-only `--test-observer-file` fixture is selected. Claude's Stop hook sends its exact latest assistant entry and Pi's `message_end` handler sends only finalized assistant content to that authenticated transport; the broker public socket never accepts message text. This is byte-build and private out-of-process harness wiring only: do not activate it against a live daemon, live socket, systemd service, tmux session, or model-output stream outside the controlled integration procedure. + +Receipt honesty is load-bearing: absent, malformed, prefix-truncated, and observable adapter-mutated terminal receipts do not promote. A tail-only case is non-promoting only where the concrete terminal payload is malformed or observably incomplete. A tail-preserving middle drop is **not receipt-detectable**; it is the disclosed T-C injection-contract residual deferred to WI-7 server-side evidence. The receipt remains a T-A delivery/liveness prerequisite, never a safety, obedience, or residency proof. The framework skill is source-resident and bridge-projected on install/upgrade; do not hand-create a live runtime symlink. + +After a runtime exits, its `generation-.state` file may be removed only after verifying that no process for that broker-minted session remains; stale files carry no lease authority but should be retained during incident analysis. After a broker crash, preserve the protected state file and restart only after verifying that no broker owns the socket. Restart intentionally clears all volatile VERIFIED leases. A leftover socket requires an operator to verify the owning service is stopped and remove that exact socket deliberately. Corrupt, oversized, symlinked, or non-regular state fails closed; do not overwrite it. Preserve it for incident review and establish new state only through an explicit operational decision, which invalidates prior sessions and tokens. + +## Security posture + +Directory `0700` plus socket/state `0600` is built-in same-principal hardening only: it excludes other UIDs but does **not** stop the same UID from unlinking and counterfeiting the socket. It therefore does not close T-C same-UID replacement. WI-1 does not provide a distinct-principal boundary. A stronger distinct-principal deployment requires an external protected proxy, ACL, or service boundary that clients cannot unlink or rebind and that preserves the authenticated client identity required by the broker's `SO_PEERCRED` and ancestry checks. Server-side branch protection remains the irreducible backstop. diff --git a/docs/guides/migrate-tier.md b/docs/guides/migrate-tier.md index c920545a..bc9516ac 100644 --- a/docs/guides/migrate-tier.md +++ b/docs/guides/migrate-tier.md @@ -1,147 +1,98 @@ # Migrating to the Federated Tier -Step-by-step guide to migrate from `local` (PGlite) or `standalone` (PostgreSQL without pgvector) to `federated` (PostgreSQL 17 + pgvector + Valkey). +> **KBN-101-07 ownership:** This active documentation is a **non-operative KBN-101 +> contract** with no current command authority until KBN-101-00, KBN-101-02, KBN-101-03, KBN-101-05, and KBN-101-06 land and +> KBN-101-08 activates an exact reviewed release. The commands below describe the produced interface only. Do not run them on the +> current branch or replace them with direct PostgreSQL, raw SQL, legacy storage migration, or +> credential-on-argv procedures. -## When to migrate +## Held future procedure -Migrate to federated tier when: +This section is non-operative and grants no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05 land. -- Scaling from single-user to multi-user deployments -- Adding vector embeddings or RAG features -- Running Mosaic across multiple hosts -- Requires distributed task queueing and caching -- Moving to production with high availability +The deployment control plane executes the complete held future procedure, in order: external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. The +runner is the only attestation producer after its verified TLS, identity, manifest, and schema +checks. A data importer is never a schema bootstrap, extension installer, repair command, or DDL +consumer. -## Prerequisites +## Target material contract -- Federated stack running and healthy (see [Federated Tier Setup](../federation/SETUP.md)) -- Source database accessible and empty target database at the federated URL -- Backup of source database (recommended before any migration) +KBN-101-05 obtains the target URL from Vault KV-v2 +`secret-{env}/mosaic-stack/database/importer`, key `url`, and reads its authenticated version from +the same successful response `data.metadata.version`. A hash or DSN byte sequence is not a +provider version. The renderer treats URL bytes and provider version as one generation, writes a +temporary generation directory with fsync plus atomic rename, and creates separate immutable +consumer mounts. Swarm uses distinct versioned secret/config references. A deployment cannot mix +generations. -## Dry-run first +| Consumer | Permitted material | +| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Migrator-attestation producer (`10003:10003`) | Its own migration URL/CA; read-only `/run/secrets/mosaic-migrate-target-url` and `/run/secrets/mosaic-migrate-target-version`, each `0400`, solely to bind; producer-only attestation output at `/run/mosaic-attestations-producer/migrate-target.v1.json`; root-wrapper-only signing key. It never connects with, uses, exports, logs, or forwards the importer URL/version. | +| Privileged deployment handoff controller | After runner success and before importer creation, it receives only root-owned non-secret expected provider-version/URL-SHA-256/generation descriptor and pinned public verifier key—not URL bytes or private key. It safe-opens/verifies descriptor and producer artifact, copies exact bytes to a new importer-only mount with fsync/atomic rename, sets `10002:10002` `0400`, seals it read-only, and refuses importer start on any partial/wrong-generation/wrong-owner/mode result. | +| Importer (`10002:10002`) | Its own immutable `0400` copies at the same URL/version paths; CA at exact `DATABASE_TLS_CA_CERT_PATH=/run/secrets/mosaic-db-ca.crt`; pinned Ed25519 public key; read-only `/run/mosaic-attestations/migrate-target.v1.json` supplied only by the sealed handoff. | +| Gateway/runtime/unrelated container | No importer URL/version, importer artifact, attestation private key, or unrelated CA mount. | -Always run a dry-run to validate the migration: +The migrator and importer safe-open URL, provider-version, attestation, and public-key files only +with `O_RDONLY|O_CLOEXEC|O_NOFOLLOW`; they validate from the opened fd that the file is regular, +has its expected owner/mode and link count one. The migrator digests only that URL fd for binding, +then zeroizes/closes it. The importer reads URL bytes once into protected memory, validates the +signed binding and exact CA before connecting from those same bytes, then zeroizes/closes every +fd. It neither logs nor exposes a URL/version/attestation/key oracle. + +## Produced command interface + +After activation and only after approved target preparation, the future interface is: ```bash +# Deployment control plane has already completed the held runner procedure above. mosaic storage migrate-tier --to federated \ - --target-url postgresql://mosaic:mosaic@localhost:5433/mosaic \ + --target-url-file /run/secrets/mosaic-migrate-target-url \ + --target-attestation-file /run/mosaic-attestations/migrate-target.v1.json \ --dry-run ``` -Expected output (partial example): +The provider-version file is fixed deployment material, not argv. This connecting dry-run consumes its nonce; before an actual copy, the deployment control plane must provide fresh runner verification and a new sealed handoff. The runner uses its migration +identity; the importer connects only as non-DDL `mosaic_data_importer` and only after all +pre-connect validation. After verified TLS and before DML it compares PostgreSQL system ID, +database OID, `current_user`, CA/SPKI, and manifest/schema fingerprints to the artifact. -``` -[migrate-tier] Analyzing source tier: pglite -[migrate-tier] Analyzing target tier: federated -[migrate-tier] Precondition: target is empty ✓ - users: 5 rows - teams: 2 rows - conversations: 12 rows - messages: 187 rows - ... (all tables listed) -[migrate-tier] NOTE: Source tier has no pgvector support. insights.embedding will be NULL on all migrated rows. -[migrate-tier] DRY-RUN COMPLETE (no data written). 206 total rows would be migrated. -``` +## Required refusals and evidence -Review the output. If it shows an error (e.g., target not empty), address it before proceeding. +KBN-101-02/-03/-05/-06 must prove, with stable sanitized errors, that no target connection occurs +for missing/unsafe URL/version/attestation/public-key files; symlink, hardlink, owner, mode, or +TOCTOU violations; mixed URL/version generations; missing/wrong CA mount; stale/replayed/tampered +or revoked-key artifacts; provider rotation/revocation; wrong TLS/server/database/role/manifest +binding; raw `--target-url`; `DATABASE_URL` fallback; runtime/owner identity; consumer leakage; +or any DDL attempt. Post-connect identity mismatch closes with zero DML/DDL. Tests also prove no +forwarding, child environment, logging, or error oracle leaks URL/version/key/artifact contents. -## Run the migration +The attestation is credential-free JCS with detached Ed25519 signature and binds issued/expiry, +nonce, authenticated provider version, exact URL-fd SHA-256, TLS host/port/database, CA/SPKI, +PostgreSQL system ID/database OID, importer role, manifest/schema, and producer identity. Provider +version rotation invalidates an old artifact and requires a fresh rendered generation plus runner +verification. -When ready, run without `--dry-run`: +## Actual copy after dry-run + +After reviewed dry-run, obtain the required fresh verification/attestation generation, then use: ```bash +# Deployment control plane has supplied fresh runner verification and attestation. mosaic storage migrate-tier --to federated \ - --target-url postgresql://mosaic:mosaic@localhost:5433/mosaic \ + --target-url-file /run/secrets/mosaic-migrate-target-url \ + --target-attestation-file /run/mosaic-attestations/migrate-target.v1.json \ --yes ``` -The `--yes` flag skips the confirmation prompt (required in non-TTY environments like CI). +The dry-run artifact is terminally replayed and must be rejected; `--yes` bypasses no file, +generation, signature, TLS, identity, or DDL control. -The command will: +## Data boundary and recovery -1. Acquire an advisory lock (blocks concurrent invocations) -2. Copy data from source to target in dependency order -3. Report rows migrated per table -4. Display any warnings (e.g., null vector embeddings) +The importer has only an allowlisted mutable-table DML registry. It has no grant for immutable KBN +relations, schemas, roles, memberships, extensions, catalogs, or the Drizzle ledger. Source PGlite +uses its explicit local directory and does not make a PostgreSQL URL fallback valid. -## What gets migrated - -All persistent, user-bound data is migrated in dependency order: - -- **users, teams, team_members** — user and team ownership -- **accounts** — OAuth provider tokens (durable credentials) -- **projects, agents, missions, tasks** — all project and agent definitions -- **conversations, messages** — all chat history -- **preferences, insights, agent_logs** — preferences and observability -- **provider_credentials** — stored API keys and secrets -- **tickets, events, skills, routing_rules, appreciations** — auxiliary records - -Full order is defined in code (`MIGRATION_ORDER` in `packages/storage/src/migrate-tier.ts`). - -## What gets skipped and why - -Three tables are intentionally not migrated: - -| Table | Reason | -| ----------------- | ----------------------------------------------------------------------------------------------- | -| **sessions** | TTL'd auth sessions from the old environment; they will fail JWT verification on the new target | -| **verifications** | One-time tokens (email verify, password reset) that have either expired or been consumed | -| **admin_tokens** | Hashed tokens bound to the old environment's secret keys; must be re-issued | - -**Note on accounts and provider_credentials:** These durable credentials ARE migrated because they are user-bound and required for resuming agent work on the target environment. After migration to a multi-tenant federated deployment, operators may want to audit or wipe these if users are untrusted or credentials should not be shared. - -## Idempotency and concurrency - -The migration is **idempotent**: - -- Re-running is safe (uses `ON CONFLICT DO UPDATE` internally) -- Ideal for retries on transient failures -- Concurrent invocations are blocked by a Postgres advisory lock; the second caller will wait - -If a previous run is stuck, check for advisory locks: - -```sql -SELECT * FROM pg_locks WHERE locktype='advisory'; -``` - -If you need to force-unlock (dangerous): - -```sql -SELECT pg_advisory_unlock(); -``` - -## Verify the migration - -After migration completes, spot-check the target: - -```bash -# Count rows on a few critical tables -psql postgresql://mosaic:mosaic@localhost:5433/mosaic -c \ - "SELECT 'users' as table, COUNT(*) FROM users UNION ALL - SELECT 'conversations' as table, COUNT(*) FROM conversations UNION ALL - SELECT 'messages' as table, COUNT(*) FROM messages;" -``` - -Verify a known user or project exists by ID: - -```bash -psql postgresql://mosaic:mosaic@localhost:5433/mosaic -c \ - "SELECT id, email FROM users WHERE email='';" -``` - -Ensure vector embeddings are NULL (if source was PGlite) or populated (if source was postgres + pgvector): - -```bash -psql postgresql://mosaic:mosaic@localhost:5433/mosaic -c \ - "SELECT embedding IS NOT NULL as has_vector FROM insights LIMIT 5;" -``` - -## Rollback - -There is no in-place rollback. If the migration fails: - -1. Restore the target database from a pre-migration backup -2. Investigate the failure logs -3. Rerun the migration - -Always test migrations in a staging environment first. +A failed or ambiguous migration is a control-plane incident: preserve sanitized evidence, retain +the approved backup/rollback state, and retry only after independent review. Never inspect, +unlock, repair, or initialize the target with ad hoc SQL or copied credentials. diff --git a/docs/guides/mos-connector-lease-operations.md b/docs/guides/mos-connector-lease-operations.md new file mode 100644 index 00000000..99ef63b8 --- /dev/null +++ b/docs/guides/mos-connector-lease-operations.md @@ -0,0 +1,43 @@ +# Mos Connector Lease Operations — M1 + +## Operational status + +M1 installs the durable schema and gateway policy/adapter boundary. It does **not** activate a connector, expose a lease administration endpoint, or cut over a channel. The default gateway connector-lease policy is deny-all until a later work package supplies an authorized server-side policy and concrete adapter. + +## Events to monitor + +Use correlation IDs to follow `connector_lease_audit_log` events: + +| Event | Meaning | +| ---------- | --------------------------------------------------------------------- | +| `acquire` | First holder inserted for an unused binding | +| `renew` | Current holder heartbeat extended the TTL | +| `takeover` | Authorized CAS replaced the holder and incremented epoch | +| `release` | Current holder explicitly relinquished authority | +| `expiry` | An expired current lease was observed | +| `reject` | Policy, CAS, expiry, scope, or fencing validation denied an operation | + +Audit data is metadata-only. Raw grant objects, connector payloads, scopes, tokens, approval references, and credentials must never be added to audit output. + +## Incident checks + +For suspected duplicate/stale connector effects: + +1. Correlate the attempted operation with its `reject`, `takeover`, or `expiry` event. +2. Compare the current row's connector ID, lease UUID, epoch, expiry, and release time with the adapter's normalized execution context. +3. Treat an old epoch, old lease UUID, expired lease, or released lease as non-authoritative. Do not retry it as the old holder. +4. Recovery uses the authorized takeover path with the observed expected epoch. Ordinary acquire is intentionally rejected for expired/released rows. +5. If an external effect may already have happened, preserve evidence and do not assume lease fencing provides exactly-once replay safety. + +## Migration and rollback safety + +Migration `0016_salty_morlocks.sql` is additive: it creates two new tables and indexes without modifying existing authorization/session tables. Before rollout, normal database backup and migration verification still apply. Rolling application code back leaves unused additive tables in place; dropping tables is not part of automated rollback because it would destroy lease/audit evidence. + +## Security constraints + +- Tenant comes from authenticated gateway context, never a connector request field. +- Logical agent, binding, connector, and scope identifiers use normalized constrained forms. +- Takeover requires explicit gateway policy authorization and an expected epoch. +- Default defense-in-depth TTL caps are 5 minutes for leases and 30 seconds for grants; policy may enforce stricter limits. +- Validation and rejection audit complete before adapter side effects. +- Existing authz and exact-action approval controls remain additional required gates; a valid connector lease does not bypass them. diff --git a/docs/guides/upgrade-safety-and-recovery.md b/docs/guides/upgrade-safety-and-recovery.md new file mode 100644 index 00000000..1f755cb7 --- /dev/null +++ b/docs/guides/upgrade-safety-and-recovery.md @@ -0,0 +1,147 @@ +# Upgrade Safety & Recovery + +How Mosaic protects operator-owned configuration under `~/.config/mosaic` across +framework upgrades, and how to recover if a projection is ever lost. + +A framework upgrade runs `install.sh` in keep-mode (`MOSAIC_INSTALL_MODE=keep`, +`MOSAIC_SYNC_ONLY=1`) to refresh framework-owned files in place. The incident +this hardening addresses: an upgrade that silently overwrites or deletes a file +the operator owns — credentials, personas, a roster, or a generated agent env — +with no snapshot to fall back to. + +Protection is layered. Each layer is independent; a later layer catches what an +earlier one misses. + +## Layer 1 — Manifest-owned sync (prevention) + +The single source of truth for ownership is +[`framework-manifest.txt`](../../packages/mosaic/framework/framework-manifest.txt). +Both the bash installer and the TypeScript sync path resolve every path against +this one file (parity is enforced by test), so they can never drift. + +- Ownership is **allow-list, deny-wins**: a path is framework-owned only if a + `[framework]` glob matches and no `[operator]` carve-out overrides it. +- **Unknown paths default to operator** (fail-safe): a file the manifest never + anticipated is treated as operator-owned and is never pruned. +- Keep-mode does a non-deleting copy plus an explicit, manifest-scoped prune that + only ever iterates framework globs — operator and unknown paths are + structurally unreachable by the prune. + +Result: a correct upgrade cannot touch operator config at all. + +## Layer 2 — Durable pre-update snapshot + verify net (safety + rollback) + +Before **any** mutation, the installer snapshots the operator-owned surface that +exists into: + +``` +${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-/ +``` + +- `0700` directories / `0600` files (`umask 077`, scoped and restored), + outside `~/.config/mosaic` and outside any repo. +- **Fail-open**: a snapshot failure warns but never aborts the upgrade it + protects. +- Retention is `MOSAIC_BACKUP_RETENTION` snapshots (default 5). + +After the sync, a **verify net** compares each snapshot file against its target +and restores (with a loud warning) any operator file the upgrade diverged or +removed — a divergence means a manifest bug slipped through Layer 1. + +Inspect and restore snapshots with the CLI: + +```bash +mosaic restore --list # dry-run: enumerate snapshots by timestamp +mosaic restore --from # restore the operator surface from one snapshot +mosaic restore --from --dry-run # preview a specific restore without writing +``` + +`mosaic restore` reports **counts and relative paths only** — it never emits file +contents, so a secret in `tools/_lib/credentials.json` is never echoed. Restores +are confirmation-gated (`--yes` or `MOSAIC_ASSUME_YES`) and write each leaf +atomically with `O_NOFOLLOW` (a symlink swapped in after the snapshot fails +closed rather than following out of the managed tree). + +## Layer 3 — Regeneration from roster SSOT (recovery) + +Some operator files are **derived** and do not need a byte-for-byte snapshot to +recover — they can be rebuilt from their source of truth. The fleet's per-agent +generated env projections are the prime case: + +- `~/.config/mosaic/fleet/agents/.env.generated` is a deterministic + projection of `~/.config/mosaic/fleet/roster.yaml`. +- The launcher (`start-agent-session.sh`, invoked by + `mosaic-agent@.service`) sources that generated projection to establish + each agent's identity, runtime, model, and working directory. If it is missing + or wrong, the agent cannot launch with its intended identity. + +`mosaic fleet regen` rebuilds those projections from the roster SSOT: + +```bash +mosaic fleet regen # dry-run (default): show what would be rebuilt +mosaic fleet regen --json # same, machine-readable +mosaic fleet regen --write # rebuild the projections on disk +``` + +- **Dry-run by default.** Nothing is written until you pass `--write`. +- **Deterministic and idempotent** — the projection is a pure function of the + roster, so repeated `--write` runs produce byte-identical files. +- **Projection-only. It never restarts an agent.** Recovery order forbids + restart-before-verify; `regen` has no path to systemd lifecycle at all. +- **It rebuilds only `.env.generated`** — it never writes, relocates, or + deletes the operator-owned `.env` / `.env.local` surface. +- It **validates the roster the same way `reconcile` does** (persona resolution + and protected-class tool-policy match), so a hand-edited or corrupt roster is + rejected rather than projected, and a `--write` takes the shared reconcile + lock so it cannot race a concurrent reconcile. +- Output is **paths and counts only** — the rendered `KEY=value` body is never + echoed. + +`regen` uses the exact same roster→env mapping as `mosaic fleet reconcile`, so a +recovered projection matches what a normal reconcile would have written. + +## Recovery runbook — wiped `fleet/agents/*.env.generated` + +If an upgrade (or a manual mistake) has left an agent without its generated +projection, **do not restart the unit first** — a launch against a missing +projection fails closed, and any stale state must be corrected before restart, +not after. + +1. **Prefer a snapshot restore if one exists** (byte-exact operator state): + + ```bash + mosaic restore --list + mosaic restore --from + ``` + +2. **Otherwise regenerate the derived projections from the roster SSOT:** + + ```bash + mosaic fleet regen # confirm the plan (create vs rebuild per agent) + mosaic fleet regen --write # rebuild fleet/agents/.env.generated + ``` + +3. **Verify each unit will resolve the intended runtime/workdir _before_ any + restart.** The unit sets **no** `EnvironmentFile=` — it launches from a minimal + environment and `start-agent-session.sh` sources `.env.generated` itself, so + verify the generated file directly and confirm the launcher path: + + ```bash + # Confirm fleet/agents/.env.generated exists and carries the intended + # MOSAIC_AGENT_* values (name, runtime, model, workdir, socket). + test -f ~/.config/mosaic/fleet/agents/.env.generated + # Confirm the unit launches the session script that reads it. + systemctl --user cat mosaic-agent@ | grep ExecStart + ``` + +4. **Only then restart, one unit at a time:** + + ```bash + systemctl --user restart mosaic-agent@ + ``` + +## See also + +- Design: [`docs/design/791-upgrade-config-protection.md`](../design/791-upgrade-config-protection.md) +- Fleet operations: [`docs/guides/fleet-local-canary.md`](./fleet-local-canary.md) +- Ownership SSOT: [`packages/mosaic/framework/framework-manifest.txt`](../../packages/mosaic/framework/framework-manifest.txt) diff --git a/docs/guides/user-guide.md b/docs/guides/user-guide.md index 323159ca..ec4ba629 100644 --- a/docs/guides/user-guide.md +++ b/docs/guides/user-guide.md @@ -193,6 +193,8 @@ Flags for non-interactive use: --no-auto-launch # Skip auto-launch of wizard after install ``` +Unrecognized flags or positional arguments fail before installation starts and print the supported-option usage. + Or if installed globally: ```bash @@ -317,6 +319,39 @@ mosaic quality-rails --- +### Claude Code Skill Registration + +Mosaic stores canonical skills under `~/.config/mosaic/skills/`. Claude Code scans +`~/.claude/skills/`, so Mosaic maintains one symlink per skill between those +directories. + +```bash +mosaic skill list +mosaic skill register +mosaic skill unregister +``` + +- `register` is idempotent and repairs a dangling Mosaic-owned link. Names use + the safe grammar `[A-Za-z0-9][A-Za-z0-9._-]*`; files, directories, foreign + symlinks, path traversal, absolute paths, and names beginning with `-` are + refused. +- `unregister` is idempotent when no entry exists. It removes only symlinks that + point inside `~/.config/mosaic/skills/`; foreign entries are never removed. +- `list` reports `registered`, `unregistered`, `dangling`, `foreign`, + `foreign-dangling`, or `misdirected` for each canonical or Claude entry. + +Install, wizard finalization, and `mosaic update` framework re-seeding reconcile +every canonical skill automatically. A skill directory added after initial +setup therefore receives its Claude bridge without a per-skill code change or +manual `ln -s`. If Claude Code is already running, use `/reload-skills` or start +a new session after registration so its in-process skill registry rescans. + +This command group is Claude-only in M1. Pi can consume Mosaic's canonical skill +root through its Mosaic launcher configuration and does not need this Claude +bridge. Codex has a separate link path managed by the legacy full skill-sync +script; equivalent lifecycle management remains follow-up scope and is not +changed here. + ## Sub-package Commands Each Mosaic sub-package exposes its full API surface through the `mosaic` CLI. @@ -532,8 +567,14 @@ mosaic storage export --bucket agent-artifacts --output ./artifacts.tar.gz # Import data into storage mosaic storage import --bucket agent-artifacts --input ./artifacts.tar.gz -# Migrate data between tiers -mosaic storage migrate --from hot --to cold --older-than 30d +# Schema migration is unavailable in this release. The current storage wrapper shells +# directly to `pnpm --filter @mosaicstack/db db:migrate`; it is legacy N-1, +# uncertified, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation. +# Future schema migration is non-operative: external bootstrap → TLS/roles → runner +# --run → runner --verify → readiness. + +# Tier copy uses only the separately held secure migrate-tier route. Never use a legacy +# --from/--to storage-migrate command or pass a credential on argv. ``` --- diff --git a/docs/native-kanban-sot/DOCUMENTATION-CHECKLIST.md b/docs/native-kanban-sot/DOCUMENTATION-CHECKLIST.md new file mode 100644 index 00000000..017237f4 --- /dev/null +++ b/docs/native-kanban-sot/DOCUMENTATION-CHECKLIST.md @@ -0,0 +1,36 @@ +# Documentation Completion Checklist — Native Kanban/SOT Canon + +**Tracking:** Mosaic Stack issue #751 +**Scope:** Requirements and contract publication only; runtime implementation follows in separate slices. + +## Required artifacts + +- [x] Project `docs/PRD.md` exists; the workstream requirements refine its task/project-management scope. +- [x] Canonical workstream requirements published at `docs/requirements/native-kanban-sot.md`. +- [x] Mission manifest, task decomposition, frozen shared contract, and typed contract declarations included. +- [x] `docs/SITEMAP.md` updated. +- [x] Independent initial review and final GO report stored under `docs/reports/native-kanban-sot/`. +- [x] Task scratchpad stored under `docs/scratchpads/`. +- [ ] User/Admin/Developer guides — N/A for canon-only publication; required in implementation slices that change behavior or operations. +- [ ] OpenAPI and endpoint index — N/A until KBN-105 freezes implementation-ready endpoint contracts. + +## Structural and root hygiene + +- [x] Canonical requirements are under `docs/requirements/`. +- [x] Workstream artifacts are under `docs/native-kanban-sot/`. +- [x] Review reports are under `docs/reports/native-kanban-sot/`. +- [x] No new unscoped document was added to the `docs/` root. +- [x] Root mission/task rollups link to the workstream. + +## Review gate + +- [x] Author and independent reviewer are different agents. +- [x] KCR-001–016 closure was independently verified. +- [x] Ultron final gate returned GO with zero BLOCKER/HIGH findings. +- [x] Formatter, lint, typecheck, strict contract TypeScript, link, scope, and invariant publication validation passed in the current Stack toolchain. +- [ ] PR review, CI, squash merge, and issue closure remain required before publication completion. + +## Publishing + +- [x] Canonical source remains in-repository. +- [x] No external publishing platform is required for this internal architecture contract. diff --git a/docs/native-kanban-sot/INDEX.md b/docs/native-kanban-sot/INDEX.md new file mode 100644 index 00000000..6c5d7faa --- /dev/null +++ b/docs/native-kanban-sot/INDEX.md @@ -0,0 +1,67 @@ +# Native Kanban/SOT Canon + +**Status:** KCR-001–016 independently cleared; KBN-101 rc.16 current generic storage-wrapper authority remediation awaits independent exact-head re-review under issue [#771](https://git.mosaicstack.dev/mosaicstack/stack/issues/771) +**Date:** 2026-07-14 +**Implementation hold:** no feature implementation starts until this canon is squash-merged to `main` with terminal-green CI; after merge, every slice remains held until its KBN prerequisite graph is satisfied. + +## Artifacts + +| Artifact | Purpose | +| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Canonical requirements](../requirements/native-kanban-sot.md) | Canonical P0–P3 requirements, all seven ratified decisions, fixed invariants, thin MVP, recovery tiers, non-goals, and per-requirement acceptance criteria | +| [`MISSION-MANIFEST.md`](./MISSION-MANIFEST.md) | Mission/authority boundaries, exact role chain, gate model, mandatory SecReview triggers, Certifier final/no-merge rule, and collision-free slice ownership | +| [`TASKS.md`](./TASKS.md) | Dependency-ordered, bounded P0–P3 slices with IN/OUT scope, dependencies, shared contracts, file ownership, evidence, and USC coder2/3/4/5 parallelization | +| [`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 | +| [`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 | +| [`contracts/recovery-posture.v1.ts`](./contracts/recovery-posture.v1.ts) | Provider-neutral shape schema plus normative runtime refinement, cross-field constraints, and Lite/Standard/High-assurance defaults | +| [`tsconfig.json`](./tsconfig.json) | Strict no-emit project scope for linting and compiling the four frozen TypeScript contracts against the current Stack Drizzle declarations | +| [`DOCUMENTATION-CHECKLIST.md`](./DOCUMENTATION-CHECKLIST.md) | Publication documentation gate and implementation-slice deferrals | +| [KBN-101 exact-head security review](../reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) | Historical `da742ca` REQUEST CHANGES report retained as prior closure evidence; rc.16 awaits independent exact-head re-review after closing the current generic storage-wrapper authority HIGH finding | +| [Initial independent review](../reports/native-kanban-sot/canon-initial-review-no-go.md) | KCR-001–016 findings that blocked the first draft | +| [Final independent re-review](../reports/native-kanban-sot/canon-final-rereview-go.md) | Closure matrix, reproducible validation evidence, and GO verdict | +| [Ultron final gate](../reports/native-kanban-sot/ultron-final-go.md) | Final requirements, authority, schema, migration, recovery, decomposition, and evidence review GO | + +## Recommended USC lane partition + +| Lane | Natural seam | Exclusive ownership | +| ---------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **coder2** | Schema + migrations + recovery slice | Unified Drizzle schema, migration SQL/meta/journal/tests, then recovery parser/mechanism/runbook files | +| **coder3** | Domain + Gateway + MCP server | Workspace-safe repositories, DTOs/controllers/services, exact `apps/gateway/src/mcp/**` files, health proof, proposals, Coordinator persistence adapter | +| **coder4** | Pure Coordinator + tooling | `packages/coord` mechanical engine, CLI/MCP consumers, generated projection, one-way importer and cutover tooling; lane-serialized internally | +| **coder5** | Web | Tasks/Projects Kanban/List/detail and later Coordinator/migration-review UI | +| **Mos** | Serialized integration | Canon publication, frozen-contract changes, shared-root/exports, integration gates, merge authority | + +The safe order is KBN-010 → KBN-101 foundation → KBN-100 → KBN-101 deployed-role immutable-operation certificate → KBN-105, then coder3 Gateway/MCP server, coder4 CLI/projection, coder5 web, and coder2 recovery can proceed on disjoint files. KBN-100 is blocked on the KBN-101 foundation; real deployed-role certification—not synthetic test roles—is required before KBN-105. coder4 then runs pure Coordinator → importer → cutover tooling serially. No two active slices edit the same files. + +## Recovery defaults + +| Tier | RPO / RTO | WAL / PITR | Base backup | Restore / break-glass | Off-cluster | +| -------------- | ------------ | ------------------- | ----------- | ----------------------- | ----------------------------------------------- | +| Lite | 24h / 24h | disabled / disabled | daily | quarterly / annual | encrypted separate target | +| Standard | 1h / 8h | q15m / 14d | daily | quarterly / semiannual | encrypted separate object storage | +| High-assurance | **15m / 4h** | **q5m / 35d** | **daily** | **monthly / quarterly** | **encrypted base+WAL, separate failure domain** | + +These knobs affect recovery posture only. PostgreSQL remains the sole writable SOT in every tier. Fail-closed writes, generated-file non-authority, attributable post-recovery proposals, non-LLM Coordinator limits, and Certifier final-gate/no-merge authority are fixed for every tier. + +## Non-blocking implementation sub-decisions for Mos + +The source plan and ratified seven decisions resolve all build-blocking product choices. The following implementation-local selections remain for the owning slices/Mos and must not weaken v1: + +1. Exact PostgreSQL write-health probe SQL and bounded proof lifetime; authority and failures are frozen. +2. Dependency-cycle serialization mechanism (recursive CTE plus transaction/advisory lock or equivalent); required behavior is frozen. +3. Whether RLS lands in the first migration or immediately after the tested session-context pattern; workspace constraints/repository authorization are required from migration one. +4. Concrete off-cluster backup provider/bucket and selected production recovery tier; High-assurance minima are frozen if selected. +5. Cutover reconciliation thresholds and stabilization duration, to be owner-approved before P3 execution. + +None authorizes a second writer, dual sync, LLM scheduling, Coordinator gate waiver/merge, or Certifier merge authority. + +## Publication validation evidence + +- Concrete TypeScript contracts are formatted with repository Prettier. +- All four contracts pass strict TypeScript no-emit checking against the current Stack Drizzle toolchain. +- Contract remediation and KCR-001–016 traceability are recorded in the issue scratchpad and linked review reports. +- Independent re-review returned GO with KCR-001–016 closed; implementation remains held until canon merge and the dependency-ordered KBN prerequisites complete. diff --git a/docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md b/docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md new file mode 100644 index 00000000..29bc5fe9 --- /dev/null +++ b/docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md @@ -0,0 +1,415 @@ +# KBN-010 — Threat, Authorization, and Constraint-Impact Gate + +- **Issue:** [#753](https://git.mosaicstack.dev/mosaicstack/stack/issues/753) +- **Gate status:** **PASS / GO** +- **Reviewed baseline:** `origin/main` at `49e8a54` (2026-07-14) +- **Frozen target:** `SHARED-CONTRACT.md` v1.0.0-rc.4 and `contracts/*.v1.ts` +- **Disposition input:** contract commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`, tree `7ebab8fa530a7180036928cea9527f808548aa14` +- **Scope:** documentation and future-test planning only; no runtime, schema, migration, API, configuration, dependency, CI, or deployment change + +## 1. Decision + +KBN-010 is **PASS / GO** against frozen contract rc.4. The original rc.3 finding remains historical detection evidence: + +- **KBN010-SI-001 — rc.3 invalid mission composite-FK candidate key.** At rc.3, `missionsV1` declared a primary key on `id` and a unique key on `(workspace_id, project_id, id)`, but not a candidate key on `(workspace_id, id)`. Both `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` referenced exactly `(missions.workspace_id, missions.id)`. PostgreSQL requires the referenced column list of a foreign key to match a non-partial unique/primary candidate key; uniqueness of `id` alone did not satisfy that two-column reference. The rc.3 DDL was therefore invalid, and KBN-010 correctly blocked it. + +Contract rc.4 resolves SI-001 by adding the non-partial `missions_workspace_id_uidx` candidate key on `(workspace_id, id)` while retaining the global `id` primary key and the project-congruent `(workspace_id, project_id, id)` key. Both polymorphic child FKs retain their exact workspace-safe ordered columns and `ON DELETE RESTRICT`; no target, tenancy, project-congruence, exactly-one-target, N-1, rollback, no-cascade, identity, approval, or fencing authority is weakened. + +Independent Homelab non-author schema/security review returned **APPROVE** for the exact rc.4 commit/tree/content and found no collision with #757 connector fencing. SI-001 has no unresolved contract/schema-design impact. + +This GO completes the KBN-010 analysis/review prerequisite only. It does **not** claim that runtime schema or migration DDL exists. KBN-100 remains held and may be released only after this PR squash-merges, the merged change reaches terminal-green CI on `main`, and issue #753 closes. + +### 1.1 Independent rc.4 evidence identity + +- **Commit:** `3f6a3387b419eb99453ee10dd25ba888faaab0b5` +- **Tree:** `7ebab8fa530a7180036928cea9527f808548aa14` +- **Stable full-index SHA-256:** `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42` +- **Stable patch-id:** `058cf98026fcd1043703c866aee047c8bb144740` +- **Verdict:** Homelab independent non-author schema/security review **APPROVE**. +- **Reviewed conclusions:** the candidate key repairs both dependent FKs; tenant safety, polymorphic exactly-one-target semantics, RESTRICT/no-cascade behavior, and N-1/rollback semantics remain valid; #757 uses separate tables/indexes/FKs/identity/fence authority and has no collision. + +A command-rendered patch SHA may differ when Git rendering options, headers, or command form differ. That rendering digest is non-authoritative. Canonical review identity is the Git commit object plus tree and exact file content; the stable full-index digest and stable patch-id above are corroborating identities. + +## 2. Method and trust boundaries + +### 2.1 Inputs inspected + +- Canonical requirements: `docs/requirements/native-kanban-sot.md`. +- Workstream manifest and read-only task plan. +- Frozen health, schema, Mechanical Coordinator, and recovery contracts in full. +- Actual current-main schema, Better Auth guard/scope helpers, project/task/mission/team controllers and repositories, fleet backlog, and `TASKS.md` parser/writer. +- Issue #753 through the Mosaic provider wrapper. + +### 2.2 Current-main exposure that the target must replace, not inherit + +| Current-main fact | Constraint on future implementation | +| --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Teams are global; projects, missions, tasks, agents, and fleet backlog have no `workspace_id`. | KBN-100 must add the workspace boundary and KBN-110 must query by server-derived workspace in every repository operation. | +| `AuthGuard` authenticates a Better Auth user, while `scopeFromUser` falls back through optional tenant/team/org claims and finally user ID. | Kanban tenancy must derive from an authenticated **active workspace membership**, not this compatibility fallback or caller data. | +| Team list/get/member endpoints return global team data to any authenticated user. | New Kanban endpoints must use a uniform no-oracle denial and must not reuse global team lookup as authorization. | +| Project/task repositories load and mutate by bare IDs; controller checks are separate and sometimes distinguish not-found from forbidden. | Workspace predicates and authorization must be inside the authoritative transaction/repository command path. | +| Tasks can have nullable project/mission links, free-text assignee, JSON tags, no aggregate version, and no fence. | Expand/backfill/quarantine must precede NOT NULL/composite constraints; new commands cannot trust legacy fields. | +| `mission_tasks.status` is a second status writer. | Pre-expand must prohibit it as a write source and later retire it only after N-1 evidence. | +| Fleet `backlog` has global JSON dependencies and TTL claims without workspace, assignment, approval, session, or fencing. | It must be frozen and imported as non-dispatching shadow data; it cannot be adapted into the canonical lease path. | +| `packages/coord/src/tasks-file.ts` parses and mutates `TASKS.md`. | KBN-120 must replace production use with generated, read-only projection code and prove there is no import/mutation path. | +| No Kanban transaction-local health proof, semantic audit/event chain, change proposals, canonical outbox, approval binding, or fenced lease model exists. | These are new frozen invariants, not behaviors that may be inferred from current endpoints. | + +## 3. Authorization matrix + +The exact route/DTO freeze belongs to KBN-105. This matrix fixes the minimum authorization behavior that freeze and later implementation must preserve. + +| Principal/state | Permitted authority | Required authoritative checks | Explicit denials | +| -------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Unauthenticated caller | Public health observation only, if deployment exposes it | Health DTO validation; no proof field accepted | All canonical reads/mutations; health observation never authorizes a write | +| Active workspace `owner`/`admin` user | Policy-allowed workspace administration and domain commands | Better Auth session; active membership; server-derived workspace; command-family role; expected version/idempotency | Foreign workspace, suspended workspace, revoked membership, caller workspace override | +| Active workspace `member` user | Policy-allowed project/task/proposal commands | Active membership plus project/team capability and target checks in the same transaction | Admin, approval, purge, service-only Coordinator, and unrelated project commands | +| Active workspace `auditor` user | Workspace-scoped reads and audit/evidence inspection | Active membership and read capability | Every mutation, approval, lease, token issuance, purge | +| Active workspace `service` identity | Only explicitly issued command families | Credential maps to workspace+agent+session; agent enabled; session live; role/capability allowlist; token expiry/audience; DB recheck per command | Raw DB credentials, user/admin fallback, cross-workspace scope, command families absent from token and registry | +| Enabled agent with live session | Agent commands matching its declared and policy-approved specialist role/capabilities | Exact workspace+agent+session binding, heartbeat/state, assignment target, lease, current decimal-string fence | Ended/offline/degraded session where policy disallows; disabled agent; another assignment/session/fence | +| Mechanical Coordinator engine | Pure eligibility/order/expiry decisions from immutable snapshots | Complete workspace-local snapshot and policy revision | Authentication, ID loading, SQL, proof minting, scope invention, approval, certification, merge | +| Coordinator persistence service | Service-only assignment/lease/checkpoint/recovery commands | Fresh transaction-local proof; locks; current assignment/approval/task/session/policy/fence | Public/user proof-by-value, stale approval/policy, direct completion/certification/merge | +| Reviewer/SecReview/Certifier | Attributable evidence decisions allowed by gate policy | Active authority, author differs from reviewer, mandatory SecReview classification, immutable artifacts | Self-review; missing evidence; Certifier merge/issue-close/release | +| Break-glass retention operator | Narrow, time-bounded purge procedure only | Separate break-glass authority, reason, scope, approvals, immutable pre-purge evidence, semantic audit, post-action reconciliation | Normal application role DELETE/UPDATE, bulk unscoped purge, unaudited hard delete | +| Revoked/expired/disabled identity or ended session | None beyond policy-permitted public observation | Revocation/lifecycle checked from PostgreSQL on every command | Cached token/Valkey state cannot preserve authority | + +**No-oracle rule:** authentication may return 401, but once authenticated, a foreign-workspace, nonexistent, inaccessible, or wrong-project identifier must follow the one KBN-105-frozen 404/403 policy with the same response shape and no foreign metadata, timing-derived detail, or WebSocket/MCP discrepancy. + +## 4. Threat matrix + +Every disposition is against the frozen target, not a claim about current-main behavior. + +| ID | Attacker or failure | Asset | Precondition and abuse path | Frozen preventive/detective control | Required schema/API/negative-test evidence | Future owner | Residual risk | Disposition | +| --- | --------------------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| T01 | Authenticated user supplies a foreign workspace/resource ID | Tenant confidentiality and integrity | Caller knows or guesses project/task/mission/team IDs and probes REST, MCP, WebSocket, repository, or Coordinator paths | `workspace_id` on every canonical row; composite relations; server-derived tenant; uniform no-oracle denial | Composite FK/unique DDL; every repository predicate includes workspace; N100-01/02, N110-01..05, N130-01 | KBN-100, 105, 110, 130 | Timing/volume side channels require operational review | Controlled after evidence | +| T02 | Revoked or inactive member retains an old session | Ownership and mutation authority | Authentication remains valid after workspace membership revocation | Active membership rechecked in the authoritative transaction for owners, principals, proposers, and decision actors | Active/inactive membership fixtures; N100-03, N110-06/07; no cached membership authority | KBN-100, 110 | Better Auth session may remain valid for unrelated features | Controlled after evidence | +| T03 | User joins/forges a team relation outside its workspace | Team-owned projects and tasks | Global-current-main team behavior or a stale membership is reused | Team is intra-workspace only; workspace/team composites; active workspace membership precedes team authorization | Cross-workspace team/member/owner insert and command denials; N100-04/05, N110-08 | KBN-100, 110 | Team-role policy mistakes remain possible | Controlled after evidence | +| T04 | Same-workspace IDs from a different project are combined | Planning hierarchy integrity | Valid mission/milestone/parent/current-milestone UUIDs are substituted | Project-congruent composite relations and serialized hierarchy validation | Mission/milestone/parent/current milestone mismatch and parent-cycle tests; N100-06..10, N110-09 | KBN-100, 110 | Deep hierarchy checks can be expensive | Controlled after evidence | +| T05 | Foreign or unrelated evidence/link/artifact IDs are attached | Review and audit truth | Caller has a valid same-workspace or foreign artifact UUID | Workspace-aware joins; immutable artifact digest/revision; semantic same-target validation in authoritative transaction | Mixed-workspace and same-workspace wrong-task/mission checkpoint/approval evidence tests; N100-11..14, N210-15/16 | KBN-100, 110, 210 | Same-workspace semantic validation is application-enforced | Controlled after evidence | +| T06 | Stolen, over-scoped, or replayed service token | Coordinator and task mutation authority | Service credential is accepted as admin/user or claims are trusted without DB state | Command-family least privilege; agent/session workspace binding; no raw DB credentials; enabled/live state checked per command | Auth registry fixtures prove audience/expiry/role/capability; revoked agent and ended session denials; N105-01, N110-10..13, N210-01/02 | KBN-105, 110, 210 | Credential theft until expiry/revocation check | Controlled after evidence | +| T07 | Caller forges public `healthy` or replays a stale health response | Sole-writer/fail-closed invariant | Public health body or caller field reaches mutation context | Public DTO is observation only; public DTOs reject proof/health fields; Gateway mints internal proof after live PG transaction probe | Contradictory union and forbidden-field tests; N105-02, N110-14..17 | KBN-105, 110, 140 | Health endpoint can still be used for reconnaissance | Controlled after evidence | +| T08 | Internal stale, wrong-policy, or wrong-transaction proof is reused | Transaction integrity | A branded value leaks or an adapter fails to revalidate it | Non-exported brand; transaction identity, `checkedAt <= now < validUntil`, and policy revision revalidated immediately before mutation | Wrong transaction, expiry boundary, future timestamp, policy mismatch, commit-after-expiry tests; N110-18..22 | KBN-110, 140 | In-process code can bypass TypeScript; runtime checks are mandatory | Controlled after evidence | +| T09 | DB/transport uncertainty is mislabeled as deliberate denial or conflict | Safe retry and exactly-once result | Timeout occurs before/after commit and client changes key or retries 503 | Exact 503/502/504/timeout/409 union; unknown outcome retries only with same idempotency key | Exhaustive fixture mapping and commit-before-timeout replay; N105-03, N110-23..27, N120-01/02 | KBN-105, 110, 120, 140 | External client may ignore retry rules | Controlled after evidence | +| T10 | Assignment payload forges task version, target agent/session, role, expiry, or proposer | Work routing authority | Lease service trusts command DTO rather than persisted assignment | Persisted assignment identity; exactly-one principal/proposer; exact agent/session composite; acquire accepts IDs then reloads+locks | Cross-workspace and same-workspace target substitutions, stale task version, invalid role, expired assignment; N100-15..18, N210-03..08 | KBN-100, 200, 210 | Compromised authorized proposer can make harmful proposals | Controlled by approval/audit | +| T11 | Approval proof is forged by value or borrowed from another assignment | Gate integrity | Caller submits `approved=true`, unrelated decision ID, stale policy, or self-approval | Relational approval bound to assignment; lock/reload; policy revision; author≠reviewer and mandatory SecReview | No proof-by-value DTO; wrong assignment/task/workspace/policy/actor/decision tests; N105-04, N210-09..14, N230-01 | KBN-105, 210, 230 | Colluding principals remain an organizational risk | Controlled after evidence | +| T12 | Revoked policy or expired proposal/assignment is raced against lease acquisition | Routing policy | Approval and lease transactions do not lock/revalidate current rows | Lock assignment, approval, task, target session; compare current policy and expiry inside fresh-proof transaction | Concurrent revoke/expire/acquire tests with one valid terminal result; N210-17..19 | KBN-210, 230 | Clock skew if DB time is not canonical | Controlled after evidence | +| T13 | Stale worker sends ack/heartbeat/checkpoint/review after reassignment | Canonical task and evidence state | Old process retains task/session IDs | Task-row-locked atomic monotonic bigint fence; every worker command carries exact lease/session/fence | Lower, expired, future, and other-task fences denied; old worker loses after new lease; N100-19/20, N210-20..24 | KBN-100, 210, 230 | Signed bigint exhaustion is theoretical | Controlled after evidence | +| T14 | JavaScript precision truncates a fence | Stale-worker exclusion | bigint token is serialized as number above `2^53-1` | Drizzle bigint and decimal-string wire type only | `9007199254740993` and near-`int8` boundary round trips; numeric JSON rejected; N105-05, N210-25 | KBN-105, 210 | Nonconforming external clients | Controlled after evidence | +| T15 | Checkpoint/evidence from another lease/task/session is submitted | Recovery and certification evidence | Same-workspace valid IDs are mixed | Exact lease composite binds workspace+task+assignment/session+fence; checkpoint composite binds lease+fence; evidence join plus semantic artifact-owner check | Same-workspace mismatched task/assignment/lease/session/checkpoint/artifact tests; N100-21..23, N210-26..31 | KBN-100, 210 | Artifact URI target may disappear outside DB | Controlled with digest/retention | +| T16 | Outage note or pending/rejected proposal mutates/orders work | Sole SOT and gate integrity | Importer/UI treats note/proposal as task state | Proposals are inert; only explicit accept invokes normal typed command after recovery | Row/outbox/task counts unchanged for pending/rejected; no readiness/dependency/lease effect; N110-28..31 | KBN-110, 140 | Humans may act outside Mosaic operationally | Accepted as attributable residual | +| T17 | Submission event is missing, foreign, or for another proposal | Proposal audit chain | Caller supplies an existing event UUID | Preallocated proposal ID; event-first same transaction; workspace composite FK; exact event type/aggregate/version semantic check | Missing/foreign/wrong-type/wrong-proposal event rolls back event+proposal; N100-24/25, N110-32..36 | KBN-100, 110 | Semantic checks are transaction code, not only FK | Controlled after evidence | +| T18 | Acceptance borrows an unrelated command event | Proposal and target integrity | Same-workspace event exists for another target/command/proposal | Accept locks proposal+target, executes normal command, requires workspace/target match, causation=submission event, payload proposal ID | Foreign, wrong target/type/command/causation/payload event aborts target/event/proposal atomically; N100-26, N110-37..43 | KBN-100, 110 | Event payload schema drift | Controlled by KBN-105 fixtures | +| T19 | Application role updates/deletes audit, approval evidence, checkpoint, or artifact | Nonrepudiation | Broad DB grants or parent cascade exists | INSERT/SELECT-only application roles; RESTRICT parent deletes; archive/cancel normal lifecycle | Role-level UPDATE/DELETE denied; parent delete RESTRICT; digest unchanged; N100-27..31 | KBN-100 | DB superuser can alter state | Break-glass/infra audit residual | +| T20 | Break-glass purge is used as routine deletion or erases its own evidence | Retention and incident forensics | Elevated credential available | Separate audited retention procedure, bounded scope, reason, pre/post evidence, authority separation | Normal role denied; expired/missing approval denied; purge cannot delete its authorizing audit package; N115-01, N230-02/03 | KBN-115, 230 | Privileged DBA compromise | Accepted operational residual | +| T21 | PostgreSQL unavailable or partitioned | Canonical state | Public health/Valkey remains live while transaction probe fails | Fail closed; no alternate writer/hidden queue; 503 only for proven not-applied; transport uncertainty remains unknown | Fault injection proves DB rows/outbox/files/Valkey unchanged on deliberate denial; commit-unknown replay; N110-44..48, N140-01 | KBN-110, 140, 230 | Availability loss is intentional | Accepted by Option A | +| T22 | Valkey unavailable, duplicated, stale, or partitioned | Scheduling notifications | Queue wake is treated as truth or publication fails | Valkey derived/expendable; transactional outbox in PG; idempotent publisher; recovery from PG | Commit with Valkey down leaves pending outbox; replay publishes once logically; stale wake reloads PG; N110-49, N140-02, N230-04..06 | KBN-110, 210, 230 | Duplicate at-least-once delivery | Consumers must be idempotent | +| T23 | Coordinator restarts between assignment, lease, checkpoint, or outbox steps | Durable orchestration truth | Process-local cache is treated as authority | PostgreSQL stores assignments, execution state, leases, fences, checkpoints, events, outbox; `recoverFromPostgres` | Restart at every transaction boundary reconstructs identical active/expired/pending sets without Valkey/files; N210-32..36, N230-07 | KBN-210, 230 | Recovery latency | Controlled after evidence | +| T24 | Dependency cycle or concurrent reciprocal edge | Readiness and dispatch safety | Two transactions each see an acyclic graph before inserting | Unique directed edge; no self-edge; serialized recursive cycle check; readiness evaluates all blockers | Self/duplicate/cycle and concurrent A→B/B→A tests; all predecessor property test; N100-32..35, N200-01/02 | KBN-100, 200, 230 | Very large DAG performance | Bounded operational residual | +| T25 | Parent-task cycle or project-incongruent relation | Planning hierarchy | Valid same-workspace IDs are arranged into an invalid tree | Project-congruent composites; serialized parent-cycle/orphan validation required by REQ-PLAN-001 | Self/indirect parent cycle, orphan, and cross-project mission/milestone/parent tests; N100-06..10 | KBN-100, 110 | Cycle validation is service/transaction enforced | Controlled after evidence | +| T26 | Concurrent update, duplicate retry, or idempotency payload drift | Aggregate consistency | Two clients use same version/key with different payloads | Expected-version check; semantic event and outbox in same transaction; key returns prior immutable result only for identical command | One update wins; stale gets 409; duplicate identical returns prior; payload drift rejected; N110-50..54, N140-03 | KBN-105, 110, 140 | Long-lived clients face visible conflicts | Intentional user-visible residual | +| T27 | State/event/outbox partial commit | Audit and notification consistency | Separate transactions or exception after state write | One PostgreSQL transaction for state+semantic event+outbox | Failure injected after each insert rolls all three back; success revisions align; N110-55..58 | KBN-110, 140 | Outbox publication remains asynchronous | Controlled after evidence | +| T28 | Malicious/incorrect importer injects foreign workspace data or dispatchable work | Migration integrity | Source keys collide, lineage is absent, or importer has direct DB authority | Immutable source snapshots/checksums; one-way Gateway/migration-only port; workspace-safe idempotent modes; shadow records cannot dispatch | Foreign/malformed/duplicate/partial-resume/lineage checksum and no-dispatch tests; N300-01..08 | KBN-300, 330 | Source data may be semantically ambiguous | Quarantine and owner sign-off | +| T29 | Cutover leaves legacy writer or forward/reverse sync active | Sole-writer invariant | Credentials/processes survive switch or rollback is improvised | Writer inventory, freeze, final delta, Gateway switch, credential shutdown, no dual write; rollback authority changes after first DB mutation | Process/credential inventory; concurrent-writer assertion; before/after-mutation rollback rehearsal; N320-01..06, N330-01 | KBN-320, 330, 340 | Missed external automation | Owner-gated residual | +| T30 | Generated `TASKS.md`/`mission.json` is edited or parsed into DB | Canonical state | Current-main parser/writer remains reachable or file watcher imports changes | Generated non-authoritative header/IDs/time/revision; no production importer; regenerate/overwrite only | Static import search, tamper/regeneration, read-only permission, source-revision parity; N120-03..07, N140-04 | KBN-120, 140 | Humans may mistake snapshots for live data | Header and docs mitigate | +| T31 | N-1 compatibility copies legacy ambiguity into canonical authority | Data integrity | Nullable/global/current-main fields are guessed during backfill | Nullable-first expand; deterministic mapping or quarantine; checksums; no new-only status before switch; legacy fields retained | Production-shape, ambiguous owner/assignee, status shadow, JSON/config/digest, rollback tests; N100-36..44 | KBN-100 | Quarantined records require human decision | Controlled by signed reconciliation | +| T32 | Recovery posture claims durability not provided by mechanisms | Availability and audit retention | Shape-only validation or optimistic RPO is accepted | Normative validator; WAL/PITR/RPO/storage/high-assurance constraints; mechanism and restore evidence | Unknown/impossible/weakened configuration plus actual mechanism/restore tests; N115-02..08 | KBN-115 | Backup operator or storage compromise | Separate failure domain residual | +| T33 | rc.3 frozen DDL could not create mission-scoped evidence/approval FKs | Tenant/evidence relational integrity | KBN-100 generated DDL from the rc.3 contract without an exact composite candidate key | rc.4 adds non-partial `missions_workspace_id_uidx(workspace_id,id)` before both dependent FKs while retaining global and project-congruent keys | KBN-100 must execute N100-45..50: exact-key reconciliation, candidate-before-FKs, duplicate feasibility, empty/prod/N-1/rollback, and both-child foreign-workspace negatives | KBN-100 after PR/CI/#753 release | Runtime DDL remains unimplemented and must prove the frozen order | **Resolved by rc.4 + independent APPROVE; implementation evidence remains required** | + +## 5. Constraint-impact matrix + +| Impact ID | Required invariant | Frozen schema impact | API/transaction impact | Required evidence | Owner | Status | +| --------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------- | +| CI-01 | Hard workspace tenancy and no oracle | `workspace_id`, workspace-aware unique/FKs on all canonical rows | Server-derived workspace; uniform denial on all surfaces | N100-01..14; N110-01..09; N130-01 | KBN-100/105/110/130 | Resolved by frozen controls | +| CI-02 | Active user membership | Membership row plus unique `(workspace_id,user_id)`; active state retained | Recheck active membership in same authoritative transaction | N100-03; N110-06/07 | KBN-100/110 | Resolved; not FK-only | +| CI-03 | Service identity least privilege/revocation | Agent/session workspace, lifecycle, state, roles, capabilities | Token maps to exact agent/session; command-family allowlist; DB recheck; no admin/raw DB fallback | N105-01; N110-10..13; N210-01/02 | KBN-105/110/210 | Resolved at auth/API layer | +| CI-04 | Project-congruent hierarchy | Composite project/mission/milestone/parent/current-milestone relations | Lock/serialized parent-cycle and orphan validation | N100-06..10 | KBN-100/110 | Resolved; cycle behavior required | +| CI-05 | Health-proof authority | Internal branded proof has transaction/time/policy fields | Probe and revalidate on same PG transaction; no public field | N105-02/03; N110-14..27 | KBN-105/110 | Resolved by frozen controls | +| CI-06 | Assignment/approval identity | Exactly-one principal/proposer, exact agent/session assignment, relational approval | Reload+lock all IDs; compare version/target/state/expiry/policy/decision | N100-15..18; N210-03..19 | KBN-100/210 | Resolved by frozen controls | +| CI-07 | Monotonic bigint fencing | Durable bigint counter, exact lease/fence keys, one active lease | Atomic increment/RETURNING; decimal-string DTO; reject every stale worker command | N100-19..23; N210-20..31 | KBN-100/105/210 | Resolved by frozen controls | +| CI-08 | Proposal event chain | Both workspace-aware event FKs; event table created first | Exact submission/acceptance semantic checks in one transaction | N100-24..26; N110-28..43 | KBN-100/110 | Resolved; semantic checks not FK-only | +| CI-09 | Immutable audit/evidence retention | RESTRICT parents; INSERT/SELECT-only immutable tables | Archive/cancel normal flow; separately authorized purge | N100-27..31; N115-01; N230-02/03 | KBN-100/115/230 | Resolved by frozen controls | +| CI-10 | DB/Valkey/outbox/restart semantics | PG outbox and durable orchestration rows | Fail closed; same-key uncertainty retry; Valkey reloads PG; restart from PG | N110-44..49; N140-01/02; N230-04..07 | KBN-110/210/230 | Resolved by frozen controls | +| CI-11 | DAG/race/idempotency/version | Unique edge; self check; event idempotency; aggregate versions | Serialized recursive cycle check; payload binding; expected-version conflict | N100-32..35; N110-50..58; N200-01/02 | KBN-100/110/200 | Resolved by frozen controls | +| CI-12 | Import/cutover trust boundary | Lineage/artifact/event fields; shadow state cannot dispatch | One-way scoped importer, freeze, no direct DB/file authority, no dual writer | N300-01..08; N320-01..06 | KBN-300/320/330 | Resolved by frozen controls | +| CI-13 | Generated-file no-import | No canonical file schema/import contract | Projection-only package; static reachability check removes current parser from production Kanban paths | N120-03..07; N140-04 | KBN-120/140 | Resolved by frozen controls | +| CI-14 | Mission-scoped artifact and approval FKs | rc.4 adds non-partial `missions_workspace_id_uidx(workspace_id,id)` and retains global/project-congruent keys | KBN-100 must emit the candidate before both exact RESTRICT FKs and preserve N-1/rollback order | N100-45..50: exact reconciliation, duplicate feasibility, empty/prod/N-1/rollback, and separate artifact/approval foreign-workspace negatives | KBN-100 after PR/CI/#753 release | **Resolved by rc.4 and independent APPROVE; future executable evidence required** | + +## 6. Exact future negative-test catalog + +These names are normative evidence identifiers for future slices. Equivalent test-file names are acceptable only if traceability retains these IDs and expected outcomes. + +### KBN-100 — schema and migration + +- **N100-01** reject every canonical child row whose `workspace_id` differs from its parent. +- **N100-02** reject foreign-workspace link, artifact, proposal target, dependency, assignment, lease, checkpoint, approval, and event relationships. +- **N100-03** reject an inactive/revoked member as accountable owner, proposer, decision actor, archive actor, or user principal in the authoritative command transaction. +- **N100-04** reject a team/project relation crossing workspaces. +- **N100-05** reject a team authorization path when the user lacks active membership in the team's workspace. +- **N100-06** reject task→mission project mismatch. +- **N100-07** reject task→milestone and project→current-milestone project mismatch. +- **N100-08** reject task→parent project mismatch and self-parent. +- **N100-09** reject indirect parent cycles under concurrent transactions. +- **N100-10** reject mission→milestone project mismatch/orphan. +- **N100-11** reject checkpoint artifact from another workspace. +- **N100-12** reject checkpoint artifact owned by another same-workspace task/mission unless an explicitly frozen evidence rule permits it. +- **N100-13** reject approval evidence from another workspace. +- **N100-14** reject same-workspace approval evidence unrelated to the approval target. +- **N100-15** reject zero/multiple assignment principals and zero/multiple proposers. +- **N100-16** reject target session without its exact target agent. +- **N100-17** reject assignment task/agent/session crossing workspaces. +- **N100-18** reject non-positive task version and expired assignment acquisition. +- **N100-19** concurrent lease insert permits one active lease and returns one winner. +- **N100-20** successive leases return strictly increasing bigint fences. +- **N100-21** reject checkpoint with another task, lease, or fence. +- **N100-22** reject duplicate/non-monotonic checkpoint sequence. +- **N100-23** reject evidence join for a mismatched checkpoint/task. +- **N100-24** proposal insert without exact submission event fails atomically. +- **N100-25** foreign/wrong-type/wrong-proposal submission event fails atomically. +- **N100-26** foreign/wrong-target/unrelated acceptance event fails atomically. +- **N100-27** application role cannot UPDATE/DELETE `task_events`. +- **N100-28** application role cannot UPDATE/DELETE checkpoints/artifacts/evidence joins. +- **N100-29** parent hard delete is RESTRICTed while audit/evidence children exist. +- **N100-30** archive does not alter canonical lifecycle status. +- **N100-31** purge without break-glass authority/evidence is denied. +- **N100-32** reject dependency self-edge and duplicate directed pair regardless of type. +- **N100-33** reject direct and indirect dependency cycles. +- **N100-34** concurrent reciprocal dependency inserts cannot both commit. +- **N100-35** readiness remains false until every blocking predecessor and completion condition passes. +- **N100-36** empty DB migration succeeds after the contract amendment. +- **N100-37** production-shape expand retains all legacy declarations. +- **N100-38** crash/resume backfill is idempotent and checksum-stable. +- **N100-39** ambiguous workspace/owner/assignee is quarantined, never guessed. +- **N100-40** no `ready`/`in_review` status is emitted to N-1 readers before switch. +- **N100-41** `mission_tasks.status` cannot remain a write source. +- **N100-42** tags/assignee/date/mission JSON/config/description/agent fields reconcile without loss. +- **N100-43** claimed fleet backlog rows are quarantined and imported rows cannot dispatch. +- **N100-44** pre-switch rollback works while post-first-mutation rollback requires freeze/reconciliation. +- **N100-45** reconcile both exact child FK column lists to the rc.4 `(workspace_id,id)` mission candidate while retaining the global `id` primary key and `(workspace_id,project_id,id)` key. +- **N100-46** empty-DB migration creates `missions_workspace_id_uidx` before `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk`. +- **N100-47** production-shape preflight finds no duplicate `(workspace_id,id)` groups, preserves global `id` uniqueness, and applies the candidate before both dependent FKs. +- **N100-48** N-1 startup/read/write remains unchanged; pre-switch rollback drops both dependents before the candidate and preserves the global/project-congruent keys. +- **N100-49** artifact insert using a valid mission ID paired with a foreign workspace fails before commit. +- **N100-50** approval-decision insert using a valid mission ID paired with a foreign workspace fails before commit. + +### KBN-105/KBN-110/KBN-120/KBN-130/KBN-140 — API and P1 + +- **N105-01** every route has an explicit user/service command-family policy; user/admin tokens cannot call service-only Coordinator mutations. +- **N105-02** public DTO validation rejects `writeProof`, internal context, body `workspaceId`, and caller-asserted health. +- **N105-03** fixture exhaustiveness prevents 503, 502/504/timeout, and 409 cross-mapping. +- **N105-04** approval DTO accepts an ID and decision command only, never approval proof-by-value. +- **N105-05** all fence fields accept/emit decimal strings and reject JSON numbers. +- **N110-01** listing with a foreign `workspaceId` or foreign filter ID follows the frozen no-oracle denial and returns no rows/counts/cursors. +- **N110-02** get by foreign or nonexistent aggregate ID has the same frozen denial shape and no foreign metadata. +- **N110-03** create/update/archive with a foreign owner, parent, project, mission, milestone, tag, or target ID is denied before mutation. +- **N110-04** dependency/proposal commands with foreign target IDs are denied with unchanged state/event/outbox counts. +- **N110-05** REST, MCP, WebSocket, and internal Coordinator paths produce equivalent no-oracle behavior for the same foreign ID. +- **N110-06** a revoked/inactive owner is denied even with a still-valid Better Auth session. +- **N110-07** stale membership/team cache cannot authorize a proposer, decision actor, archive actor, or principal after revocation. +- **N110-08** a team ID from another workspace cannot authorize or own the command target. +- **N110-09** same-workspace but wrong-project mission/milestone/parent IDs are denied inside the transaction. +- **N110-10** an expired service token is denied before repository access. +- **N110-11** an audience- or workspace-mismatched service token is denied without an existence oracle. +- **N110-12** an over-scoped service token cannot call a command family absent from its role/capability allowlist. +- **N110-13** disabled agent or ended session revokes service-token command authority immediately on PostgreSQL recheck. +- **N110-14** contradictory public health state/boolean combinations fail validation. +- **N110-15** Valkey-only liveness cannot mint or substitute a PostgreSQL write proof. +- **N110-16** caller-forged public `healthy` cannot enter internal mutation context. +- **N110-17** public REST/MCP/CLI bodies containing health/proof fields are rejected. +- **N110-18** an expired internal proof produces no state/event/outbox write. +- **N110-19** a future-dated or not-yet-valid proof produces no write. +- **N110-20** a policy-revision-mismatched proof produces no write. +- **N110-21** a proof minted on another transaction/connection produces no write. +- **N110-22** a proof that expires before the final pre-mutation check produces no write. +- **N110-23** deliberate read-only/write-unavailable denial maps only to authoritative 503/not-applied/non-retryable. +- **N110-24** timeout before commit maps to transport-unknown and permits only same-key retry. +- **N110-25** timeout after commit maps to transport-unknown and same-key retry returns the committed canonical result once. +- **N110-26** expected-version mismatch maps only to 409/not-applied/non-retryable. +- **N110-27** recovery replay with a changed idempotency key cannot masquerade as the original uncertain request. +- **N110-28** pending proposal cannot alter target fields/status/rank/version. +- **N110-29** rejected proposal cannot affect readiness, dependencies, or gates. +- **N110-30** pending/rejected proposal cannot create an assignment or lease. +- **N110-31** direct proposal-row state manipulation cannot bypass normal command execution. +- **N110-32** proposal submission without a submission event rolls back fully. +- **N110-33** foreign-workspace submission event rolls back fully. +- **N110-34** wrong aggregate/event type submission event rolls back fully. +- **N110-35** same-workspace event for another proposal rolls back fully. +- **N110-36** submission event with wrong previous/new version semantics rolls back fully. +- **N110-37** foreign-workspace acceptance event rolls back proposal, target, event, and outbox. +- **N110-38** same-workspace event for another target aggregate rolls back acceptance. +- **N110-39** event from an unrelated normal command rolls back acceptance. +- **N110-40** event caused by a different submission event rolls back acceptance. +- **N110-41** event whose payload lacks or changes `changeProposalId` rolls back acceptance. +- **N110-42** event for another proposal with the same target/command rolls back acceptance. +- **N110-43** missing accepted-command event after target handling rolls back the entire transaction. +- **N110-44** read-only-degraded denial changes no DB row/outbox/file/Valkey/provider state. +- **N110-45** write-unavailable denial changes no DB row/outbox/file/Valkey/provider state. +- **N110-46** PostgreSQL disconnect cannot redirect a command to any fallback writer. +- **N110-47** commit uncertainty remains `unknown` and never becomes a fabricated 503/not-applied result. +- **N110-48** same-key replay after recovery returns one canonical result with no duplicate event/outbox row. +- **N110-49** Valkey publication failure leaves committed PG outbox pending and replayable. +- **N110-50** two same-version updates produce one winner and one visible 409 loser. +- **N110-51** identical duplicate key+payload returns the prior immutable result without another event/outbox row. +- **N110-52** same key with payload/command drift is rejected as an idempotency conflict. +- **N110-53** the same key in another workspace cannot reveal or reuse the first workspace's result. +- **N110-54** stale reconnect/update cannot silently overwrite a newer aggregate revision. +- **N110-55** failure after state write but before semantic event rolls back state. +- **N110-56** failure after semantic event but before outbox rolls back state and event. +- **N110-57** failure after outbox insert but before commit rolls back state, event, and outbox. +- **N110-58** success commits matching aggregate/event/outbox revisions and correlation/causation. +- **N120-01** CLI never retries an authoritative 503 deliberate denial. +- **N120-02** CLI retries only transport-unknown outcomes and preserves the exact idempotency key. +- **N120-03** generated projection header contains non-authoritative warning, workspace/project IDs, generated time, and source revision. +- **N120-04** projection revision and records match the API snapshot revision exactly. +- **N120-05** hand-tampering is overwritten or rejected by regeneration and never mutates PostgreSQL. +- **N120-06** static/runtime reachability finds no parser/import path from `TASKS.md`, `mission.json`, or another export. +- **N120-07** projection writer has no domain mutation/raw SQL/Valkey authority. +- **N130-01** UI foreign/no-access/not-found state follows the frozen no-oracle response and renders no stale foreign data. +- **N140-01** real-Gateway DB fault journey proves fail-closed no-fallback behavior. +- **N140-02** real-Gateway Valkey-loss journey proves pending outbox replay. +- **N140-03** real-Gateway concurrent update/retry journey proves version and idempotency semantics. +- **N140-04** generated-file tamper journey proves projection parity and no import. + +### KBN-115/KBN-200/KBN-210/KBN-230 — recovery and coordination + +- **N115-01** retention purge without current break-glass authority, reason, immutable evidence, or bounded scope is denied and audited. +- **N115-02** recovery posture with an unknown top-level or storage field is rejected. +- **N115-03** PITR retention without WAL archival is rejected. +- **N115-04** WAL archival with zero PITR retention is rejected. +- **N115-05** claimed RPO better than the configured backup/WAL mechanism is rejected. +- **N115-06** unencrypted, optional, or same-failure-domain storage is rejected. +- **N115-07** weakened high-assurance values are rejected. +- **N115-08** shape-only validation cannot pass without normative mechanism and restore evidence. +- **N200-01** cyclic/incomplete dependency snapshots never become eligible. +- **N200-02** identical immutable snapshot+policy+time returns identical ordering and explanation with no I/O/model import. +- **N210-01** disabled agent cannot claim, ack, heartbeat, checkpoint, or submit review. +- **N210-02** ended/offline/mismatched session cannot claim, ack, heartbeat, checkpoint, or submit review. +- **N210-03** foreign-workspace task is rejected after lock/reload without an oracle. +- **N210-04** stale task version is rejected before fence increment. +- **N210-05** assignment target agent mismatch is rejected. +- **N210-06** target session mismatch is rejected. +- **N210-07** expired assignment is rejected. +- **N210-08** assignment in rejected/released/expired/superseded/leased-invalid state is rejected. +- **N210-09** missing approval is rejected. +- **N210-10** rejected/escalated/requested approval is rejected as approval authority. +- **N210-11** stale policy-revision approval is rejected. +- **N210-12** foreign-workspace approval is rejected without an oracle. +- **N210-13** approval for another assignment is rejected. +- **N210-14** author self-approval/review is rejected when independence is required. +- **N210-15** foreign-workspace artifact evidence is rejected. +- **N210-16** same-workspace artifact unrelated to the assignment/task/gate is rejected. +- **N210-17** concurrent policy revocation versus acquire cannot produce a lease under the revoked revision. +- **N210-18** concurrent assignment expiry versus acquire cannot produce a lease after expiry. +- **N210-19** concurrent session end versus acquire cannot produce a lease for the ended session. +- **N210-20** lower fencing token is rejected without writes. +- **N210-21** token from an older lease is rejected without writes. +- **N210-22** token paired with another task is rejected without writes. +- **N210-23** token paired with another session is rejected without writes. +- **N210-24** token on an expired/revoked/released lease is rejected without writes. +- **N210-25** fences above JavaScript safe integer round-trip exactly as decimal strings. +- **N210-26** lease task does not match assignment task and is rejected. +- **N210-27** lease agent/session does not match assignment target and is rejected. +- **N210-28** checkpoint task does not match lease task and is rejected. +- **N210-29** checkpoint fence does not match exact lease fence and is rejected. +- **N210-30** checkpoint sequence duplicate/regression is rejected. +- **N210-31** checkpoint artifact does not match workspace/task/evidence semantics and is rejected. +- **N210-32** restart after assignment persistence reconstructs the pending assignment. +- **N210-33** restart after lease commit reconstructs exact active lease and fence. +- **N210-34** restart after checkpoint commit reconstructs checkpoint/recovery state. +- **N210-35** restart during expiry/retry/quarantine reconstructs durable disposition and eligibility. +- **N210-36** restart with pending outbox reconstructs publication work without Valkey/files. +- **N230-01** author=self-review and missing mandatory SecReview cannot certify or complete. +- **N230-02** normal application role cannot execute retention purge. +- **N230-03** break-glass purge cannot delete or alter its own authorization/evidence chain. +- **N230-04** Valkey down leaves canonical work in PostgreSQL/outbox. +- **N230-05** duplicate wake produces one logical effect after PostgreSQL reload/idempotency. +- **N230-06** stale wake cannot revive an expired/revoked assignment or lease. +- **N230-07** restart with no Valkey/files reconstructs leases/retry/quarantine/outbox exactly. + +### KBN-300/KBN-320/KBN-330/KBN-340 — migration and cutover + +- **N300-01** source record targeting another workspace is denied/quarantined without an oracle. +- **N300-02** malformed source record is rejected with attributable reject evidence. +- **N300-03** duplicate source system/key/batch replay is idempotent. +- **N300-04** source snapshot/checksum drift aborts apply/verify. +- **N300-05** partial import resumes from durable lineage without duplicating state/events. +- **N300-06** imported shadow record cannot become ready, assigned, or leased automatically. +- **N300-07** missing source key/file/checksum/batch lineage prevents apply/sign-off. +- **N300-08** importer cannot use direct DB, generated file, Valkey, or provider issue as canonical write authority. +- **N320-01** cutover without a verified write freeze fails safe. +- **N320-02** active legacy writer process or credential blocks cutover. +- **N320-03** reverse and forward synchronization cannot run concurrently. +- **N320-04** failed final delta/reconciliation blocks client switch. +- **N320-05** rollback before first canonical DB mutation may switch authority back only after freeze assertion. +- **N320-06** rollback after first canonical mutation requires freeze, DB-delta export/reconciliation, and owner decision. +- **N330-01** rehearsal cannot sign off while counts/checksums/exceptions/writer inventory differ. +- **N340-01** cutover cannot proceed without owner authorization, terminal evidence, scoped identities, and zero active legacy writers. + +## 7. Requirements traceability + +| Requirement | Threats/impacts | Planned evidence | +| ---------------- | ---------------------------- | --------------------------------------------------------------- | +| REQ-SOT-001 | T16, T21, T22, T27, T29, T30 | N110-28..31, N110-44..49, N110-55..58, N120-03..07, N320-01..06 | +| REQ-SOT-002 | T07, T08, T09, T21 | N105-02/03, N110-14..27, N110-44..48 | +| REQ-SOT-003 | T30 | N120-03..07, N140-04 | +| REQ-SOT-004 | T16..18 | N100-24..26, N110-28..43 | +| REQ-TEN-001 | T01..05, T15, T33 | N100-01..14, N100-45..50, N110-01..09, N210-15/16 | +| REQ-ID-001 | T02, T03, T06, T10..12 | N105-01, N110-06..13, N210-01..19 | +| REQ-PLAN-001 | T04, T25 | N100-06..10 | +| REQ-TASK-001 | T13, T26, T31 | N100-20, N100-37..42, N110-50..54 | +| REQ-TASK-002 | T16, T24 | N110-28..31, N100-35, N200-01 | +| REQ-DEP-001 | T24 | N100-32..35, N200-01 | +| REQ-ASN-001 | T10..12 | N100-15..18, N210-03..19 | +| REQ-AUD-001 | T17..20, T22, T27 | N100-24..31, N110-32..43, N110-49, N110-55..58 | +| REQ-API-001 | T01, T06..18, T26 | N105-01..05 plus KBN-110 catalog | +| REQ-UI-002/003 | T01, T15, T26 | N130-01 and real-Gateway KBN-140 journeys | +| REQ-COORD-001 | T22..24 | N200-01/02, N210-32..36 | +| REQ-COORD-002 | T10..12, T16 | N210-03..19, N110-28..31 | +| REQ-COORD-003 | T13..15, T23 | N100-19..23, N210-20..36 | +| REQ-COORD-004 | T23, T26 | N210-32..36, N230-07 | +| REQ-GATE-001/002 | T11, T19, T20 | N210-09..14, N230-01..03 | +| REQ-REC-001 | T20, T32 | N115-01..08 | +| REQ-MIG-001/002 | T28, T29, T31 | N100-37..44, N300-01..08, N320-01..06, N330-01, N340-01 | + +REQ-UI-001 and REQ-UI-004 are downstream functional/accessibility requirements rather than schema-threat controls; they remain owned by KBN-130/KBN-140. Their security-relevant tenancy, conflict, and stale-reconnect portions are covered above. + +## 8. Issue #753 acceptance mapping + +| Issue requirement/criterion | Evidence in this document | Result | +| --------------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------- | +| Cross-workspace owners, principals, evidence, project hierarchy | T01–T05, T15, T25; CI-01–04 | Mapped | +| Active membership and service-token boundaries | Authorization matrix; T02, T03, T06; CI-02/03 | Mapped | +| Stale/forged health and transaction-local proof | T07–T09, T21; CI-05 | Mapped | +| Assignment/approval forgery and monotonic fencing | T10–T15; CI-06/07 | Mapped | +| Change-proposal abuse and event binding | T16–T18; CI-08 | Mapped | +| Immutable audit and break-glass | T19/T20; CI-09 | Mapped | +| PostgreSQL/Valkey failures | T21–T23; CI-10 | Mapped | +| Dependency/idempotency/version races | T24–T27; CI-11 | Mapped | +| Import/cutover and generated-file boundary | T28–T31; CI-12/13 | Mapped | +| Every schema/API/test impact explicit | Constraint matrix and negative-test catalog | Mapped | +| No unresolved schema impact | CI-14; rc.4 resolved-impact record | **PASS — none unresolved** | +| Independent SecReview | Homelab non-author exact commit/tree/content review | **PASS / APPROVE** | +| PR merge, terminal-green main CI, and #753 closure | Orchestrator-owned post-worker gates | Pending; KBN-100 remains held until completion | + +## 9. UNRESOLVED SCHEMA IMPACTS + +none + +### Resolved-impact record — KBN010-SI-001 + +- **Historical detection:** rc.3 lacked an exact `(workspace_id,id)` candidate key for the artifact and approval-decision mission FKs. This document's original BLOCKED verdict was correct and remains preserved in §1 and T33. +- **Resolution:** rc.4 adds non-partial `missions_workspace_id_uidx(workspace_id,id)` before both exact dependent FKs while retaining the global primary key and project-congruent key. +- **Reviewed object:** commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`, tree `7ebab8fa530a7180036928cea9527f808548aa14`. +- **Corroborating identities:** full-index SHA-256 `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`; stable patch-id `058cf98026fcd1043703c866aee047c8bb144740`. +- **Independent verdict:** Homelab non-author schema/security review **APPROVE**. It confirmed PostgreSQL candidate/FK validity, unchanged tenant and polymorphic exactly-one-target safety, RESTRICT/no-cascade semantics, N-1/rollback validity, and no shared table/index/FK/identity/fence authority collision with #757. +- **Digest interpretation:** a command-rendered patch digest varied with rendering command/options and is non-authoritative. Git commit + tree + exact file content are canonical; stable full-index SHA-256 and stable patch-id corroborate that identity. +- **Residual implementation obligations:** KBN-100 must create the candidate before both dependent FKs; prove production-shape duplicate feasibility without weakening global uniqueness; pass empty/prod/N-1/rollback tests; reconcile both exact FK targets; and separately reject foreign-workspace mission references for artifacts and approval decisions (N100-45..50). +- **Implementation status:** no runtime schema, migration, API, or deployment implementation is claimed by this gate disposition. + +## 10. Residual risk and handoff + +- Active membership, polymorphic targets, same-task evidence semantics, parent/DAG cycle checks, token scope, and no-oracle behavior depend on authoritative transaction code and must not be treated as FK-only guarantees. +- DB superuser and break-glass compromise cannot be eliminated by application constraints; separation of duties, immutable external backup/audit evidence, drills, and monitoring remain required. +- PostgreSQL unavailability intentionally sacrifices writes for integrity. Transport-unknown outcomes remain safe only when clients preserve the exact idempotency key. +- Imported ambiguous records remain quarantined until owner sign-off; no automated mapping may convert ambiguity into authority. +- SI-001 is resolved at frozen contract/design-review level only. KBN-100 still owes N100-45..50 executable migration evidence. + +**Handoff status:** KBN-010 **PASS / GO** at rc.4. KBN-100 remains held until this PR squash-merges, terminal-green CI completes on `main`, and issue #753 closes; the orchestrator owns those remaining gates. diff --git a/docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md b/docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md new file mode 100644 index 00000000..2bb62099 --- /dev/null +++ b/docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md @@ -0,0 +1,281 @@ +# KBN-101 — Database Runtime/Migration Role Split + +**Status:** frozen implementation contract; rc.16 closes HIGH-1 current generic storage-wrapper authority findings, awaiting independent exact-head re-review for [#771](https://git.mosaicstack.dev/mosaicstack/stack/issues/771) + +**Version:** 1.0.0-rc.16 + +**Dependency:** KBN-010 → **KBN-101 foundation** → KBN-100 → **KBN-101 deployed-role certification** → KBN-105 +**Scope:** PostgreSQL standalone/federated runtime identity, the sole application DDL runner, TLS bootstrap, readiness, deployment handoff, and evidence. This documentation card changes no database, secret, deployment, CI, runtime, migration, or compose artifact. + +## 1. Decision, modes, and non-negotiable boundaries + +Current `main` has one `DATABASE_URL` path in `packages/db/src/client.ts`, `migrate.ts`, `drizzle.config.ts`, storage adapters/CLI, Gateway startup, fleet-backlog, CI, installer output, compose, and Portainer. It also has PostgreSQL DDL outside a controlled migration phase: direct Drizzle scripts, `CREATE EXTENSION` probes, a direct-DDL federated integration test, and platform init SQL. None of those current paths is certified by this contract; every implementation card below must close its listed path. + +| Item | Exact name / shape | Rule | +| ------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Runtime URL | `DATABASE_URL` | PostgreSQL **non-owner runtime** connection. Required only by runtime services in `standalone`/`federated`; those services never receive `DATABASE_MIGRATION_URL`. | +| Migration URL | `DATABASE_MIGRATION_URL` | Required only by `mosaic-db-migrator`, the dedicated one-shot runner/Job. It is forbidden in Gateway, storage runtime, fleet, and ordinary CLI environments. | +| Runtime DTO | `DatabaseRuntimeConnectionConfigDto` | `tier`, `databaseUrl`, `runtimeMode`; maps only `DATABASE_URL`. It is not constructible from migration configuration. | +| Migration DTO | `DatabaseMigrationConnectionConfigDto` | `tier`, `migrationDatabaseUrl`, `migrationMode`; maps only `DATABASE_MIGRATION_URL`, is accepted only by `mosaic-db-migrator`, and has no runtime-bootstrap import path. | +| TLS DTO | `DatabaseTlsConfigDto` | `caCertificatePath`, `rejectUnauthorized: true`, `serverName`; validates the mounted CA without serializing its bytes. `serverName` is derived only from the validated connection host. | +| Readiness DTO | `DatabaseSchemaReadinessDto` | `state`, `expectedSchemaVersion`, `observedSchemaVersion`, `migrationRequired`, `roleCheck`, `checkedAt`; no URL, user, host, database name, or secret. | +| Modes | `local`, `standalone`, `federated` | `local` is the explicit PGlite-only exception. `standalone` and `federated` are production-like PostgreSQL modes. | + +**No fallback or plaintext:** in production-like modes, missing `DATABASE_URL`, `DATABASE_MIGRATION_URL`, or `DATABASE_TLS_CA_CERT_PATH` is a phase-specific error. No command may substitute a runtime URL, config-file URL, default URL, inferred URL, or hard-coded URL. PostgreSQL URLs must use `sslmode=verify-full` with `rejectUnauthorized: true`; `disable`, `allow`, `prefer`, `require`, `no-verify`, an absent CA, a wrong CA, an unverified certificate, or host/SAN mismatch fails before readiness. PGlite uses only its configured local data directory and explicit PGlite routine; it neither reads nor interprets PostgreSQL URL/TLS variables. + +**ASSUMPTION K101-A1:** `standalone` and `federated` are the complete current PostgreSQL production-like modes. A future PostgreSQL tier inherits this contract until a versioned amendment names its DNS, secrets, bootstrap, and evidence. + +## 2. The sole PostgreSQL DDL control plane + +`mosaic-db-migrator` is the **only application/CI/test command that may connect with DDL authority to PostgreSQL**. It owns, in one `max: 1` PostgreSQL session: migration-DTO parse, TLS/identity/search-path preflight, advisory-lock acquisition, manifest/ledger reconciliation, migration execution, postflight/readiness verification, lock release, and session close. It rejects `DATABASE_URL`-only invocation **before opening a connection or emitting DDL**. The only exception is the external privileged platform/IaC bootstrap actor (§4/§7), which is not an application command, never receives either application URL, and executes only the fixed bootstrap artifact. + +**Executable contract (KBN-101-03 exclusive):** `packages/db/package.json` publishes the exact mapping `"mosaic-db-migrator": "./dist/cli.js"`; `packages/db/src/cli.ts` compiles to that target. `docker/db-migrator.Dockerfile` builds that package and has the exact image entrypoint `ENTRYPOINT ["mosaic-db-migrator"]`. KBN-101-03 alone owns package/bin/build/pack/discovery tests that execute this compiled bin and image as `mosaic-db-migrator --help|--run|--verify`. The CLI imports only private implementation modules in `packages/db/src/migrator/`; package root exports do **not** expose `runMigrations`, and no other programmatic migration API is public. The sole typed programmatic runner is private to the package/CLI boundary and accepts the typed migration DTO, never a URL/string/SQL/schema/role argument. + +The exact user-facing interface is `mosaic-db-migrator --run` and `mosaic-db-migrator --verify`. `--help` is required and is the only discovery mode. Both commands use the fixed packaged migration folder and accept only `DATABASE_MIGRATION_URL`, `DATABASE_TLS_CA_CERT_PATH`, and non-secret `MOSAIC_DATABASE_TIER`/correlation input from the environment. They reject `DATABASE_URL` as fallback and reject URL, SQL, schema, role, database, migration-folder, and identifier arguments in argv. Stable sanitized process exits are: `0` success; `64` configuration; `65` TLS; `66` unsafe identity; `67` ledger/schema; `68` lock contention; `69` migration failure. CI, Compose, Swarm, and each two-gateway migration Job invoke the immutable db-migrator image with exactly `mosaic-db-migrator --run`; their readiness verification invokes exactly `mosaic-db-migrator --verify`. Required tests cover `--help`, argv rejection, runtime-only refusal before connect, migration-only success, each sanitized error/lock exit, and ordered `postgres-a → mosaic-db-migrator-a → gateway-a` plus `postgres-b → mosaic-db-migrator-b → gateway-b` execution. + +The implementation must inventory and close every present and future entrypoint as follows: + +| Current entrypoint | Required rc.13 disposition | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/db/src/migrate.ts:runMigrations()` | Remove its optional URL, `DATABASE_URL`, and default fallback API from public/runtime exports. Its PostgreSQL behavior moves behind `mosaic-db-migrator`; callers cannot invoke it with an arbitrary URL. | +| `packages/db/src/index.ts` | `KBN-101-03` removes the public `runMigrations` re-export; only the explicit PGlite-local API remains as separately typed local behavior. A compile/import-negative proves `@mosaicstack/db` cannot directly import `runMigrations`; a `DATABASE_URL`-only direct-library attempt fails before connect. | +| `packages/db/drizzle.config.ts` and direct `drizzle-kit migrate` | A database-connecting Drizzle configuration reads only `DATABASE_MIGRATION_URL` through the migration DTO and rejects its absence before connection. Replace direct `drizzle-kit migrate` exposure with `mosaic-db-migrator`. `db:generate` is an offline schema artifact command and must neither resolve nor connect to a URL. | +| `pnpm --filter @mosaicstack/db db:migrate` and CI invocation | Make it a thin `mosaic-db-migrator` wrapper; no direct Drizzle migrator invocation remains. CI supplies an isolated disposable migration URL only to that job. | +| `db:push` / direct `drizzle-kit push` | Forbidden for standalone, federated, CI production-like, Portainer, and any URL outside a disposable developer database. If retained for local experimentation, a wrapper requires `MOSAIC_DISPOSABLE_DEVELOPER_DB=1`, a locally allowlisted disposable target, `DATABASE_MIGRATION_URL`, verified TLS when PostgreSQL is used, and rejects `DATABASE_URL`, any production-like tier, and every non-allowlisted host/database before connection. It is never a release, repair, or migration procedure. | +| current `mosaic storage migrate --run` wrapper | **Current source is legacy N-1, uncertified, and non-operative:** `packages/storage/src/cli.ts` shells directly to `pnpm --filter @mosaicstack/db db:migrate` through `execSync`; no `mosaic-db-migrator` executable exists. It MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation. KBN-101-02 must retire or redirect the wrapper only after KBN-101-03 produces the runner; the wrapper cannot be documented as runner delegation. PGlite remains only its explicitly local routine. | +| `PostgresAdapter.migrate()`, Gateway `DatabaseModule`/startup, and PostgreSQL adapter factories | Runtime PostgreSQL migration is removed: no `runMigrations`, DDL, `CREATE EXTENSION`, or migration-compatible handle is reachable from startup. Gateway performs read-only identity, TLS, `search_path`, and manifest-ledger readiness checks only. | +| `packages/mosaic/src/commands/fleet-backlog.ts` | PostgreSQL fleet backlog never migrates or creates tables. It consumes a ready runtime connection; PGlite may use only its explicit local migration routine. | +| `packages/storage/src/{adapters/postgres,tier-detection}.ts` extension work and `infra/pg-init/01-extensions.sql` | Runtime probes become read-only catalog/extension-presence checks. Extension provisioning is a fixed external bootstrap prerequisite or a reviewed runner migration where the migrator has the required scoped authority; it is never a probe side effect or a reason to grant runtime database CREATE. | +| `packages/db/src/federation.integration.test.ts` direct type/table/index DDL | `KBN-101-02` replaces it with a pre-migrated disposable database created by `mosaic-db-migrator`, or makes the test invoke that runner. The test itself has runtime credentials and no direct DDL. | +| `apps/gateway/src/__tests__/integration/federated-pgvector.integration.test.ts` `CREATE TEMP TABLE` | `KBN-101-02` replaces the temporary runtime DDL with a runner-prepared disposable **persistent** `mosaic.federated_pgvector_fixture` database/table. The test receives only its runtime URL/CA and performs read/query-only qualified-vector assertions (including the selected vector operator); it has no setup hook, temporary privilege, or DDL. Runtime `TEMPORARY` remains denied. | +| `docker/init-db.sql` and `infra/pg-init/01-extensions.sql` | `KBN-101-02` retires these duplicate tracked init artifacts; neither may remain as a hidden extension authority. The sole extension action is the fixed external bootstrap artifact described in §4/§5, or the runner only when its reviewed implementation card explicitly grants that authority. | +| `packages/storage/src/{cli,migrate-tier}.ts` operator/runtime command closure | `KBN-101-02` alone replaces raw SQL/DDL behavior with delegation to `mosaic-db-migrator --run` (or the explicit PGlite-local path). Data copy requires the exact paired non-secret argv references `--target-url-file /run/secrets/mosaic-migrate-target-url` and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`, with the paired non-argv authenticated provider-version file `/run/secrets/mosaic-migrate-target-version`. The trusted `mosaic-db-migrator --verify` producer reads those exact importer materials only to bind and attest them; it never uses importer credentials for DDL/DML. Before target connection the importer verifies strict files, signed target attestation, credential-file digest/version, canonical TLS/database binding, role, manifest, expiry, and replay; after verified TLS but before transaction/DML it verifies server/database identity and schema. It opens only a dedicated non-DDL `mosaic_data_importer` target connection. Its DML registry is limited to declared mutable application tables and excludes schemas, roles, memberships, extensions, extension/catalog objects, Drizzle ledger, and immutable KBN relations. Raw `--target-url`, `DATABASE_URL` fallback, runtime-owner/importer role confusion, missing/unsafe/substituted files, stale/replayed/tampered/wrong-key attestation, wrong binding, and any DDL attempt fail before target connection/DDL or copy. KBN-101-07 documents that produced interface but owns neither source file. | +| `tools/federation-harness/docker-compose.two-gateways.yml` | Active topology; `KBN-101-05` migrates it rather than retires it. It must contain `postgres-a`, `postgres-b`, `mosaic-db-migrator-a`, `mosaic-db-migrator-b`, `gateway-a`, and `gateway-b`; each database has separate runtime/migrator URL and CA consumers, TLS server material, verified-TLS readiness, and runner-before-Gateway ordering. Its existing plaintext URLs/init mount are forbidden. | +| `docs/fleet/backlog-conventions.md` | `KBN-101-07` removes the current automatic first-use PostgreSQL `runMigrations()` claim and makes current behavior PGlite-only. PostgreSQL CLI/runner/readiness authority is held until activation; a documentation-route `DATABASE_URL`-only test asserts no first-use/migration instruction is executable before connect. | +| `docs/PERFORMANCE.md` | `KBN-101-07` removes direct `drizzle-kit migrate` and Gateway-startup `runMigrations()` instructions. Its runner sequence is explicit non-operative future evidence, not a current procedure; a documentation-route `DATABASE_URL`-only test asserts neither direct command nor Gateway fallback remains. | +| `README.md`, `CLAUDE.md`, `docs/guides/{admin-guide,dev-guide,deployment,migrate-tier,user-guide}.md`, `docs/federation/{MILESTONES,SETUP,TASKS}.md`, `docs/fleet/backlog-conventions.md`, `docs/PERFORMANCE.md`, `docs/design/storage-abstraction-middleware.md`, and `docs/plans/{2026-03-15-agent-platform-architecture,2026-03-15-wave2-tui-layout-navigation}.md` documentation hits | `KBN-101-07` owns the non-normative disposition inventory in §2.2. It replaces, retires, or labels status-only every direct `db:migrate`, `db:push`, `CREATE EXTENSION`, first-use migration, Gateway-startup migration, generic `mosaic storage migrate`, Compose-before-runner, and production plaintext/environment credential instruction. `README.md` and `docs/guides/dev-guide.md` retain only PGlite data-layer/no-PostgreSQL work plus optional non-PostgreSQL Compose services; Gateway/Web local start is held until -02 rejects daemon, inherited, root, and app-local PostgreSQL DSNs plus non-local tier input before connection or DDL. `docs/guides/deployment.md` is non-operative for PostgreSQL/federated/bare-metal production until KBN-101-00/-03/-05 land; it may describe only the future external-bootstrap → TLS/roles → runner → verified-readiness → Gateway/Compose order and the renderer/Vault generation-pinned process-exec or `LoadCredential` consumer boundary. No current document advertises the runner as executable, a broad Compose PostgreSQL start, a production `.env`/monorepo auto-load/environment-file route, a credential shell export or argv, or restart-as-secret activation. A future runner mention is valid only as the explicit held, non-operative procedure that names KBN-101-00/-03/-05 and preserves external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. The current Gateway loader’s unguarded local `.env` behavior is explicitly local-only and itself makes bare-metal production non-operative until KBN-101-02/-05 replace it with the renderer-backed consumer boundary. Historical shipped-status language is adjacent to an exact non-authorizing KBN-101 note; `docs/federation/MILESTONES.md` is status-only, its former startup-extension wording is superseded and forbidden, and it authorizes no current DDL, Compose/init, or startup path. Documentation is a DDL entrypoint and cannot advertise a bypass. | +| New package scripts, test helpers, setup hooks, CLI commands, installers, CI steps, adapters, or operator docs | A repository check rejects any new PostgreSQL DDL-capable entrypoint or instruction unless it is the dedicated runner or the named external bootstrap artifact. No future script may accept a URL parameter or `DATABASE_URL` as a DDL escape hatch. | + +The runner must run the original migration bytes, including shipped `0009`; no migration command may repair a ledger by manual insertion, adoption, `db:push`, or schema diff. Tests requiring PostgreSQL schema consume a pre-migrated disposable database or invoke this exact runner. + +`KBN-101-06` alone owns `.woodpecker/ci.yml`, `tools/ci/kbn101-ddl-inventory.ts`, `tools/ci/kbn101-ddl-inventory.spec.ts`, `tools/ci/fixtures/kbn101-ddl-inventory.json`, `tools/ci/kbn101-entrypoint-matrix.ts`, and `tools/ci/kbn101-entrypoint-matrix.spec.ts`. The scanner inventory has exactly the common fields `path`, `class`, `ownerCard`, `disposition`, `allowedTokens`, `rationale`, `expiry`, and `reviewRevision`; each disposition then has its separately required fields. `class` is one of `executable-source`, `script-or-package-bin`, `operator-document`, `deploy-manifest`, or `normative-contract`; `ownerCard` is one KBN-101 card; `disposition` is one of `runner`, `delegate`, `deny`, `retire`, `superseded-document`, `active-secure-data-migration`, `status-only`, or `allowlisted`; and `allowedTokens` is a nonempty subset of the fixed rules below only for `allowlisted` or `superseded-document`. `normative-contract` is excluded from **operator execution**, never from scanning: it is valid only for the exact KBN PRD/contract/shared/task canonical paths, must carry declarative requirements rather than an executable shell instruction, and a fenced executable instruction or an imperative bypass in that class fails rather than being masked. An `active-secure-data-migration` record has empty `allowedTokens` and additionally requires `route`, `targetCredentialOption`, `targetCredentialFile`, `targetCredentialVersionFile`, `targetAttestationOption`, `targetAttestationFile`, `attestationProducer`, `attestationKeyIds`, `attestationBindings`, `targetSchemaPrerequisite`, `targetConnectionRole`, `forbiddenInputs`, and `routeTest`; its parser rejects a missing common or disposition-specific field, a route that accepts a credential value on argv, an attestation missing any canonical binding, or a target that is not runner-prepared/verified. The latter `superseded-document` disposition is allowed only for the exact architecture-plan path when adjacent text says the direct command is superseded/MUST NOT run and names `mosaic-db-migrator --run`. The scanner rejects duplicate path owners, ownerless non-allowlisted rows, missing paths, malformed fields, an inventory path outside its declared class, and every unrecognized active command. The classifier's exact case-insensitive token/rule set is: `runMigrations\\s*\\(`; `drizzle-kit\\s+(?:migrate|push)`; `\\bdb:(?:migrate|push)\\b`; `mosaic\\s+storage\\s+migrate`; `mosaic-db-migrator\\s+--(?:run|verify)\\b`; `CREATE\\s+(?:TEMP(?:ORARY)?\\s+)?(?:EXTENSION|TABLE|TYPE|INDEX|SCHEMA)`; `ALTER\\s+(?:EXTENSION|TABLE|TYPE|SCHEMA)`; `DROP\\s+(?:EXTENSION|TABLE|TYPE|INDEX|SCHEMA)`; and `DATABASE_URL` when it occurs in the same file as any preceding rule. Independently of those lexical rules, every operator document and deploy manifest receives a path-level semantic assertion that rejects an operative `extension`, `schema`, or `migration` described as `created`, `installed`, or `applied automatically` on `first boot` or `startup`; a `docker compose ... up`/Compose-up/start-first sequence before `mosaic-db-migrator --run` and `--verify`; any init-script/init-SQL authority; and any production `.env`, monorepo auto-load, `EnvironmentFile=`, credential shell-export/argv, or restart-as-secret-activation route. The semantic assertion runs before inventory disposition: a named path, `status-only`, `normative-contract`, or any other record can never suppress it. Every operator-document `mosaic-db-migrator --run` or `mosaic-db-migrator --verify` hit first fails as unqualified unless it is inside one Markdown section headed exactly `Held future procedure` (case-insensitive, bounded through the next heading of equal-or-higher level). That one section must explicitly say non-operative/no-current-command-authority, name **all** KBN-101-00/-03/-05 prerequisites, and contain the complete ordered external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness sequence. The scanner rejects a runner hit outside that section, a second held-procedure section, an adjacent/descendant command after the section, or any missing marker, prerequisite, stage, order, or readiness endpoint before ownership/status/normative masking. The failing fixtures include the former `docs/federation/SETUP.md` wording, the exact former `docs/federation/MILESTONES.md` wording `pgvector extension installed + verified on startup`, and these exact former Compose-first sequences: `README.md`: `docker compose up -d` → `pnpm install` → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify`; `docs/guides/dev-guide.md`: `docker compose up -d` → root `.env` PostgreSQL `DATABASE_URL` → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify`; and `docs/guides/deployment.md`: `cp .env.example .env` → `docker compose up -d` → `pnpm install` → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify`. The production credential fixtures include a production `.env`, `EnvironmentFile=`, credential shell export or credential-bearing argv, and setting a credential followed by a restart. The runner fixtures include the exact former architecture-plan imperative `mosaic-db-migrator --run`, the former PERFORMANCE executable `--run`/`--verify` block, the former backlog PostgreSQL-CLI runner claim, and an otherwise-valid runner line adjacent to but outside a `Held future procedure` section; each fails before ownership/status/normative classification can mask it. The README fixture passes only when the checked-in direct CI `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` is explicitly asserted as **legacy N-1**, **active**, **uncertified**, **non-authorizing as an operator route**, **isolated disposable CI database**, and **pending KBN-101-06 removal**, with no operator-command block or ordinary-behavior claim; omitting any status term or presenting it as approved authority fails before masking. The rc.15 `Held future procedure` fixture—local PGlite or non-PostgreSQL Compose only; then, after KBN-101-00/-03/-05 land, explicit non-operative/no-current-command-authority external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness—is the one passing runner form. It scans exact-path current executable source/scripts/package bins, operator documents, deploy manifests, and named normative contracts; any lexical or semantic hit not represented by a permitted record fails. The matrix harness invokes the compiled runner/package/image and produced deployment artifacts; it edits no producer file. + +The canonical inventory includes this active—not historical—operator route record: `path=docs/guides/migrate-tier.md`, `class=operator-document`, `ownerCard=KBN-101-07`, `disposition=active-secure-data-migration`, `allowedTokens=[]`, `route=mosaic storage migrate-tier`, `targetCredentialOption=--target-url-file`, `targetCredentialFile=/run/secrets/mosaic-migrate-target-url`, `targetCredentialVersionFile=/run/secrets/mosaic-migrate-target-version`, `targetAttestationOption=--target-attestation-file`, `targetAttestationFile=/run/mosaic-attestations/migrate-target.v1.json`, `attestationProducer=mosaic-db-migrator --verify`, `attestationKeyIds=[migrate-target-v1-active,migrate-target-v1-overlap]`, `attestationBindings=[credentialSecretVersion,credentialFileSha256,tlsHostPortDatabase,caSpkiFingerprint,postgresSystemIdentifier,databaseOid,expectedImporterRole,manifestFingerprint,schemaFingerprint]`, `targetSchemaPrerequisite=mosaic-db-migrator --verify`, `targetConnectionRole=mosaic_data_importer`, `forbiddenInputs=[--target-url,DATABASE_URL,runtime-owner,signing-key]`, and `routeTest=packages/storage/src/migrate-tier.spec.ts::secureTargetRouteAndAttestation`. The -06 scanner/matrix must reject a changed/missing field, any `--target-url` or credential-bearing argv, a `DATABASE_URL` target fallback, a missing/unsafe/substituted credential or authenticated-version file, mixed URL/version generation, missing/wrong importer CA mount, missing/stale/replayed/tampered/wrong-key attestation, wrong binding, wrong mode/owner/link count, runtime-owner/importer confusion, target connection before attestation verification, or any DDL attempt before target connection/DDL. + +### 2.1 Target-bound importer attestation v1 + +`mosaic-db-migrator --verify` is the trusted producer only after its verified-TLS identity, manifest/ledger, and schema checks pass. Its root-owned launch wrapper is the sole reader of the fixed `MOSAIC_DB_ATTESTATION_SIGNING_KEY_FILE=/run/secrets/mosaic-db-migrate-target-ed25519` reference: that regular, non-symlink `root:root` `0400` Ed25519 private-key file is mounted only to the one-shot runner. It opens the key once, passes signing capability only in memory to `mosaic-db-migrator`, and never mounts, forwards, logs, prints, or otherwise exposes the private key to the importer, runtime, or an application container. KBN-101-05 renders both that runner-only secret and the importer-only pinned public-key/key-ring mount; an alternative env path, unsafe key file, or key material in importer/runtime is a hard failure. + +After verification the `10003:10003` migrator atomically emits the credential-free, non-secret producer artifact to its **producer-only** handoff mount `/run/mosaic-attestations-producer/migrate-target.v1.json`: write a same-directory `0600` temporary file, write canonical bytes, `fsync` file and directory, set final `10003:10003` `0400` owner/mode, and rename atomically. A privileged deployment handoff controller runs only after runner success and before importer creation. It receives neither importer URL bytes nor private key: only a root-owned non-secret `0400` renderer generation descriptor (expected provider version, URL SHA-256, generation ID) and pinned public verifier key. It safe-opens/verifies that descriptor and the signed artifact against expected bindings, copies exact bytes into a new importer-only mount, fsyncs file/directory, sets `10002:10002` `0400`, and atomically renames `/run/mosaic-attestations/migrate-target.v1.json`. The controller then seals that importer mount read-only and starts the importer. It never alters payload bytes, forwards URL/version/key material, or exposes either mount to Gateway/runtime/unrelated containers; a controller failure leaves no importer process. The producer and importer never share a writable file or mount. The single JSON envelope carries an RFC 8785 JCS canonical UTF-8 `attestation` payload plus a detached Ed25519 `signature` envelope (`algorithm`, `keyId`, base64 signature); the signature covers only the canonical payload bytes, never a reformatted document. The v1 payload fields are exactly `format`, `version`, `keyId`, `issuedAt`, `expiresAt`, `nonce`, `targetCredentialSecretVersion`, `targetCredentialFileSha256`, `tlsHost`, `tlsPort`, `database`, `caSpkiFingerprint`, `postgresSystemIdentifier`, `databaseOid`, `expectedImporterRole`, `manifestFingerprint`, `schemaFingerprint`, `producerInvocation`, `producerBuildDigest`, `producerImageDigest`, and `correlationId`. It contains no URL/DSN, username, password, private key, or credential bytes. The target credential must be a high-entropy DSN secret because its SHA-256 is non-secret binding evidence, never a substitute secret. + +For binding, the renderer obtains the importer URL `url` and its authenticated provider version from the **same successful Vault KV-v2 response** at canonical path `secret-{env}/mosaic-stack/database/importer`: `data.data.url` and `data.metadata.version`. The version is never inferred from DSN bytes, a hash, a filename, or an unverified side channel. The renderer treats exact URL bytes plus this metadata version as one generation and produces separately mounted immutable copies for the migrator-attestation producer and importer consumer. The migrator opens each copy only with `O_RDONLY|O_CLOEXEC|O_NOFOLLOW`, validates from the opened fd with `fstat` that it is a regular file with its exact fixed owner, `0400` mode, and link count `1`, then digests the URL from that fd and reads the authenticated version from its paired fd. It reads these importer-only materials solely to bind/sign; it never connects, performs DDL/DML, forwards, exports, logs, or passes either URL/version to a child environment. It zeroizes protected URL memory and closes both fds after signing. The runner uses `DATABASE_MIGRATION_URL` for its verification connection; it does not use importer credentials for DDL or DML. + +`mosaic storage migrate-tier` accepts exactly `--target-url-file /run/secrets/mosaic-migrate-target-url` and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`; the paired authenticated provider-version file is fixed, non-argv `/run/secrets/mosaic-migrate-target-version`. Before **any** target connection it safe-opens the URL, version, attestation, and pinned public-key files with `O_RDONLY|O_CLOEXEC|O_NOFOLLOW`, verifies post-open `fstat` regular-file/exact-owner/`0400`/link-count-one invariants, and validates public key/key ID, JCS signature/canonicality, expiry, nonce replay cache, authenticated provider version, URL-fd digest, canonical host/port/database, the CA file at exact `DATABASE_TLS_CA_CERT_PATH`, expected importer role, and manifest/schema fields. It reads URL bytes once into protected memory, digests/parses/connects from those exact bytes with no reread, then zeroizes memory and closes fds. Missing, substituted, symlinked, hard-linked, wrong-owner, wrong-mode, changed-after-open, mixed-generation, stale, replayed, tampered, wrong-key, revoked, missing-CA, wrong-mount, or wrong-binding inputs return stable sanitized errors with **zero target connection and zero DDL**. The nonce replay cache is durable through the artifact expiry window and atomically claims `(keyId, nonce, credentialFileSha256)` before connecting; a replay is terminal. A connecting dry-run consumes this nonce and MUST obtain fresh `mosaic-db-migrator --verify`, a new artifact handoff, and a new nonce before any `--yes` copy; tests prove the old artifact is rejected. New active and overlap public keys are pinned by key ID; rotation accepts only the bounded overlap, revocation removes a key immediately, and a provider version rotation invalidates the old attestation and requires a freshly rendered generation and runner verification/signature. + +Only after verified TLS connection and before transaction/DML, the importer compares server system identifier, database OID, `current_user`, CA/SPKI, and manifest/schema fingerprint with the attestation. A mismatch closes the connection with **zero DML/DDL**. Its grants and statement classifier make DDL impossible. KBN-101-02 tests consumer isolation, absent/wrong CA mount, missing/wrong/stale/replayed/tampered attestation, wrong/revoked key, substituted URL/version file, symlink/hardlink/mode/owner/TOCTOU rejection, mixed URL/version generation, provider rotation/revocation, wrong host/database/CA/role/manifest/system ID, digest/version mismatch, no URL/version/attestation/key logging or error oracle, and both `zero target connection` versus `connection then zero DML` outcomes. KBN-101-03 tests JCS/signing/atomic producer and fd-zeroization/close semantics; KBN-101-05 tests generation-pinned private/public key, URL/version, CA, and artifact mounts/rendering; KBN-101-06 invokes the complete matrix; KBN-101-07 owns the operator guide only. + +### 2.2 Complete current documentation disposition inventory + +The KBN-101-06 fixture records these exact non-normative paths: `README.md`, `CLAUDE.md`, `docs/federation/MILESTONES.md`, `docs/federation/SETUP.md`, `docs/guides/admin-guide.md`, `docs/guides/dev-guide.md`, `docs/guides/deployment.md`, `docs/guides/migrate-tier.md`, `docs/guides/user-guide.md`, `docs/fleet/backlog-conventions.md`, `docs/PERFORMANCE.md`, `docs/plans/2026-03-15-agent-platform-architecture.md`, `docs/plans/2026-03-15-wave2-tui-layout-navigation.md`, and `docs/design/storage-abstraction-middleware.md` as `operator-document`; each is runner/delegate/deny/superseded/active-secure as adjacent text specifies. `README.md` and `docs/guides/dev-guide.md` are `deny` for current PostgreSQL Compose/startup authority and Gateway/Web local startup; they permit only PGlite data-layer work plus explicitly selected non-PostgreSQL Compose services until -02 proves daemon/inherited/root/app-local DSN and non-local-tier rejection before connection or DDL. `docs/guides/deployment.md` is `deny` for current PostgreSQL/federated/bare-metal production activation and plaintext/environment credential delivery; its future renderer/Vault process-exec or `LoadCredential` schematic is non-executable until -00/-03/-05. `docs/federation/MILESTONES.md` and `docs/federation/TASKS.md` are exact `operator-document` `status-only` historical records: their shipped M1 language is superseded and cannot authorize current DDL, extension creation, importer execution, Compose/init, startup, or credential authority. Before inventory, ownership, or status masking, the semantic suite injects each exact former generic-wrapper form below; each MUST fail. + +```text +README.md (commented code fence) +# `mosaic storage migrate --run` is a schema-wrapper compatibility command only; +# it delegates solely to mosaic-db-migrator and is not tier data copy. + +docs/guides/user-guide.md (executable code fence) +# Schema migration (compatibility wrapper only; not a tier data copy) +mosaic storage migrate --run # delegates only to mosaic-db-migrator +``` + +A source-consistency test reads `packages/storage/src/cli.ts` and proves that the current command description plus `execSync` target is direct `pnpm --filter @mosaicstack/db db:migrate`, that no current `mosaic-db-migrator` bin exists, and that any documentation claiming runner delegation fails. The fixture also uses the exact former MILESTONES wording, the exact former README/dev/deployment Compose-first sequences, the former architecture-plan/PERFORMANCE/backlog runner-authority routes, and the README legacy-CI status assertion as semantic cases; every failing route fails before an inventory status-only record can mask it, and the legacy-CI case passes only with its exact non-authorizing status terms.`docs/PRD.md`, this contract, `SHARED-CONTRACT.md`, and `native-kanban-sot/TASKS.md`are exact`normative-contract` records; their declarative KBN requirements are scan-visible but excluded from operator execution and cannot mask an instruction. The full-doc scanner must fail an unknown active command, a missing record, or a status-only/normative path containing executable bypass or production credential guidance. + +The only allowlist categories are `historical-sql` (`packages/db/drizzle/**`, byte-immutable runner input only), `pglite-local` (an explicitly PGlite-only source/test path), `negative-test-literal` (a focused negative test), `vendored-generated` (a generated or vendored artifact), and `historical-review-report` (`docs/reports/**` only). Every allowed record names its exact path, token(s), rationale, expiry, and review revision. No allowlist category is valid for an executable current source/script/package bin, operator document, deploy manifest, `README.md`, `CLAUDE.md`, `docs/plans/**`, or `docs/guides/**`; `historical-review-report` cannot contain an operative command. Scanner self-tests place each token in an unowned current path, prove duplicate-owner/ownerless/path-existence failure, prove an operative `db:migrate` instruction in a path labeled historical fails rather than being masked, and prove every unqualified runner `--run|--verify` operator-document hit fails before ownership/status masking. The scanner is a classifier plus path inventory and review—not a naive token scan alone—and never by itself proves DDL authority. For every non-allowlisted inventory row and `gateway-a`/`gateway-b`, the matrix proves `DATABASE_URL`-only, missing migration URL, direct library import, direct Drizzle, `db:push`, init artifact, operator route, runtime-only fixture/test, and each harness migration Job/Gateway fail before connection/DDL as applicable. The `db:push` negatives also cover production-like tier and production-like URL rejection. + +## 3. Exact migration manifest, ledger, and lock + +### 3.1 Manifest v1 + +The runner generates and verifies a source-controlled **migration manifest v1** from the shipped Drizzle journal and SQL files. A record has exactly: + +```text +logicalIndex: non-negative integer from journal array position +journalTag: exact journal `tag` string +migrationSha256: lowercase SHA-256 of the exact migration `.sql` file bytes +``` + +The canonical SQL bytes are the raw Git blob bytes at the signed source-release commit, not workstation checkout bytes. KBN-101-03 adds/validates an LF-pinning `.gitattributes` rule for `packages/db/drizzle/**/*.sql`, generates the source-controlled manifest from those canonical blobs in CI, and makes the runner verify deployed file bytes against the manifest before DDL. No newline, Unicode, whitespace, SQL, or line-ending normalization is applied. Manifest canonical serialization is UTF-8 bytes of: + +```text +mosaic-drizzle-manifest-v1\n +\t\t\n +... in ascending logicalIndex with no omitted index +``` + +`manifestSha256` is SHA-256 of those canonical bytes. The manifest has no timestamp-derived ordering; `folderMillis`, legacy ledger `id`, and `created_at` are diagnostic only. KBN-101-03 corrects journal **logical** order metadata (including the current `0008`/`0009` anomaly) without changing shipped `0009` bytes. + +The runner stores certification in `drizzle.__mosaic_migration_manifest` with exactly one active v1 row (`manifest_version=1`, `manifest_sha256`, `certified_at`). It is owned by `mosaic_schema_owner`; only the migrator after `SET ROLE mosaic_schema_owner` may insert/update it; runtime gets `SELECT` only, and `PUBLIC` gets no schema/table privilege. Existing databases receive this table through the runner after backup/preflight; it is not created by Gateway, a test, or manual SQL. + +### 3.2 Reconciliation and 0009 transition + +The expected ledger is the ordered list of v1 manifest tuples. The runner reads every observed `drizzle.__drizzle_migrations.hash`, maps **each observed hash to exactly one** manifest tuple, and rejects a missing, unknown, duplicate, ambiguous, corrupt, or stale-replica mapping. Equality is tuple-complete: every expected tuple occurs once, no additional tuple occurs, and the manifest digest matches. A count comparison or hash-set comparison is forbidden. Physical legacy insertion `id`, insertion timestamp, and order are expressly non-normative. + +For an existing database: + +1. take the KBN-101-07 approved backup and capture a read-only inventory before changing anything; +2. if `0009` is missing **and** its effects are absent, run the original shipped `0009` through `mosaic-db-migrator` and record it normally; +3. if the `0009` hash is present but physically late, accept it when its one-to-one tuple mapping is exact; +4. if an expected hash is missing while its effects are partial or complete, or catalog/ledger evidence conflicts, fail closed as `DATABASE_MIGRATION_RECONCILIATION_AMBIGUOUS`. Recovery is backup restoration or an explicit separately reviewed repair artifact with its own owner, tests, backup, rollback, and approval—not manual ledger insertion/adoption; and +5. write/update the v1 certification row only after exact reconciliation and final catalog/readiness verification. + +Required runner tests cover clean, pre-0009, skipped-0009/effects-absent, applied-late, duplicate, unknown, missing, corrupt tuple-pair, partial/full-effect ambiguity, stale replica, backup/restore, and rerun idempotence. The tests prove the `0009` SQL bytes are unchanged and logical journal ordering—not physical ledger order—controls reconciliation. + +### 3.3 Fixed advisory-lock namespace + +Before any preflight that can decide migration state, `mosaic-db-migrator` acquires `pg_try_advisory_lock(1297044289, 1262636593)`. The constants are signed-int4-safe fixed namespace values: class `1297044289` (`MOSA`) and object `1262636593` (`KBN1`). The same `max: 1` session retains it for preflight, reconcile, migrate, verify, release, and close. Failure returns `DATABASE_MIGRATION_LOCKED` immediately; a process crash releases it when the PostgreSQL connection closes. Runtime readiness remains unready while a holder is active and never waits by running migrations. Tests prove concurrent contention, crash/connection-loss release, readiness while held, and non-interference from an unrelated advisory key. + +## 4. PostgreSQL roles, identifiers, and trusted sessions + +Role creation, passwords, membership, database ownership, certificates, and Vault values are platform/IaC/operator work—not Drizzle/application migrations. Application SQL must not issue credential/role management statements or embed credentials. + +| Role | Attributes and ownership | Membership / session use | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mosaic_platform_database_owner` | `NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`; platform-only database owner after bootstrap. | Never granted to application roles. | +| external platform bootstrap actor | Provider/operator/IaC-controlled, externally audited **superuser** identity outside the Mosaic role graph and Vault/application configuration. | Creates/transitions the database and roles, then retires from application use. It alone `SET ROLE`s the extension owner for `CREATE EXTENSION`, `ALTER EXTENSION ... UPDATE`, or `ALTER EXTENSION ... SET SCHEMA`, records the action, and `RESET ROLE`s. | +| `mosaic_schema_owner` | `NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`; owns only `mosaic`/`drizzle` schemas and application/ledger objects. It has only `USAGE` on `mosaic_extensions` for fixed legacy type resolution. | Never an application login; no ownership, `CREATE`, `ALTER`, `DROP`, extension/member-change, or default-privilege authority in `mosaic_extensions`; its migrator subphase never receives temporary `CREATE` there. | +| `mosaic_extension_owner` | Dedicated `NOLOGIN SUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS` extension owner, distinct from platform/schema/migrator/runtime. It is used solely to own/maintain untrusted `vector`, `mosaic_extensions`, and every owner-bearing extension member there. | `rolcanlogin=false`, `rolsuper=true`, and **zero members** are catalog-proven. No application role has membership, `SET ROLE`, credential, or inheritable grant. An externally controlled, audited platform-bootstrap **superuser** session alone executes `SET ROLE mosaic_extension_owner` for fresh creation, approved-owner update/relocation, or shadow bootstrap, then `RESET ROLE`; no persistent membership is ever granted. | +| `mosaic_migrator` | `LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`. | Only `mosaic_schema_owner`; runner verifies `session_user=mosaic_migrator`, `SET ROLE mosaic_schema_owner`, then `current_user=mosaic_schema_owner`. | +| `mosaic_data_importer` | `LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`; dedicated data-copy identity, not a DDL or schema owner. | It is used only after runner preparation/verification through the KBN-101-02 file-reference interface. It has no owner/migrator/extension membership and cannot `SET ROLE mosaic_extension_owner`, or `ALTER`/catalog-or-extension-member `UPDATE`/`DROP`/change extension membership. Its bounded data-copy DML is not extension authority. | +| `mosaic_runtime_capability` | `NOLOGIN` and no ownership/administrative attributes. | Holds only named runtime grants. | +| `mosaic_runtime` | `LOGIN INHERIT` with no ownership/administrative attributes. | Only `mosaic_runtime_capability WITH INHERIT TRUE, SET FALSE, ADMIN FALSE`; never owner/migrator member. | +| `mosaic_runtime_user_capability` | `NOLOGIN`, no ownership/admin attrs. | Holds the **User (god)** rung's named grants incl. `INSERT(status)`, `UPDATE(status)` + the status-transition RLS `WITH CHECK` policy on `tasks`; **plus the SOLE `INSERT/UPDATE/DELETE` grant on `task_status_write_override`** (B-1); never granted to Orchestrator/base. | +| `mosaic_runtime_orchestrator_capability` | `NOLOGIN`, no ownership/admin attrs. | Holds the **Orchestrator (near-god)** rung's grants; identical to User **except** (i) every `tasks` write policy **subqueries** the override table and is REJECTED at the sink when an active User deny row exists for that `(workspace_id, task_id, orchestrator)` (B-1); (ii) `INSERT(status)` is pinned by `WITH CHECK` to a **non-terminal initial status** (N-3) — only User may INSERT an arbitrary/terminal status; (iii) it has only `SELECT` (never write) on the override table. Scope is tier/task-identity only (F6). | +| `mosaic_runtime_user` | **`LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`**, no ownership; **non-owner of every sink table + the override table**. | Member of `mosaic_runtime_user_capability` only (`WITH INHERIT TRUE, SET FALSE, ADMIN FALSE`); the connection-selection **User-rung** credential. **[AD-1]** Requires `CONNECT`, denied `TEMPORARY`. | +| `mosaic_runtime_orchestrator` | **`LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`**, no ownership; **non-owner of every sink table + the override table**. | Member of `mosaic_runtime_orchestrator_capability` only (same INHERIT/SET/ADMIN flags); the **Orchestrator-rung** credential. **[AD-1]** Requires `CONNECT`, denied `TEMPORARY`. | + +> The existing `mosaic_runtime` is confirmed already `LOGIN INHERIT` with no admin attrs (frozen L150) and non-owner; it becomes the **others / deny-by-default** base rung. On the sink it retains `SELECT` (workspace-scoped `USING`, B-2), `INSERT (explicit non-status column list)` (F1), and column-level `UPDATE` on **non-status, non-invariant, non-key, non-tenancy** columns only (F7), each `USING` workspace-scoped; it has **no** `INSERT(status)`, **no** `UPDATE(status)`, **no** `DELETE`, **no** `TRUNCATE` on `tasks`, **no** write on the override table, and RLS denies its status/override writes by default. The rung set is **fixed at three**. +> +> **F2 note:** the three rung LOGIN roles' `NOBYPASSRLS` + `NOSUPERUSER` + **non-ownership** are load-bearing — `FORCE ROW LEVEL SECURITY` collapses for any SUPERUSER/BYPASSRLS role and RLS is silently bypassed by a table **owner**. These attributes are declared in -00 bootstrap, catalog-proven in -00 tests, and re-verified at every checkout by -01 (extended L222 assertion). + +The fixed application/runtime schema is **`mosaic`**. Every application/runtime pooled connection executes and verifies exactly `SET search_path TO pg_catalog, mosaic` before its first application query; connection checkout repeats this after reset/reconnect. Transactional application operations use `SET LOCAL search_path TO pg_catalog, mosaic` and verify it before query execution. `public` and `$user` are forbidden in all runtime paths. + +The sole runner has one narrowly bounded **legacy-history bootstrap subphase** for the immutable current journal: before it runs, external bootstrap revokes `CREATE` on `public` from `PUBLIC`, temporarily assigns its ownership to `mosaic_schema_owner`, and denies all runtime connections. The externally controlled audited bootstrap-superuser session first executes `SET ROLE mosaic_extension_owner` to create/own `mosaic_extensions` and fresh `vector`, or to perform approved-owner relocation/update, records that control-plane action, and executes `RESET ROLE`; it receives no membership because a superuser can assume the role without one. In its locked `max:1` migration session only, after `SET ROLE mosaic_schema_owner`, the runner has only `USAGE` (not ownership or `CREATE`) on `mosaic_extensions` and uses the fixed legacy-only `SET LOCAL search_path TO pg_catalog, public, mosaic_extensions` solely so immutable `0001` resolves its unqualified existing `vector` type. It cannot create, alter, drop, reassign, or change a member there. The preflight catalogs `pg_namespace.nspowner`, schema ACL/default ACLs, `pg_extension.extowner`, and member owners, then directly proves `CREATE`/`ALTER`/`DROP`/member-change denial for runtime, migrator, and schema owner. After relocation/catalog verification and before manifest certification or any runtime readiness, the runner transfers `public` ownership to `mosaic_platform_database_owner`, revokes application `USAGE`/`CREATE`, and restores `pg_catalog,mosaic`. This compatibility subphase is not a runtime/operator option, accepts no configuration identifier, has no fallback, and is removed/disabled before KBN-101-08. + +`KBN-101-03` exclusively owns `packages/db/src/schema.ts`, `packages/db/drizzle/meta/*`, `packages/db/drizzle/meta/_journal.json`, the generated relocation migration, and exact database/Drizzle tests. It freezes one exported `export const mosaic = pgSchema('mosaic')`; every application `pgTable` and every application `pgEnum` must be declared through that export. The generated snapshot/journal and future `db:generate` output must target `mosaic` only; a static declaration/SQL test rejects a default-schema application `pgTable`/`pgEnum`, `public` application declaration, or future generated application DDL outside `mosaic`. Historical `0000` through current SQL and the shipped journal provenance are byte-immutable and execute only in the trusted legacy-`public` subphase; they are never rewritten to claim they originally targeted `mosaic`. + +### 4.1 pgvector ownership and supported transition + +The selected extension policy is fixed for PostgreSQL 17 + pgvector 0.8.2. Its actual target-image `vector.control` evidence must show `relocatable = true` and no `trusted = true` or `superuser = false` setting: `vector` is therefore untrusted and creation requires superuser authority. Fresh databases: the external audited bootstrap-superuser session executes `SET ROLE mosaic_extension_owner`, creates and owns `mosaic_extensions`, then creates `vector` as `CREATE EXTENSION vector WITH SCHEMA mosaic_extensions`; it records the control-plane action and executes `RESET ROLE`. Under that role it revokes default `TABLES`, `SEQUENCES`, `FUNCTIONS`, `TYPES`, and `SCHEMAS` privileges from `PUBLIC`, `mosaic_schema_owner`, `mosaic_migrator`, `mosaic_data_importer`, and `mosaic_runtime`, then grants only the explicitly required read/type/function privileges. It validates `rolcanlogin=false`, `rolsuper=true`, zero role members, `pg_namespace.nspowner`, `pg_extension.extowner`, and owner-bearing extension-member ownership/schema/version (ownerless PostgreSQL catalog member classes are verified as ownerless, never falsely assigned), schema/default privileges, `RESET ROLE`, and the external audit record. Approved-owner update/relocation is likewise performed only by that external session while set to `mosaic_extension_owner`; the existing path is eligible only when `extowner` is already exact. `mosaic_extensions` remains non-writable by runtime, migrator, schema owner, importer, and every service role. They have no membership in `mosaic_extension_owner` and must fail catalog assertions plus `SET ROLE`, `CREATE`/`ALTER`/`DROP EXTENSION`, extension-member `UPDATE`/DDL, and role-membership change denials. Shadow migration and rollback repeat these owner/default-privilege/preflight assertions before copying, before atomic switch, after resume, and before read-only rollback. + +PostgreSQL has **no supported** `ALTER EXTENSION ... OWNER TO`. No card may invent it, mutate system catalogs, use `DROP ... CASCADE`, or adopt legacy extension ownership. An existing `vector` is eligible for the clean in-place path only when `pg_extension.extowner` already resolves to the approved `mosaic_extension_owner`; after exact supported-version, `extrelocatable=true`, dependency, and complete expected member-set checks, the bootstrap actor may perform the tested `ALTER EXTENSION vector SET SCHEMA mosaic_extensions`. The same postflight verifies `extowner`, each member owner/schema, version, dependency inventory, and denial for runtime/migrator/schema owner. + +A `vector` extension owned by a legacy runtime/single login is **not activated in place**. It fails closed before activation and requires this controlled shadow-database migration: (1) approved backup and read-only inventory; (2) create new database/roles; (3) the audited external bootstrap-superuser session `SET ROLE mosaic_extension_owner`, creates `mosaic_extensions`/`vector`, records the audit event, and `RESET ROLE`; (4) sole runner applies migrations and relocation; (5) copy data with vector dimensions, row counts, checksums, FK, and sequence evidence; (6) quiesce/drain writers; (7) apply and verify final delta; (8) prove role/TLS/readiness; (9) atomically switch connections; and (10) retain the old database read-only for the approved rollback window. Partial/cancel/resume and rollback preserve the old read-only source until the switch; no target unable to shadow-migrate is eligible until separately reviewed. Required tests cover fresh, approved-owner clean existing relocation, legacy-owner shadow path, partial/resume/rollback, N-1, exact `extowner`/member/schema/version assertions, and `SET ROLE`/ALTER/DROP/member-update denials for runtime, migrator, schema owner, importer, and all service roles. + +**Superuser exception and threat boundary:** PostgreSQL superuser authority cannot be privilege-limited by `GRANT`/`REVOKE`; this is deliberately **not** a least-privilege claim for `mosaic_extension_owner`. The containment is its dedicated identity, `NOLOGIN`, zero membership, absence from runtime credentials/Vault/application containers, external control-plane-only audited use, and independent review. Any extension create/update/schema operation is a control-plane change requiring independent review, approved backup/rollback, maintenance window, and audit evidence. A managed target that cannot establish this exact `NOLOGIN SUPERUSER`, zero-member, externally controlled role is ineligible until an independently approved, versioned provider-owned extension-owner profile exists; it must not silently retain app/migrator ownership. + +`schema.ts` must emit `mosaic_extensions.vector(...)`, and all vector casts/operators/functions in runner, application queries, fixtures, and generated SQL must explicitly qualify `mosaic_extensions` (for example `OPERATOR(mosaic_extensions.<->)`); `mosaic_extensions` is deliberately **not** added to runtime `search_path`. + +Before relocation, the runner records a parameterized catalog inventory and dependency graph. It must classify, in dependency order, application enum/domain/base types; owned and identity sequences; application tables; table columns/defaults; functions/procedures; views/materialized views; extension-owned objects; then triggers, constraints/FKs, indexes, and dependent rules/policies. It moves base types, sequences, tables, and functions as required; OID-bound constraints/indexes follow their owning objects and are catalog-verified rather than recreated blindly. Every object must be expected exactly once and be in either the immutable legacy/bootstrap allowlist, the `mosaic` application allowlist, or the selected `mosaic_extensions` extension membership; unknown, extra, duplicate, cross-schema, dependency-cycle, or partial-resume state fails closed. No raw client-side identifier interpolation is allowed. + +`KBN-101-00` owns bootstrap-role/schema/extension/default-privilege and direct-denial tests. `KBN-101-03` owns the runner integration tests that consume those bootstrap fixtures: approved-owner existing relocation, legacy-owner shadow migration, interrupted/partial relocation resume, partial/cancel shadow resume, and pre-activation reverse rollback-before-activation. Reverse rollback is allowed only before KBN-101-08 and restores the approved backup or the reviewed inverse relocation, never a runtime `search_path` bypass. Its N-1 order is: current legacy release → inactive bootstrap/runner and `mosaic` declarations → catalog relocation/generated-artifact verification or approved shadow migration → verified non-owner runtime activation. The combined evidence includes byte-immutable historical execution, no public application objects/declarations/future SQL, vector type/cast/operator query success under fixed `pg_catalog,mosaic`, exact extension-owner/member/schema/version assertions, catalog and direct ALTER/DROP/member-change denials for runtime/migrator/schema owner, and all extension eligibility negatives. + +No SQL identifier may come from URL/config/environment/operator input. Catalog comparisons use parameter values. The fixed identifiers above are constants; the external bootstrap artifact alone may use server-side `format('%I', fixed_allowlisted_identifier)` after allowlist validation. Raw client-side interpolation for identifiers, `SET search_path`, database, schema, role, table, or extension names is forbidden. Tests include injection-shaped values, a poisoned pooled-session reset, and transaction `SET LOCAL` restoration negatives. + +External bootstrap executes `REVOKE CONNECT, TEMPORARY ON DATABASE FROM PUBLIC`, then grants `CONNECT` only to `mosaic_runtime`, `mosaic_runtime_user`, `mosaic_runtime_orchestrator`, `mosaic_migrator`, and the time-bounded external bootstrap actor while it is required. Certification fails if an unrelated login retains `CONNECT` or any of the three application logins retains `TEMPORARY`. Runtime receives `USAGE` on `mosaic`, named table/sequence grants through `mosaic_runtime_capability`, and `USAGE` on `drizzle` plus `SELECT` only on `drizzle.__drizzle_migrations` and `drizzle.__mosaic_migration_manifest`. **[NB-5]** The two new capability roles `mosaic_runtime_user_capability` and `mosaic_runtime_orchestrator_capability` additionally receive the identical baseline grants: `USAGE` on `mosaic`, `USAGE` on `drizzle` plus `SELECT` only on `drizzle.__drizzle_migrations` and `drizzle.__mosaic_migration_manifest`, and the relevant sequence `USAGE`/`SELECT` grants — mirroring `mosaic_runtime_capability` — so the frozen §6 L222 runtime verify does not fail closed on missing inherited/sequence/ledger grants for the two new rungs. `INSERT`, `UPDATE`, `DELETE`, `TRUNCATE`, and DDL rights on ledger/manifest are revoked. Revoke public CREATE and function EXECUTE; `SECURITY DEFINER` is forbidden unless a separately reviewed exception pins trusted path and grants only the capability role. Database TEMPORARY, role management, extension, schema, and object ownership are denied. + +Immutable KBN relations, after KBN-100 creates them, grant runtime only `SELECT, INSERT` and explicitly deny `UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER`: `task_events`, `artifacts`, `task_checkpoints`, `task_checkpoint_artifacts`, and `approval_decision_artifacts`. KBN-100 retains RESTRICT/no-cascade semantics. Foundation certification verifies the role/schema boundary only; post-KBN-100 certification verifies this real deployed-role matrix. + +> **rc.20 (Envelope A) — authorized by Jason's declarative-RBAC B1 ruling + Mos OPTION A + Mos Q1 (TIER-LEVEL) + Mos Q2 (RLS):** adds (i) the fixed **User/Orchestrator/others** runtime rung-roles (per-ROLE, deny-by-default); each rung **LOGIN** role is `NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`, **NON-OWNER** of every sink/override table, **added to the §4 L176 `CONNECT` allowlist**, and **asserted `TEMPORARY`-denied**. **No per-federated-user role or credential is created** — federated identity maps to a rung **at authorization time only**, and per-writer **attribution lives in metadata columns** (app-supplied `actor_id` on `task_events`), **never** in DB credentials/roles. (ii) Two new fixed rung DSN secrets **`mosaic-db-runtime-user-url`** and **`mosaic-db-runtime-orchestrator-url`** (Gateway-only, `0600 10001:10001`), extending the frozen runtime-URL secret row. (iii) **RLS `WITH CHECK` write-source policies (including the B2-INSERT per-rung INSERT tenant binding `workspace_id = current_setting('mosaic.workspace_id', true)::uuid`, composed as a SINGLE compound `WITH CHECK` or `AS RESTRICTIVE` — never multi-permissive-intended-to-AND, per the v5 composition mandate) + per-rung `SELECT`/`UPDATE` `USING` tenant policies + `FORCE ROW LEVEL SECURITY`** on the sink table `tasks` **and on the new sink-resident `task_status_write_override` table** (tighten-only, deny-by-default) — a §10 schema-v1 **mechanism addition** beyond the prior grant/revoke-only model, authorized by Mos Q2 as coordinator co-authorization with Jason's B1. **The two new rung capability roles additionally receive the baseline frozen §4 L176 runtime grants (`USAGE ON mosaic`, `drizzle` ledger `SELECT`, sequence grants) [NB-5] so they satisfy the frozen §6 L222 runtime verify.** (iv) The **`task_status_write_override` table** (keyed `(workspace_id, task_id, tier)`, no per-writer key), written only by the User rung, read by the Orchestrator/base rung `tasks` write-policy subquery, homes the User-override **at the sink**. The sink DDL (grants + RLS `USING`/`WITH CHECK` + override table + FORCE + unique key + trigger/view/rule invariants) is homed in the **KBN-100 producer** after it creates `tasks`, with `FORCE RLS` as the **terminal** migration step after all backfill/repair DML, per the §4 L178 producer/consumer precedent. +> +> **Frozen-invariant note (authorization basis):** touching the frozen role graph, the §4 L176 CONNECT allowlist, and adding RLS + the override table are §10 contract-change classes (MISSION-MANIFEST §10; SHARED-CONTRACT L82 (rc.6 non-effect) / L88-90 (rc.5 non-effect), both amended by this rc.20; frozen §4 L176). They are authorized here by **Jason B1 + Mos OPTION A + Mos Q1 + Mos Q2**. The addition is strictly a **fixed per-ROLE** set + a **tighten-only** RLS mechanism + a **task/tier-scoped** override table. It stops exactly at the HALT boundary: **no** per-federated-user topology is introduced. + +## 5. Deployable verified-TLS bootstrap + +`mosaicstack/stack` is the named repository/control plane. Ownership is intentionally non-overlapping: **KBN-101-00 exclusively owns** the versioned external bootstrap interface `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, and `infra/pg-bootstrap/README.md`, plus its bootstrap tests—roles, extension-owner transition, and no renderer/deployment manifests. **KBN-101-05 exclusively owns** `tools/db/render-postgres-secrets.ts`, its tests, the current `docker-compose.yml`, `docker-compose.federated.yml`, `deploy/portainer/federated-test.stack.yml`, `tools/federation-harness/docker-compose.two-gateways.yml`, and `apps/gateway/Dockerfile`; it consumes the versioned KBN-101-00 bootstrap interface and owns no bootstrap SQL. The named **Mosaic deployment control plane / Jason** is activation authority; the environment-specific IaC/Vault owner supplies only approved input secret versions and may not substitute an unreviewed current-repository artifact. The KBN-101-05 renderer is the only deployment handoff: it reads secret-provider references, validates owners/modes/digests/SANs, writes each output atomically (`mkstemp` on the target tmpfs, `fsync`, `chmod`/`chown`, atomic rename), and records only secret-version identifiers and hashes. + +`KBN-101-05` changes the Gateway image to fixed non-root `USER 10001:10001`, the importer image to fixed non-root `USER 10002:10002`, and the migrator process to fixed non-root `USER 10003:10003`; image and renderer tests freeze every UID:GID. A root-only migrator launch wrapper may open the private signing key before dropping to `10003:10003`, but the migrator process never regains root. Gateway CA and Gateway leaf-certificate mounts, and its own Gateway private key only when it terminates its HTTPS listener, must be readable by `10001:10001`; PostgreSQL private keys and migration-only material are never mounted there, and no secret is world-readable. PostgreSQL is not assigned a guessed UID/GID: its image must first be pinned by digest, and an image-inspection plus rendered Compose/Swarm test freezes the image's effective PostgreSQL UID:GID before the renderer selects mount owner/group. A digest, service UID/GID, rendered secret `uid`/`gid`/`mode`, or container `USER` mismatch is a KBN-101-05 failure. Mosaic applications never generate, self-sign, copy, or persist production certificates; the external bootstrap actor receives them only through the deployment secret mechanism and no plaintext development exception exists for production-like modes. + +| Material | Vault target / deployment secret | Mount, injection, and authorized consumer | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | +| Runtime URL | `secret-{env}/mosaic-stack/database/runtime` (`url`) → `mosaic-db-runtime-url-v1` | Gateway only: `/run/secrets/mosaic-db-runtime-url`, `0600`, `10001:10001`; entrypoint maps it to `DATABASE_URL` only at process exec. It is denied to every migrator, storage runtime, fleet, ordinary CLI, and test except an explicit runtime-negative fixture. | +| Runtime URL (User rung) | `secret-{env}/mosaic-stack/database/runtime-user` (`url`) → `mosaic-db-runtime-user-url-v1` **(PROVISIONAL-CONVENTION — the exact Vault subpath is inferred from the Runtime URL row's naming pattern and is TO-CONFIRM against the live Vault layout at KBN-101-03/-05 implementation; the secret NAME `mosaic-db-runtime-user-url` is envelope-fixed, the `.../database/runtime-user` subpath is not.)** | Gateway only: `/run/secrets/mosaic-db-runtime-user-url`, `0600`, `10001:10001`; entrypoint maps it to the User-rung pool DSN only at process exec. Denied to every migrator, storage runtime, fleet, ordinary CLI, and test except an explicit runtime-negative fixture. | +| Runtime URL (Orchestrator rung) | `secret-{env}/mosaic-stack/database/runtime-orchestrator` (`url`) → `mosaic-db-runtime-orchestrator-url-v1` **(PROVISIONAL-CONVENTION — the exact Vault subpath is inferred from the Runtime URL row's naming pattern and is TO-CONFIRM against the live Vault layout at KBN-101-03/-05 implementation; the secret NAME `mosaic-db-runtime-orchestrator-url` is envelope-fixed, the `.../database/runtime-orchestrator` subpath is not.)** | Gateway only: `/run/secrets/mosaic-db-runtime-orchestrator-url`, `0600`, `10001:10001`; entrypoint maps it to the Orchestrator-rung pool DSN only at process exec. Denied to every migrator, storage runtime, fleet, ordinary CLI, and test except an explicit runtime-negative fixture. | +| Migration URL | `secret-{env}/mosaic-stack/database/migrator` (`url`) → `mosaic-db-migrator-url-v1` | Each one-shot migrator only: `/run/secrets/mosaic-db-migrator-url`, `0600`, fixed migrator UID:GID asserted by the image/render test; entrypoint maps it only to `DATABASE_MIGRATION_URL`. It is denied to Gateway, storage runtime, fleet, and ordinary CLI. | +| Importer target URL + provider version | **Vault KV-v2** `secret-{env}/mosaic-stack/database/importer` (`url`) with the same successful response `data.metadata.version` → generation-pinned `mosaic-db-importer-url-v` / `mosaic-db-importer-version-v` | The renderer consumes value and authenticated provider version as one generation, never derives version from DSN bytes. It renders separate immutable copies: migrator-attestation producer only gets `/run/secrets/mosaic-migrate-target-url` and `/run/secrets/mosaic-migrate-target-version`, each `10003:10003` `0400`; importer only gets the same two fixed paths, each `10002:10002` `0400`. Each consumer receives its own read-only mount, never a shared writable file. Runtime, Gateway, ordinary CLI, fleet, and unrelated containers receive neither file nor version. | +| Attestation signing key | `secret-{env}/mosaic-stack/database/migrate-target-attestation` (`private_key`) → `mosaic-db-migrate-target-ed25519-v1` | Runner root-wrapper only: fixed `/run/secrets/mosaic-db-migrate-target-ed25519`, `root:root` `0400`, referenced only by `MOSAIC_DB_ATTESTATION_SIGNING_KEY_FILE`; it opens once then drops to migrator `10003:10003`. No importer/runtime/Gateway mount or log/export is permitted. | +| Attestation public key ring | versioned deployment public-key bundle → `mosaic-db-migrate-target-ed25519-public-v1` | Importer only: pinned `/run/mosaic-attestations/migrate-target.ed25519.pub`, `10002:10002` `0400`; active/overlap key IDs are explicit and revoked IDs fail closed. It contains no private key. | +| Handoff verifier inputs | root-owned non-secret generation descriptor (expected provider version, URL SHA-256, generation ID) plus pinned public verifier key | Privileged controller only: `0400`; no importer URL bytes or private key. It verifies artifact bindings before copy/start. | +| Target attestation artifact | runner-produced non-secret file | Migrator writes only `/run/mosaic-attestations-producer/migrate-target.v1.json`, `10003:10003` `0400`, on a producer-only mount. After runner success, privileged deployment handoff controller verifies and atomically copies the exact signed bytes to a distinct importer-only `/run/mosaic-attestations/migrate-target.v1.json` mount, `10002:10002` `0400`, seals it read-only, then creates importer. No importer write access, shared writable mount, Gateway/runtime access, or unrelated-container mount exists. | | +| CA bundle | `secret-{env}/mosaic-stack/database/tls-ca` (`certificate`) → generation-pinned `mosaic-db-ca-v` | Explicit DB-client consumers only: Gateway `/run/secrets/mosaic-db-ca.crt` `10001:10001` `0444`; migrator same path `10003:10003` `0444`; importer at the exact `DATABASE_TLS_CA_CERT_PATH=/run/secrets/mosaic-db-ca.crt`, `10002:10002` `0444`. PostgreSQL receives a distinct read-only CA copy only when client-cert validation is enabled; no unrelated container receives it. | +| Gateway leaf certificate | `secret-{env}/mosaic-stack/federation/gateway-server-tls` (`certificate`) → `mosaic-gateway-server-cert-v1` | Gateway only: `/run/secrets/mosaic-gateway-server.crt`, `0444`, `10001:10001`; renderer emits this exact Compose and Swarm target and validates it before start. | +| Gateway private key | same Vault record (`private_key`) → `mosaic-gateway-server-key-v1` | Gateway only: `/run/secrets/mosaic-gateway-server.key`, `0400`, `10001:10001`; not mounted to migrator or PostgreSQL and never world-readable. | +| PostgreSQL leaf certificate | `secret-{env}/mosaic-stack/database/postgres-server-tls` (`certificate`) → `mosaic-postgres-server-cert-v1` | PostgreSQL only: `/run/secrets/mosaic-postgres-server.crt`, `0444`, frozen verified postgres UID:GID. | +| PostgreSQL private key | same Vault record (`private_key`) → `mosaic-postgres-server-key-v1` | PostgreSQL only: `/run/secrets/mosaic-postgres-server.key`, `0400`, frozen verified postgres UID:GID; never mounted to Gateway or migrator. | + +Compose renders each value-plus-provider-version generation into a non-repository temporary generation directory, `fsync`s every file and directory, atomically renames the complete generation, and mounts immutable per-consumer copies only after the pair is complete. Swarm declares distinct versioned secret/config references for each migrator/importer consumer and generation. A deployment may not start, reload, or combine any URL/version/CA/attestation/public-key material across generations. The privileged handoff controller is the only bridge from migrator producer mount to importer mount: it verifies the signed producer artifact plus generation, copies atomically, seals importer read-only, and only then starts importer; it is tested for failed/partial copy, wrong generation, wrong owner/mode, and no importer start. KBN-101-05 rejects bind-mounted committed cert/key files, environment-encoded PEM, missing secrets, non-atomic renderer output, mixed generations, shared writable files, private-key access outside its named consumer (Gateway for Gateway key; PostgreSQL for PostgreSQL key), importer URL/version/attestation/key access outside their named consumer, CA outside explicit DB clients, and any world-readable URL/key. The existing target Vault names are planned canonical paths and must be verified/provisioned by the deployment-owner input; the planning card does not claim they exist. + +The server leaf SANs are frozen to actual connection DNS names, not a configurable alias: + +| topology | PostgreSQL service DNS names that must be SANs | Gateway leaf SANs / consumers | +| ------------------------------------ | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| standalone compose | `DNS:postgres`, `DNS:localhost` (only for the documented host-port disposable test path) | `DNS:gateway`; its Gateway process consumes runtime URL + CA + Gateway leaf/key only | +| federated compose | `DNS:postgres-federated`, `DNS:localhost` (only for the documented host-port disposable test path) | `DNS:gateway-federated`; runtime/migrator consume only their database-specific material | +| Portainer/Swarm federated test stack | `DNS:postgres` (the in-stack service endpoint used by Gateway and migrator) | `DNS:gateway`; same consumer isolation | +| two-gateway harness | `DNS:postgres-a`, `DNS:postgres-b` | `DNS:gateway-a`, `DNS:gateway-b`; each Gateway gets only its own runtime URL, CA, and Gateway leaf/key; each `mosaic-db-migrator-{a,b}` gets only the matching migration URL and CA | + +A new topology requires a versioned amendment before issuance. The PostgreSQL container activation artifact sets `ssl=on`, `ssl_cert_file`, and `ssl_key_file` to those paths, uses a locked-down `postgresql.conf` include, and verifies file ownership/mode before start. The server health/readiness gate makes a `verify-full` CA/SAN-validated connection as the approved runtime/migrator identity; `pg_isready` alone is insufficient. Migration Job starts only after server TLS readiness. Gateway replicas start only after a successful runner result and independently pass verified-TLS, identity, search-path, and ledger readiness. In the two-gateway harness this ordering occurs independently as `postgres-a → mosaic-db-migrator-a → gateway-a` and `postgres-b → mosaic-db-migrator-b → gateway-b`; no Gateway starts against its database before its own runner certificate succeeds. + +**Fresh DB:** provision CA/leaf/secrets and server TLS configuration before initial database bootstrap; bootstrap/extension prerequisites run, then the runner migrates over verified TLS, then runtime deploys. **Existing DB:** take the approved backup, provision/mount TLS material, and **drain/scale to zero every N-1 runtime, worker, CLI maintenance process, and replica before TLS enforcement**. Enable server TLS, terminate any residual non-TLS PostgreSQL backend sessions, set `pg_hba.conf` to `hostssl` for all application/migrator CIDRs with no matching `host` rule, reload/restart as required, and prove the non-TLS session count is zero. Only then prove `verify-full` through the existing endpoint, run reconciliation/migration, and roll the non-owner TLS runtime. There is no plaintext transition interval. + +**Rotation/rollback:** stage a CA bundle containing old+new trust roots to runtime/migrator, validate a new server leaf with exact SANs, restart PostgreSQL and validate it, roll consumers, then remove the old root only after evidence. Credential rotation remains independent and never mounts migration material into Gateway. Before expiry, rollback restores the prior known-valid leaf/key and overlapping CA bundle, restarts PostgreSQL, enforces `hostssl`, terminates residual non-TLS sessions, and verifies `verify-full`; it never downgrades `sslmode`, restores a plaintext-only N-1 runtime after enforcement, or accepts plaintext. A pre-enforcement abort may restore the backed-up N-1 state only before `hostssl` is enabled and is recorded as an aborted—not activated—release. The runbook records expiry windows, secret versions, backup ID, drained-service/session evidence, activation actor, and validation result—not secret values. + +Required disposable tests cover standalone compose, federated/Swarm, and the two-gateway harness positives using verified TLS, plus for **both** gateway/database pairs: missing CA, wrong CA, wrong PostgreSQL SAN, wrong Gateway SAN, `sslmode` downgrade, missing/mispermissioned server or Gateway key, runtime-with-migrator-secret, cross-pair secret leakage, rendered secret-consumer/UID/GID/mode isolation, legacy plaintext drain/termination/`hostssl` enforcement, and readiness-before-migration negatives. PGlite local tests are explicitly classified as non-PostgreSQL and do not satisfy a PostgreSQL TLS test. + +## 6. Runtime verification and sanitized failures + +Before accepting traffic, runtime queries only parameterized/sanitized identity and privilege metadata on its already-open verified-TLS connection. It fails closed for owner/migrator identity or assumability; superuser/CREATEROLE/CREATEDB/BYPASSRLS; object/schema/database ownership; TEMPORARY; unexpected function execute; missing inherited capability/sequence/ledger grants; any non-exact search path; wrong schema/database target; bad TLS; or manifest/ledger mismatch. After KBN-100 it also checks every immutable grant/denial and relation presence. + +The runner performs reciprocal preflight under the same session/lock: dedicated migration DTO only, verified TLS, exact allowlisted target, migrator `session_user`, schema-owner `current_user`, exact trusted search path, and no unsafe attributes. It fails before DDL otherwise. + +Stable sanitized codes are `DATABASE_RUNTIME_URL_REQUIRED`, `DATABASE_MIGRATION_URL_REQUIRED`, `DATABASE_TLS_REQUIRED`, `DATABASE_TLS_VERIFICATION_FAILED`, `DATABASE_ROLE_UNSAFE`, `DATABASE_ROLE_GRANT_MISMATCH`, `DATABASE_SEARCH_PATH_UNSAFE`, `DATABASE_SCHEMA_MISMATCH`, `DATABASE_MIGRATION_RECONCILIATION_AMBIGUOUS`, `DATABASE_MIGRATION_LOCKED`, `DATABASE_MIGRATION_IDENTITY_UNSAFE`, `MIGRATE_TARGET_ATTESTATION_REQUIRED`, `MIGRATE_TARGET_ATTESTATION_INVALID`, `MIGRATE_TARGET_ATTESTATION_EXPIRED`, `MIGRATE_TARGET_ATTESTATION_REPLAYED`, `MIGRATE_TARGET_BINDING_MISMATCH`, `MIGRATE_TARGET_FILE_UNSAFE`, `MIGRATE_TARGET_GENERATION_MISMATCH`, `MIGRATE_TARGET_CA_REQUIRED`, and `MIGRATE_TARGET_CONSUMER_ISOLATION`. Logs/metrics may contain code, tier, manifest fingerprint, role class, and correlation ID only; never DSN, username, host, database name, SQL parameter, secret, or raw catalog result. External health exposes only unavailable/not-ready. + +## 7. Safe DAG, activation, and rollback authority + +Every KBN-101 card remains one PR with exclusive ownership. Cards `00`–`07` may merge only as **prepared, inactive capability**: no current owner-runtime deployment consumes their image/config, and no compatibility switch is exposed to a runtime operator. They must not retain `ALLOW_LEGACY_*`, runtime DDL, `DATABASE_URL` migration fallback, plaintext TLS, direct Drizzle, or test-only bypass flags. Current owner-runtime deployments remain on their known N-1 release until final activation. + +| Card | Depends on | Complete, disjoint file/glob manifest and required test/evidence paths | +| --------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `KBN-101-00` platform bootstrap / IaC | contract | **Only:** `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, `infra/pg-bootstrap/tests/**`. It creates the extension-owner role/schema/extension interface and proves fresh, approved-owner, legacy-shadow, catalog/default-privilege, and direct-denial bootstrap cases. No renderer, runner, Compose, CI, or deployment path. | +| `KBN-101-01` typed runtime config and verifier | 00 | **Only:** `packages/config/src/index.ts`, `packages/config/src/mosaic-config.ts`, `packages/config/src/mosaic-config.spec.ts`; `packages/db/src/client.ts`, `packages/db/src/defaults.ts`, `packages/db/src/connection-identity.ts`, `packages/db/src/client.spec.ts`, `packages/db/src/defaults.spec.ts`, `packages/db/src/connection-identity.spec.ts`; `apps/gateway/src/database/database.module.ts`, `apps/gateway/src/database/database.module.spec.ts`. It supplies runtime/migration/TLS DTO parsing plus runtime identity/search-path/readiness verification. No migrator, storage, installer, deploy, or CI path. | +| `KBN-101-10` declarative sink-RBAC + per-role connection-selection + credential-handoff | 00,01 | **Only:** `packages/db/src/sink-rbac/**`, `packages/db/src/credential-handoff/**`. It homes: the source-controlled declarative RBAC policy artifact + schema + evaluator; the fixed **rung ladder** + federation-aware identity→rung resolver; the rung→DB-credential **connection-selection** module (consumes -01 pools; import direction `10→01`); the **status-preserving-UPSERT** contract + **invariant field-set** module + **no-status view/rule/trigger enumeration**; and the **SCM_RIGHTS** credential-handoff + **non-dumpable** assert/verify library. It creates no roles, no DDL, no Compose/CI/deploy path; it is consumed by -00 (role names), -01 (connection wiring + identity/attribute verify), -02/-03/-05 (write path + fd handoff), specified-against by **KBN-100** (sink DDL: grants/RLS/`USING`+`WITH CHECK` policies/override-table/FORCE/unique-key/trigger-view-rule-invariant on `tasks`), and certified deployed by KBN-101-09. | +| `KBN-101-03` sole runner, manifest, and schema foundation | 00,01,10 | **Only:** `.gitattributes`; `packages/db/package.json`; `packages/db/drizzle.config.ts`; `packages/db/src/cli.ts`, `packages/db/src/cli.spec.ts`, `packages/db/src/index.ts`, `packages/db/src/index.import-negative.spec.ts`, `packages/db/src/migrate.ts`, `packages/db/src/migrate.test.ts`, `packages/db/src/schema.ts`, `packages/db/src/schema.spec.ts`; `packages/db/src/migrator/**`; `packages/db/drizzle/*.sql`, `packages/db/drizzle/meta/*.json`; `docker/db-migrator.Dockerfile`, `docker/db-migrator.Dockerfile.spec.ts`; `packages/db/package-bin.spec.ts`. It alone publishes `"mosaic-db-migrator": "./dist/cli.js"`, verifies source/build/pack/discovery, and sets `ENTRYPOINT ["mosaic-db-migrator"]`; it exclusively owns `packages/db/src/migrator/target-attestation.dto.ts`, `target-attestation-signer.ts`, and their specs: fixed-key reference validation, JCS canonical payload, Ed25519 signing, producer TLS/identity/manifest binding, atomic artifact emission, and producer tests. It owns journal/manifest/ledger/lock/relocation/shadow tests. Shipped `0009` bytes stay unchanged. | +| `KBN-101-02` runtime DDL closure | 01,03,10 | **Only:** `docker/init-db.sql`, `infra/pg-init/01-extensions.sql`; `packages/storage/src/adapters/postgres.ts`, `packages/storage/src/adapters/postgres.spec.ts`, `packages/storage/src/factory.ts`, `packages/storage/src/factory.spec.ts`, `packages/storage/src/types.ts`, `packages/storage/src/tier-detection.ts`, `packages/storage/src/tier-detection.spec.ts`, `packages/storage/src/cli.ts`, `packages/storage/src/cli.spec.ts`, `packages/storage/src/migrate-tier.ts`, `packages/storage/src/migrate-tier.spec.ts`, `packages/storage/src/migrate-tier.integration.test.ts`; `apps/gateway/src/main.ts`, `apps/gateway/src/__tests__/integration/federated-boot.pg-unreachable.integration.test.ts`, `apps/gateway/src/__tests__/integration/federated-boot.success.integration.test.ts`, `apps/gateway/src/__tests__/integration/federated-pgvector.integration.test.ts`; `packages/db/src/federation.integration.test.ts`; `packages/mosaic/src/commands/fleet-backlog.ts`, `packages/mosaic/src/commands/fleet-backlog.spec.ts`. It consumes the -03 runner and exclusively owns importer verification/interface in `packages/storage/src/{cli,migrate-tier}.ts` and the named unit/integration specs: fixed URL/version/attestation/public-key safe-open fds (`O_RDONLY | O_CLOEXEC | O_NOFOLLOW`plus post-open regular-owner-mode-link-count validation), one-read protected-memory URL connection, fd zeroization/close, signature/key/expiry/replay/authenticated-provider-version/digest/generation/CA/binding validation, no forwarding/logging/oracle, post-TLS zero-DML comparison, consumer isolation, and DDL classifier. It closes runtime/retired-init DDL only; removes the current Gateway production`.env`/monorepo auto-load path in favor of the -05 renderer-backed process-exec or `LoadCredential` consumer boundary; and excludes every -03 signer/runner, index, migrate, and Drizzle-config asset, and every deployment/CI/doc path. | +| `KBN-101-04` installer/wizard | 01 | **Only:** `packages/mosaic/src/stages/gateway-config.ts`, `packages/mosaic/src/stages/gateway-config.spec.ts`, `packages/mosaic/src/stages/gateway-config-cors.spec.ts`, `packages/mosaic/src/stages/wizard-menu.spec.ts`, `packages/mosaic/src/wizard.ts`. It persists only non-secret references/injected-variable contracts; source inspection excludes `tools/install.sh`, which does not read/write the database DSN. | +| `KBN-101-05` renderer and deployment | 00,03,10 | **Only:** `tools/db/render-postgres-secrets.ts`, `tools/db/render-postgres-secrets.spec.ts`; `apps/gateway/Dockerfile`, `apps/gateway/Dockerfile.spec.ts`; `docker-compose.yml`, `docker-compose.spec.ts`; `docker-compose.federated.yml`, `docker-compose.federated.spec.ts`; `deploy/portainer/federated-test.stack.yml`, `deploy/portainer/federated-test.stack.spec.ts`; `tools/federation-harness/docker-compose.two-gateways.yml`, `tools/federation-harness/docker-compose.two-gateways.spec.ts`. It consumes the -00 bootstrap interface and -03 immutable runner image, and exclusively renders/tests fixed Gateway/importer/migrator UIDs; runner-only root-owned signing-key reference; KV-v2 importer URL plus same-response `data.metadata.version`; separate immutable generation-pinned URL/version mounts for `10003:10003` migrator and `10002:10002` importer; importer-only CA at `DATABASE_TLS_CA_CERT_PATH`, public-key/key-ring, and controlled producer-only-to-importer-only attestation handoff mount; privileged controller verification/copy/fsync/atomic-rename/seal-before-importer-start behavior; Compose generation-dir fsync/atomic-rename and Swarm versioned-secret/config no-mixed-generation behavior. It owns no bootstrap, runner, config, storage, or CI file. | +| `KBN-101-07` operator/runbook/docs | 02,03,04,05 | **Only:** `README.md`, `CLAUDE.md`, `docs/guides/admin-guide.md`, `docs/guides/dev-guide.md`, `docs/guides/deployment.md`, **`docs/guides/migrate-tier.md`**, `docs/guides/user-guide.md`, `docs/federation/MILESTONES.md`, `docs/federation/SETUP.md`, `docs/federation/TASKS.md`, `docs/fleet/backlog-conventions.md`, `docs/PERFORMANCE.md`, `docs/design/storage-abstraction-middleware.md`, `docs/plans/2026-03-15-agent-platform-architecture.md`, `docs/plans/2026-03-15-wave2-tui-layout-navigation.md`, `docs/runbooks/kbn-101-database-role-split.md`, `docs/reports/native-kanban-sot/kbn-101-operator-readiness-report.md`, `docs/native-kanban-sot/tests/kbn-101-operator-docs.spec.ts`. It exclusively owns the active migrate-tier operator route; local PGlite/non-PostgreSQL Compose disposition; the held PostgreSQL/federated activation order; and the non-operative production renderer/Vault generation-pinned process-exec or `LoadCredential` consumer-isolation schematic. It documents interfaces produced by -02/-03/-04/-05, including both file references, signing-key isolation, attestation bindings, rotation/replay, no-connection/no-DML errors, and the ban on current production `.env`, monorepo auto-load, environment-file, credential export/argv, or restart-as-secret-activation guidance; it owns no source, storage, CLI, runner, or CI file. | +| `KBN-101-06` CI classifier and command matrix | 02,03,05,07,10 | **Only:** `.woodpecker/ci.yml`, `tools/ci/kbn101-ddl-inventory.ts`, `tools/ci/kbn101-ddl-inventory.spec.ts`, `tools/ci/fixtures/kbn101-ddl-inventory.json`, `tools/ci/kbn101-entrypoint-matrix.ts`, `tools/ci/kbn101-entrypoint-matrix.spec.ts`. It invokes the already-produced bin/image/deployment/doc artifacts and edits no producer file. Its inventory test enforces manifest overlap, ownerless, duplicate-owner, path-existence, allowlist, active-route field completeness, finite operator-document inventory, normative/status-only non-masking, unknown-command, and historical/status-only masking failures. Before inventory, ownership, or status masking, its semantic suite fails the exact README commented code-fence generic-wrapper form and exact user-guide executable generic-wrapper form recorded in §2.2, and source-consistency opens `packages/storage/src/cli.ts` to prove its current direct-Drizzle `pnpm --filter @mosaicstack/db db:migrate` `execSync` implementation and absence of a `mosaic-db-migrator` bin make runner-delegation copy false. It also fails former `SETUP.md` automatic-first-boot/startup, the exact former `MILESTONES.md` wording, and the exact former README/dev/deployment Compose-first sequences before an inventory record can mask any path; it fails Compose-up-before-runner, extension/schema/migration automatic wording, init-script authority, production `.env`, `EnvironmentFile=`, credential shell export/argv, and restart-as-secret-activation. It passes the rc.16 held PGlite-data-layer/non-PostgreSQL-Compose disposition (and fails Gateway/Web local start while daemon/inherited/root/app-local DSN or non-local-tier input could select PostgreSQL), the one `Held future procedure` section with KBN-101-00/-03/-05 and external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose-readiness, and the README legacy-N-1 CI status assertion; every runner hit outside that section and every current-CI authority variant fails before masking. Its matrix invokes the -02 secure target route and verifies URL/version generation mismatch, provider rotation/revocation, consumer isolation, safe-open TOCTOU/link/mode/owner, absent/wrong importer CA, no log/oracle, every declared attestation refusal before target connection/DDL, and post-connect mismatch with zero DML. | +| `KBN-101-08` foundation certification and **atomic activation release** | 00…07,10 | **Only evidence:** `docs/reports/native-kanban-sot/kbn-101-foundation-activation-certificate.md`, `docs/reports/native-kanban-sot/kbn-101-foundation-activation-evidence.json`. It changes no implementation path. Independent review and terminal-green CI must verify prepared artifacts before Mosaic control plane/Jason authorizes backup → drain/scale-zero N-1 → TLS → roles → runner → verified readiness → rolling runtime; any red result aborts. | +| `KBN-101-09` post-KBN-100 certification | KBN-100,08 | **Only evidence:** `docs/reports/native-kanban-sot/kbn-101-immutable-role-certificate.md`, `docs/reports/native-kanban-sot/kbn-101-immutable-role-evidence.json`. It changes no implementation path and records real deployed runtime INSERT/SELECT plus UPDATE/DELETE-denial evidence and independent security/Ultron approval. | + +The manifests above are the complete ownership universe for KBN-101 implementation paths **including KBN-101-10**; the KBN-101-06 inventory test fails on overlap, an ownerless in-scope path, or a nonexistent declared path. Cards `00`–`07` are prepared artifacts, not independently deployed releases: the immutable N-1 owner-runtime image stays live until KBN-101-08 control-plane atomic activation. No activation card edits a source-changing path, and no runtime bypass or broken deployed intermediate exists. + +**Authority:** Mosaic control plane/Jason is the sole activation and rollback authority. CI, Gateway, migrator, Coordinator, and Certifier cannot activate, waive a red result, or force release. Before an incompatible KBN-100 switch, the authority stops/scales runtime, uses the approved backup/restore or separately reviewed runner artifact, restores only a known TLS-compatible runtime with its runtime secret after `hostssl` enforcement, and verifies no plaintext sessions plus TLS/readiness. Migration URL is never injected into Gateway to enable rollback. KBN-100 starts only after KBN-101-08; KBN-105 starts only after KBN-101-09. + +**ASSUMPTION K101-A2:** every eligible production-like deployment can schedule a dedicated migration Job/one-shot command and an operator/IaC-controlled TLS bootstrap. A target that cannot do both is ineligible for KBN certification. + +## 8. Acceptance traceability + +| Requirement / acceptance criterion | Required implementation evidence | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| K101-REQ-01 / AC-K101-01 | DTO/command matrix covers local/PGlite, standalone, federated, and both harness pairs; every finite classified §2 path rejects `DATABASE_URL`-only before connection/DDL; no runtime fallback/default; `--help`, argv, import-compile, and live-operator-route negatives pass. Before any inventory/status mask, the semantic fixture fails the exact README commented code-fence and user-guide executable former generic-wrapper forms in §2.2; source-consistency proves current `packages/storage/src/cli.ts` directly executes `pnpm --filter @mosaicstack/db db:migrate` and has no `mosaic-db-migrator` bin, so runner-delegation prose cannot pass. The active migrate-tier route proves runner-produced JCS/Ed25519 attestation, root-owned signing/private-key and importer-public-key isolation, Vault KV-v2 `secret-{env}/mosaic-stack/database/importer` URL plus same-response authenticated `data.metadata.version`, separate immutable renderer generations, importer CA, exact `--target-url-file /run/secrets/mosaic-migrate-target-url` plus version file and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`, safe-fd/digest/TLS/server/database/role/manifest/schema bindings, expiry/replay/provider-rotation/revocation/TOCTOU checks, bounded non-DDL importer DML, and consumer-isolation/no-log-oracle plus no-connection versus zero-DML refusal. | +| K101-REQ-02 / AC-K101-02 | KBN-101-03 one-session fixed two-int lock, `--run`/`--verify` exit-code, contention/crash/readiness/unrelated-key tests; manifest-v1 canonical bytes/digest and all reconciliation states; Gateway/replica DDL impossibility. | +| K101-REQ-03 / AC-K101-03 | KBN-101-00 bootstrap schema/extension-owner/default-privilege and direct-denial proof against actual PostgreSQL 17 + pgvector 0.8.2 control metadata (`trusted` absent/untrusted, `relocatable=true`); external-superuser `SET ROLE` create/update/`RESET ROLE` audit proof; `rolcanlogin=false`, `rolsuper=true`, zero members/no runtime credential proof; KBN-101-03 catalog relocation and future-Drizzle-only-`mosaic` proof plus approved-owner/shadow runner integration; `pg_namespace.nspowner`, `pg_extension.extowner`, owner-bearing member/schema/version, and runtime/migrator/schema-owner/importer/all-service-role catalog plus `SET ROLE`/ALTER/DROP/member-update denials; KBN-101-01/03 role, path, TEMP, ledger/default-grant, and pool-reset tests. | +| K101-REQ-04 / AC-K101-04 | KBN-101-00 bootstrap-interface and KBN-101-05 renderer/deployment tests; immutable-image exact Job commands and runner-before-readiness order; fresh/existing verified-TLS Compose/Swarm/two-gateway positives; both-pair CA/SAN/downgrade/key-permission negatives; exact UID/GID/mode and runtime/migrator secret-consumer rendering/CI negatives. | +| K101-REQ-05 / AC-K101-05 | KBN-101-09 after KBN-100: real deployed runtime INSERT/SELECT success and UPDATE/DELETE denial for each frozen relation, with RESTRICT retention evidence. | +| K101-REQ-06 / AC-K101-06 | KBN-101-00…08 prepared-card/no-intermediate-deploy evidence; one final activation authority record; N-1 drain/zero-plaintext-session/`hostssl`, backup/restore, CA overlap rotation, TLS-only rollback, Vault/redaction, and no-force-on-red evidence. | +| K101-REQ-07 / KBN sequence | KBN-101-08 foundation certificate before KBN-100, KBN-101-09 real immutable-role certificate plus Ultron approval before KBN-105; KBN-100 rebases/restores Drizzle consistency and never bypasses the serial gates. | +| Delivery integrity | One-card/one-PR DAG, exact file ownership, docs/link/contract checks, independent author≠reviewer re-review on the pushed exact head, and terminal-green CI for implementation cards. | + +## 9. Non-goals and residual authority + +KBN-101 planning does not create roles, certificates, Vault paths, migrations, deployment artifacts, or a deployed certificate. It does not replace KBN-100’s data migration, immutable retention, tenant constraints, or KBN-105 endpoint freeze. A PostgreSQL superuser/break-glass operator—including the deliberately isolated extension owner—remains outside application containment: `GRANT`/`REVOKE` cannot constrain that authority. Separate identity/non-login/zero-membership/external-control/audit controls, independent review, maintenance windows, backup/rollback evidence, and drills are mandatory. diff --git a/docs/native-kanban-sot/KBN-101-ENVELOPE-A.md b/docs/native-kanban-sot/KBN-101-ENVELOPE-A.md new file mode 100644 index 00000000..1324de55 --- /dev/null +++ b/docs/native-kanban-sot/KBN-101-ENVELOPE-A.md @@ -0,0 +1,377 @@ +# KBN-101 — B1/B2 Envelope A (v6, FINAL) — Declarative Sink-RBAC + Per-Role Credential/Connection-Selection + RLS Write-Source (INSERT tenant-bound, single-compound-or-RESTRICTIVE composition) + Sink-Resident User-Override + Read/USING Enforcement + +**Ratification status:** RATIFIED — part of the frozen SSOT as of this PR (KBN-101 Envelope A, landed **FORM A — apply-in-place**). This is the converged **v6** envelope, ratified as the authoritative record of the **rc.20** contract amendment now inlined into [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) §4/§10, [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) (`### 1.0.0-rc.20`), and [`MISSION-MANIFEST.md`](./MISSION-MANIFEST.md) §10. + +**Ratification lineage (authoritative):** Jason B1 ruling (2026-07-20T23:08Z) + Mos coordinator **OPTION A** (home the layer) + **Mos Q1** ruling (TIER-LEVEL) + **Mos Q2** ruling (RLS `WITH CHECK` AUTHORIZED, two mandatory conditions — `ENABLE`/`FORCE ROW LEVEL SECURITY` as the terminal migration step, and non-owner rung roles) + **Gate A** (`a11a1e2c`) + **Gate B** (`a6aac2cd`). + +> **Ratification note.** The sections below are the converged v6 envelope text, retained **VERBATIM** for traceability. Their design-time framing — e.g. "**Scope:** DESIGN / CONTRACT-AMENDMENT only. No implementation code, no PRs, no SSOT edits." and "**Base:** … this doc modifies nothing there." — describes the envelope as originally authored against base `b0d78d86`. As of this PR that rc.20 amendment is **ratified and applied in-place (FORM A)** to the frozen SSOT; the design-time "no SSOT edits" wording is historical authoring context, not a live constraint on this ratified record. The Ratification lineage above is authoritative. + +--- + +**Status:** v6 envelope (design / contract-amendment), FINAL. **v6 = v5 + N1/N2/F-NB4 non-blocking test/lint/cert hardening ONLY; NO design predicate/policy/grant/role/table change.** v5 CONVERGED — BOTH terminal re-gate-4 gates returned GO (Gate A-delta-4 `a4ce075a`; Gate B-delta-4 `a379e0de`), NO HALT; Gate B-delta-4 flagged 3 EXPLICITLY NON-BLOCKING hardening notes "for the trunk-commit record," and v6 folds exactly those 3 as additive test/lint/cert wording on an already-BOTH-GO design: **N1** (strengthen the `pg_policy.polpermissive` lint — group by effective role incl. `PUBLIC`/inheritance; reject an internally-disjunctive single `WITH CHECK`; behavioral positive-controls remain the PRIMARY proof), **N2** (state the option-(b) vacuous-deny guard invariant — ≥1 permissive policy per writable (rung, command)), and **F-NB4** (extend owner non-reachability to SET-ROLE assumability — conjoin the `MEMBER` variant of `pg_has_role`). No design predicate, policy, grant, role, or table is changed. v6 supersedes v5 (`a6e790fd`), which supersedes v4 (`a48ad69c`). Preserves EVERY verified v4 closure (all checks passed on v4 EXCEPT one bounded item) and closes the SINGLE remaining BLOCKING finding raised identically by BOTH re-gate-3 gates (Gate A-delta-3 `aa5cbf73` = CHANGES-NEEDED; Gate B-delta-3 `aa881074` = 1 blocking): the **RLS `WITH CHECK` composition semantics** — v4's construction is right but it justifies the composition with a FALSE PostgreSQL claim ("PostgreSQL requires ALL applicable `WITH CHECK` clauses to pass / they AND") and leaves the _realization_ unspecified, so an implementation could realize the status-pin, the workspace-bind, and the override as SEPARATE PERMISSIVE policies which combine with **OR** (not AND) → a foreign-workspace INSERT satisfying only the status-pin would be admitted (B2-INSERT / B-1 re-open). v5 (a) corrects the semantics, (b) **mandates** the composition be realized as a SINGLE compound `WITH CHECK` OR as `AS RESTRICTIVE` policies and **explicitly forbids** the multi-permissive-intended-to-AND realization, and (c) hardens the red-first negatives to an otherwise-valid row + adds a positive-control. It also folds Gate B-delta-3's NB-4-transitive nit (state owner non-reachability as TRANSITIVE membership). This is a PRECISE, LIGHT revision: every v4 closure (3-rung ladder / zero per-user; F1–F7; GA-1/3/4; B-1/B-2/B-3; B2-INSERT workspace bind itself; N-1..N-5; AD-1/AD-2; NB-1..NB-5; continuous -06 scan + L174 GUC fold; rc.20) carries forward intact. Prior rc.18/rc.19 evidence does NOT carry. +**Authoring authority:** Jason B1 ruling (2026-07-20T23:08Z) + Mos coordinator **OPTION A** (home the layer) + **Mos Q1 ruling (TIER-LEVEL)** + **Mos Q2 ruling (RLS `WITH CHECK` AUTHORIZED, two mandatory conditions)**. No new coordinator authority is required for v5: the composition fix is a DDL-realization detail + corrected PostgreSQL-semantics statement + test-wording hardening entirely inside the already-authorized `WITH CHECK`/`FORCE RLS` mechanism (Q2); it introduces no new principal, key, or authority axis. **Both delta-3 gates independently confirmed the per-user/same-tier boundary HOLDS** (the crit-1 defect is a TENANT-ISOLATION correctness risk, NOT a per-user authority axis). +**Base:** frozen SSOT at `b0d78d86`, `/src/mosaic-stack/docs/native-kanban-sot/` (READ-ONLY; this doc modifies nothing there). +**Scope:** DESIGN / CONTRACT-AMENDMENT only. No implementation code, no PRs, no SSOT edits. + +**HALT self-check result (re-run for v5): NO HALT.** No v5 change forces two SAME-TIER writers to hold DIFFERENT WRITE AUTHORITY. The v5 composition fix pins **how** the already-uniform predicates are physically composed (one compound `WITH CHECK`, or `AS RESTRICTIVE`) — it changes no predicate's _content_ and adds no writer-varying axis; both delta-3 gates confirmed this is a tenant-isolation correctness pin, not a per-user distinction. The B2-INSERT fix ANDs a **shared, per-request workspace predicate** (`workspace_id = current_setting('mosaic.workspace_id', true)::uuid`) into every rung's INSERT `WITH CHECK` — applied **uniformly** to every writer in a tier, exactly like the B-2 read/`USING` predicate; it is tenant isolation, never a per-writer distinction. The B-1 sink-resident User-override remains keyed on **(workspace_id, task_id, tier)** — task-identity + tier ONLY, **NO per-federated-writer key** — so every writer inside a given tier is subject to the _identical_ veto for a given task. The five NB fixes (override-relation no-status enumeration; two claim-scopings; owner-toggle-FORCE containment naming; baseline runtime grants for the two capability roles) introduce **zero** per-writer authority. Per-writer _attribution_ remains an AUDIT concern routed to metadata (`task_events.actor_id`), never a DB credential/role/RLS key. The design remains satisfiable with the **fixed 3-rung role ladder** (User / Orchestrator / others), federated identity → rung at authorization time. The Q1 hard re-open trigger (same-tier / different write-authority) is **not** hit. See §5. + +--- + +## 0. What the "raw task-status sink" is (grounding — unchanged from v1/v2) + +- The canonical task-status store is **`tasks.status`** (SHARED-CONTRACT §5.4 L214 "canonical authority"); legacy **`mission_tasks.status`** is frozen read-only / prohibited as a write source (SHARED-CONTRACT §5.4 L219; §5.1 phase 1 L165). `tasks` is a **mutable** relation created by **KBN-100**. Canonical statuses are `backlog | ready | in_progress | blocked | in_review | done | cancelled` (SHARED-CONTRACT §3 L134); initial creation state is `backlog` (§5.4 L214 not-started→backlog). +- The append-only event relations `task_events, artifacts, task_checkpoints, task_checkpoint_artifacts, approval_decision_artifacts` receive runtime-only `SELECT, INSERT` with `UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER` **denied** (frozen §4 L178). That producer/consumer precedent — grants applied **after** KBN-100 creates the relations — is the mechanism this envelope extends to the mutable `tasks.status` sink and to the new sink-resident override table (§2.9). +- Today there is exactly one runtime login, `mosaic_runtime` (LOGIN INHERIT), sole member of NOLOGIN `mosaic_runtime_capability` (§4 L149-150). The sink therefore has **no writer-authority discrimination** — the root of RC19-B1-01. + +The three B1 findings are writes to this sink; the two B2 findings are the privileged-fd → UID-drop handoff used by the runner/importer (§5 L184/L191). This envelope homes one coherent layer over both, now with the User-override and the SELECT/tenant predicate **resident at the sink**. + +**[NB-2 / NB-3 — precise compromise-resistance scope (Mos Q1 accepted co-resident-pool residual; NO HALT).]** The prior "a compromised Gateway cannot bypass a User deny or read cross-workspace" framing is **overstated** and is corrected here. All three rung credentials are co-resident in one Gateway process (DB authenticates the _credential_, never the federated end-user — the accepted Mos Q1 residual), and the workspace GUC is _app-set_ with no per-tenant DB check. Precisely: + +- **B-1 sink-enforces the User veto against the _Orchestrator-rung path_** — an Orchestrator-pool write to a task carrying an active User deny ERRORS in PostgreSQL regardless of app-layer behavior (a _buggy_ or Orchestrator-path-compromised Gateway cannot skip it). It does **NOT** stop a Gateway compromised badly enough to hold the **User** pool: User = god and may itself clear any veto (User-write-only on the override table). That is the accepted co-resident-pool residual, not a new hole. +- **B-2 + B2-INSERT sink-enforce tenant isolation against a _buggy_ Gateway** — an omitted/wrong workspace predicate fails **closed** (reads return zero rows; INSERTs ERROR on the NULL/mismatched GUC). They do **NOT** defend a _compromised_ Gateway that forges `mosaic.workspace_id` to a victim tenant, because the GUC carries no per-tenant DB authentication (accepted Mos Q1 residual). The genuine, real closure B2-INSERT delivers is the **buggy / unbound-INSERT** hole: without it, _any_ rung could write a foreign `workspace_id` on INSERT even with a correct GUC set — that is now impossible. + +Defending against a fully compromised Gateway forging identity/tenant requires per-federated-user DB credentials = the HALT boundary; Mos Q1 explicitly accepts this residual and rules NO HALT. The claims in §2.9 (B-1) and §2.2/§2.4 (B-2/INSERT) are scoped to match. + +--- + +## 1. Contract amendment (exact ownership + text changes) + +### 1.1 Homing decision — one NEW owner card + minimal responsibility-widenings + +The layer is genuinely **new scope**. The **minimal** closed/disjoint expansion adds **one new owner card, `KBN-101-10`**, owning two brand-new, currently-unowned globs (disjoint from every existing manifest → KBN-101-06 overlap/ownerless/path-existence still pass): + +- `packages/db/src/sink-rbac/**` — B1 layer (policy artifact + schema + evaluator + ladder + connection-selection + status-UPSERT + invariant-set + view/rule-forbid enumeration). +- `packages/db/src/credential-handoff/**` — B2 library (SCM_RIGHTS + non-dumpable assert/verify). + +Plus **responsibility-widenings of already-owned files** (no manifest-glob change → no overlap): + +| Existing card | Already-owned path(s) touched | Widened responsibility (v4; v3 items preserved) | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `KBN-101-00` | `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/tests/**` | **[GA-1 FIX]** Create the **fixed rung-ROLES** (3 LOGIN roles + their capability roles) with **deny-by-default base attributes** and **CONNECT** grants; bootstrap catalog tests that each rung role carries the safe attributes (F2) and is **non-owner** of the sink. **[AD-1 FIX]** Amend the frozen §4 **L176 CONNECT-allowlist certification** so the two new LOGIN rungs (`mosaic_runtime_user`, `mosaic_runtime_orchestrator`) are admitted to the `CONNECT` allowlist **and asserted `TEMPORARY`-denied**; otherwise the frozen L176 cert test REDS (it fails if an app login retains TEMPORARY, and it enumerates the CONNECT allowlist). **[NB-5 FIX]** Grant the two new capability roles (`mosaic_runtime_user_capability`, `mosaic_runtime_orchestrator_capability`) the **baseline frozen §4 L176 runtime grants** they need to function: `USAGE ON SCHEMA mosaic`, `USAGE ON SCHEMA drizzle` + `SELECT ON drizzle.__drizzle_migrations, drizzle.__mosaic_migration_manifest` (ledger read), and the relevant sequence `USAGE`/`SELECT` grants — mirroring what `mosaic_runtime_capability` already holds; without these the two new rungs cannot connect/operate and would FAIL the frozen §6 L222 runtime verify (missing inherited capability/sequence/ledger grants → fail-closed). **It owns NO DDL on `tasks` or the override table** — those relations do not exist at bootstrap time, so RLS/column-grant/policy/FORCE/unique-key/trigger DDL **cannot** live here. | +| `KBN-100` (producer; SPEC target of -10) | its own `packages/db/src/schema.ts` + generated `packages/db/drizzle/**` migration + migration tests | **[GA-1 + GA-2/Q2 + B-1 + B-2 + B-3 + B2-INSERT FIX]** Homes ALL `tasks`-specific sink DDL **and the new `task_status_write_override` table DDL** **after** it creates `tasks`, per the frozen §4 L178 producer/consumer precedent: the rung column-grant matrix (F1/F7/N-3), the **RLS `WITH CHECK` write-source policies** (Q2/F1/N-3), **[B2-INSERT] the workspace-predicate `workspace_id = current_setting('mosaic.workspace_id', true)::uuid` as a conjunct of EVERY rung's INSERT enforced check on `tasks` — realized as a single compound `WITH CHECK` OR `AS RESTRICTIVE`, NEVER as separate permissive policies (v5 composition mandate, §1.4 item 3-bis) — (and thus into the UPSERT INSERT arm), and the symmetric INSERT workspace binding on `task_status_write_override`** so tenant isolation is homed at the sink on the write path too, the **per-rung `SELECT`/`UPDATE` `USING` tenant policies** (B-2), the **override-table subquery** in every `tasks` write policy (B-1), the `tasks (workspace_id,id)` **UNIQUE** candidate key (F4), the `actor_id` attribution metadata column (Q1 audit channel), the **no-status-normalizing-trigger / no-status-view / no-status-rule** invariants (F3/N-1/N-2) — **[NB-1] extended to cover the `task_status_write_override` relation as well** (no status-writing trigger/RULE/SECURITY DEFINER function on the override relation may write `tasks.status`), and — as the **TERMINAL migration step** — `ENABLE`/`FORCE ROW LEVEL SECURITY` on `tasks` and on the override table, run strictly **AFTER** all expand/backfill/repair DML (B-3). **KBN-100's DDL scope statement is explicitly amended** to include RLS + the override table on `tasks` under Mos Q2 coordinator co-authorization (§1.4). Certified deployed by KBN-101-09. | +| `KBN-101-01` | `packages/db/src/connection-identity.ts`(+spec), `apps/gateway/src/database/database.module.ts`(+spec) | Provision **only the three generic rung pools** at boot; set the per-request **workspace session predicate** (B-2) on each checked-out connection. **[F2 FIX]** Extend the frozen L222 unsafe-attribute checkout assertion to **each** rung connection: effective role == authorized rung **AND** fail closed on SUPERUSER / CREATEROLE / CREATEDB / REPLICATION / **BYPASSRLS** / ownership of the sink tables (`DATABASE_ROLE_UNSAFE`). **[AD-2/N-5 FIX]** -01 **provisions pools only**; it does **not** import -10. The rung→pool SELECTION lives in -10's `connection-selection.ts`, which **consumes** -01's provisioned pools (import direction `10→01`; §2.2). | +| `KBN-101-03` | `packages/db/src/migrator/**` (glob), **`docker/db-migrator.Dockerfile`** | Consume -10's credential-handoff: **SCM_RIGHTS-only** fd acquisition + **non-dumpable re-verify** after the drop to `10003:10003`. **[GA-4(ii) FIX]** The root-only **migrator launch wrapper** homes here (the migrator image). | +| `KBN-101-05` | `apps/gateway/Dockerfile`(+spec), renderer secret matrix | Renderer mounts **three** rung DSN secrets (`mosaic-db-runtime-url` + `mosaic-db-runtime-user-url` + `mosaic-db-runtime-orchestrator-url`), each `0600 10001:10001`, Gateway-only. Gateway rung-connection selection is a **DB-role choice, not a UID drop**, so -05 owns **no** SCM_RIGHTS/dumpable behavior (moved to -03 per GA-4(ii)). | +| `KBN-101-02` | `packages/storage/src/{cli,migrate-tier}.ts`, `packages/storage/src/adapters/postgres.ts` (already-owned) | Importer privileged-fd case uses SCM_RIGHTS (extends existing safe-open, drop to `10002:10002`); status writes go through the -10 status-preserving-UPSERT helper. | +| `KBN-101-06` | `tools/ci/fixtures/kbn101-ddl-inventory.json`, `.woodpecker/ci.yml` | Add `KBN-101-10` to the inventory fixture/command matrix; add the rung-selection + SCM_RIGHTS + **per-tier RLS negatives** + **override-veto negative** + **cross-workspace read negative** + **[B2-INSERT] cross-workspace INSERT/UPSERT negative** + **NULL-GUC INSERT negative** + **owner/backfill-ordering** cases to the matrix. **[Gate A-delta-2 obs #2 / N-2 continuous — ADOPTED]** Home the **continuous no-status catalog scan as a -06 CI gate that runs on EVERY migration** (promoted from the v3 one-shot -09 recommendation): the scan asserts no status-writing trigger/RULE/`SECURITY DEFINER` function/view on `tasks` **or on `task_status_write_override`** (NB-1), so a future migration that adds a status-write path fails CI, not only the one-time cert. | + +### 1.2 §7 manifest expansion (KBN-101-06 must still pass) + +Add exactly one row to the §7 card table (complete, disjoint ownership universe): + +> | `KBN-101-10` declarative sink-RBAC + per-role connection-selection + credential-handoff | 00,01 | **Only:** `packages/db/src/sink-rbac/**`, `packages/db/src/credential-handoff/**`. It homes: the source-controlled declarative RBAC policy artifact + schema + evaluator; the fixed **rung ladder** + federation-aware identity→rung resolver; the rung→DB-credential **connection-selection** module (consumes -01 pools; import direction `10→01`); the **status-preserving-UPSERT** contract + **invariant field-set** module + **no-status view/rule/trigger enumeration**; and the **SCM_RIGHTS** credential-handoff + **non-dumpable** assert/verify library. It creates no roles, no DDL, no Compose/CI/deploy path; it is consumed by -00 (role names), -01 (connection wiring + identity/attribute verify), -02/-03/-05 (write path + fd handoff), specified-against by **KBN-100** (sink DDL: grants/RLS/`USING`+`WITH CHECK` policies/override-table/FORCE/unique-key/trigger-view-rule-invariant on `tasks`), and certified deployed by KBN-101-09. | + +Amend the §7 L245 closing sentence (added clause **bold**): "The manifests above are the complete ownership universe for KBN-101 implementation paths **including KBN-101-10**; the KBN-101-06 inventory test fails on overlap, an ownerless in-scope path, or a nonexistent declared path." + +**Dependency edges (v3, unchanged from v2 — acyclic; -10 depends only on 00,01):** + +- `KBN-101-10 depends on 00,01` +- `KBN-101-02 depends on 01,03,10` +- `KBN-101-03 depends on 00,01,10` +- `KBN-101-05 depends on 00,03,10` +- `KBN-101-06 depends on 02,03,05,07,10` +- `KBN-101-08 depends on 00…07,10` +- `KBN-101-09 depends on KBN-100,08` (unchanged; evidence-only) + +**Why still disjoint (KBN-101-06 green — UNCHANGED from v2):** the only NEW ownership is the two -10 globs. The B-1 override table, B-2 `USING` policies, and B-3 terminal-FORCE ordering are **all producer DDL homed in KBN-100's already-owned `schema.ts`/`drizzle/**`** (a *responsibility* widening on files KBN-100 already owns, exactly like the `tasks`sink DDL). The N-1/N-5 additions live inside -10's already-declared`sink-rbac/**`glob. AD-1 amends -00's already-owned`roles.sql`/tests. **No glob is split, narrowed, or shared → KBN-101-06 overlap/ownerless/path-existence stay green.\*\* + +### 1.3 §4 identity-model amendment (per-role, closed additive set — F2 hardened; AD-1 CONNECT cert) + +Add these rows to the §4 role table (§4 L141-151). **Per-ROLE, not per-user.** Implements Jason's B1 (User = god; main Orchestrator near-god, User-overridable; others deny-by-default): + +| Role (added) | Attributes (F2-hardened) | Membership / session use | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mosaic_runtime_user_capability` | `NOLOGIN`, no ownership/admin attrs. | Holds the **User (god)** rung's named grants incl. `INSERT(status)`, `UPDATE(status)` + the status-transition RLS `WITH CHECK` policy on `tasks`; **plus the SOLE `INSERT/UPDATE/DELETE` grant on `task_status_write_override`** (B-1); never granted to Orchestrator/base. | +| `mosaic_runtime_orchestrator_capability` | `NOLOGIN`, no ownership/admin attrs. | Holds the **Orchestrator (near-god)** rung's grants; identical to User **except** (i) every `tasks` write policy **subqueries** the override table and is REJECTED at the sink when an active User deny row exists for that `(workspace_id, task_id, orchestrator)` (B-1); (ii) `INSERT(status)` is pinned by `WITH CHECK` to a **non-terminal initial status** (N-3) — only User may INSERT an arbitrary/terminal status; (iii) it has only `SELECT` (never write) on the override table. Scope is tier/task-identity only (F6). | +| `mosaic_runtime_user` | **`LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`**, no ownership; **non-owner of every sink table + the override table**. | Member of `mosaic_runtime_user_capability` only (`WITH INHERIT TRUE, SET FALSE, ADMIN FALSE`); the connection-selection **User-rung** credential. **[AD-1]** Requires `CONNECT`, denied `TEMPORARY`. | +| `mosaic_runtime_orchestrator` | **`LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`**, no ownership; **non-owner of every sink table + the override table**. | Member of `mosaic_runtime_orchestrator_capability` only (same INHERIT/SET/ADMIN flags); the **Orchestrator-rung** credential. **[AD-1]** Requires `CONNECT`, denied `TEMPORARY`. | + +The existing `mosaic_runtime` is confirmed already `LOGIN INHERIT` with no admin attrs (frozen L150) and non-owner; it becomes the **others / deny-by-default** base rung. On the sink it retains `SELECT` (workspace-scoped `USING`, B-2), `INSERT (explicit non-status column list)` (F1), and column-level `UPDATE` on **non-status, non-invariant, non-key, non-tenancy** columns only (F7), each `USING` workspace-scoped; it has **no** `INSERT(status)`, **no** `UPDATE(status)`, **no** `DELETE`, **no** `TRUNCATE` on `tasks`, **no** write on the override table, and RLS denies its status/override writes by default. The rung set is **fixed at three**. + +**F2 note:** the three rung LOGIN roles' `NOBYPASSRLS` + `NOSUPERUSER` + **non-ownership** are load-bearing — `FORCE ROW LEVEL SECURITY` collapses for any SUPERUSER/BYPASSRLS role and RLS is silently bypassed by a table **owner**. These attributes are declared in -00 bootstrap, catalog-proven in -00 tests, and re-verified at every checkout by -01 (extended L222 assertion). + +**[AD-1] CONNECT-allowlist certification amendment (frozen §4 L176):** L176 today grants `CONNECT` only to `mosaic_runtime`, `mosaic_migrator`, and the bootstrap actor, and its cert **fails if an unrelated login retains `CONNECT` or either application login retains `TEMPORARY`.** The two new LOGIN rungs are additional application logins that require `CONNECT` and must be `TEMPORARY`-denied. The -00 row + rc.20 text **explicitly amend the L176 allowlist** to `{mosaic_runtime, mosaic_runtime_user, mosaic_runtime_orchestrator, mosaic_migrator, bootstrap-actor}`, and the -00 cert asserts `TEMPORARY` denial for all three runtime rungs. Without this amendment the frozen -00 cert test REDS on the two new logins. + +**rc.20 amendment text (v3 — extends v2)** — amend §4's closing note and SHARED-CONTRACT rc.5 L82/L88-90 ("Non-effect: role graph … unchanged / neither creates roles/secrets") with: + +> **rc.20 (Envelope A) — authorized by Jason's declarative-RBAC B1 ruling + Mos OPTION A + Mos Q1 (TIER-LEVEL) + Mos Q2 (RLS):** adds (i) the fixed **User/Orchestrator/others** runtime rung-roles (per-ROLE, deny-by-default); each rung **LOGIN** role is `NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`, **NON-OWNER** of every sink/override table, **added to the §4 L176 `CONNECT` allowlist**, and **asserted `TEMPORARY`-denied**. **No per-federated-user role or credential is created** — federated identity maps to a rung **at authorization time only**, and per-writer **attribution lives in metadata columns** (app-supplied `actor_id` on `task_events`), **never** in DB credentials/roles. (ii) Two new fixed rung DSN secrets **`mosaic-db-runtime-user-url`** and **`mosaic-db-runtime-orchestrator-url`** (Gateway-only, `0600 10001:10001`), extending the frozen runtime-URL secret row. (iii) **RLS `WITH CHECK` write-source policies (including the B2-INSERT per-rung INSERT tenant binding `workspace_id = current_setting('mosaic.workspace_id', true)::uuid`, composed as a SINGLE compound `WITH CHECK` or `AS RESTRICTIVE` — never multi-permissive-intended-to-AND, per the v5 composition mandate §1.4 item 3-bis) + per-rung `SELECT`/`UPDATE` `USING` tenant policies + `FORCE ROW LEVEL SECURITY`** on the sink table `tasks` **and on the new sink-resident `task_status_write_override` table** (tighten-only, deny-by-default) — a §10 schema-v1 **mechanism addition** beyond the prior grant/revoke-only model, authorized by Mos Q2 as coordinator co-authorization with Jason's B1. **The two new rung capability roles additionally receive the baseline frozen §4 L176 runtime grants (`USAGE ON mosaic`, `drizzle` ledger `SELECT`, sequence grants) [NB-5] so they satisfy the frozen §6 L222 runtime verify.** (iv) The **`task_status_write_override` table** (keyed `(workspace_id, task_id, tier)`, no per-writer key), written only by the User rung, read by the Orchestrator/base rung `tasks` write-policy subquery, homes the User-override **at the sink**. The sink DDL (grants + RLS `USING`/`WITH CHECK` + override table + FORCE + unique key + trigger/view/rule invariants) is homed in the **KBN-100 producer** after it creates `tasks`, with `FORCE RLS` as the **terminal** migration step after all backfill/repair DML, per the §4 L178 producer/consumer precedent. + +> **Frozen-invariant note (authorization basis):** touching the frozen role graph, the §4 L176 CONNECT allowlist, and adding RLS + the override table are §10 contract-change classes (MISSION-MANIFEST §10; SHARED-CONTRACT L82/L88-90; frozen §4 L176). They are authorized here by **Jason B1 + Mos OPTION A + Mos Q1 + Mos Q2**. The addition is strictly a **fixed per-ROLE** set + a **tighten-only** RLS mechanism + a **task/tier-scoped** override table. It stops exactly at the HALT boundary: **no** per-federated-user topology is introduced (§5 proof). + +### 1.4 §10 mechanism amendment — RLS `WITH CHECK` (write-source + INSERT tenant-bound) + `USING` + sink-resident override at the sink (Q2), with the two mandatory conditions + +The frozen write-authority model is grant/revoke-only; **RLS appears nowhere in the frozen contract.** Mos Q2 **AUTHORIZES** adding declarative, deny-by-default, **enforce-AT-THE-SINK, fail-closed** RLS policies, because grant/revoke alone cannot express (a) row-level _write-source_ enforcement (pin new-row status, F1/N-3), (b) a _sink-enforced_ User-override that the Orchestrator-rung path cannot skip (B-1), (c) _tenant-scoped reads/updates_ (B-2), or (d) **[B2-INSERT] _tenant-scoped INSERTs_ — binding `workspace_id` on the write/create path**, which neither column grants nor the `USING` read policy can do (PostgreSQL does not apply `USING` to INSERT). RLS is **tighten-only**: it can only further restrict beyond the column grants, never widen them. + +**KBN-100 producer DDL scope is explicitly amended** to include, on `tasks` (and, where noted, the override table): + +1. `ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;` — and the same on `task_status_write_override`. +2. **CONDITION (b) — EXPLICIT DECISION: `ALTER TABLE tasks FORCE ROW LEVEL SECURITY;`** (and on the override table) — DECIDED **YES/ON**, stated explicitly and not left defaulted, so that **even the table owner (`mosaic_schema_owner`) and any maintenance connection cannot silently bypass RLS**. (Directly closes the live U-Connect failure mode where an owner connection with `rls_forced=false` silently VOIDED RLS.) +3. A **deny-all default** posture (no permissive policy) plus per-rung policies: + - **Write (`WITH CHECK`):** base/others rung → new-row `status = ` only, no status UPDATE; **Orchestrator rung → status INSERT pinned to a non-terminal initial status (N-3), and every write (`INSERT`/`UPDATE`/transition) additionally `WITH CHECK`'d against `NOT EXISTS()` (B-1)**; User rung → status INSERT (any legal status) / transition. Every write policy is `WITH CHECK` (write-source), tighten-only. + - **[B2-INSERT — tenant isolation on the write path] Every rung's INSERT `WITH CHECK` on `tasks` MUST include the workspace predicate `workspace_id = current_setting('mosaic.workspace_id', true)::uuid` as a conjunct of the _same_ enforced check that carries the status-pin (and, for the Orchestrator rung, the B-1 override subquery) — see the COMPOSITION MANDATE (item 3-bis) for the exact realization.** This is REQUIRED because PostgreSQL does **NOT** apply `USING` to INSERT, and the explicit status-pinning `WITH CHECK` **suppresses** the `USING`→`WITH CHECK` substitution — so in v3 `workspace_id` was **unbound on the INSERT path** and any rung could `INSERT INTO tasks (workspace_id, …) VALUES (, …)` (directly or via the sanctioned status-upsert INSERT arm) into a victim workspace, defeating B-2's "tenant isolation homed at the sink" goal and evading SHARED-CONTRACT §7.1 (body workspace forbidden). **CORRECTED SEMANTICS (v5 — the delta-3 fix):** multiple **PERMISSIVE** policies for the same command combine with **OR** — a row is admitted if it satisfies **ANY** one permissive policy's `WITH CHECK`, ANDed with all `AS RESTRICTIVE` policies' checks. "All applicable `WITH CHECK` clauses must pass / they AND" is **FALSE as a general rule** and is struck from this envelope; the AND-conjunction the B2-INSERT closure depends on holds ONLY (a) _within a single policy_ whose `WITH CHECK` is one AND-conjoined expression, or (b) _across `AS RESTRICTIVE` policies_. Realizing the status-pin, the workspace-bind, and the override as separate _permissive_ policies would let a foreign-workspace INSERT that satisfies only the status-pin be admitted by OR — re-opening B2-INSERT/B-1 — which item 3-bis forbids. **Fail-closed on NULL:** an absent GUC makes `current_setting(...,true)` return NULL, the equality yields NULL (not true), and — provided the predicate is a conjunct of the single enforced check (item 3-bis) — the INSERT **ERRORS at the sink**. The **same INSERT workspace binding is applied symmetrically to `task_status_write_override`**, under the identical composition mandate (its own single compound `WITH CHECK` or `AS RESTRICTIVE`). + - **Read/lock (`USING`) — B-2:** per-rung `SELECT` and `UPDATE` `USING` policies **workspace-scoped** by the session predicate `workspace_id = current_setting('mosaic.workspace_id', true)::uuid` (set per request by -01, §2.2). **`USING(true)` is explicitly FORBIDDEN** — tenant isolation rides on this predicate; a lazy `USING(true)` would expose every workspace's rows to every rung and is a fail-closed contract violation caught by a negative test. + - **3-bis. [v5 — LOAD-BEARING] COMPOSITION MANDATE (how the sink check MUST be realized; both delta-3 gates prescribed it).** Per rung, per command (INSERT / UPDATE / transition), the sink enforcement of the status-pin **AND** the workspace-bind **AND** (Orchestrator rung only) the User-override subquery MUST be realized as EITHER: + - **(a) a SINGLE policy** whose `WITH CHECK` is the _full conjunction_ — ` AND workspace_id = current_setting('mosaic.workspace_id', true)::uuid` and, for the Orchestrator rung, `AND NOT EXISTS()`; **OR** + - **(b)** the status-pin as the **SOLE PERMISSIVE** policy for that command, **plus** the workspace-bind and (Orchestrator) the override subquery authored **`AS RESTRICTIVE`** (restrictive policies AND with everything, so a row must pass every restrictive check _and_ at least one permissive check). + - **EXPLICITLY FORBIDDEN:** realizing the status-pin, the workspace-bind, and the override as **multiple _permissive_ policies whose predicates are intended to AND.** Permissive policies combine with **OR**, so that realization admits a foreign-workspace INSERT that satisfies only the status-pin (cross-tenant injection) and equally bypasses the B-1 override — exactly the delta-3 defect. This mandate applies to the `tasks` INSERT/UPDATE/transition policies **AND** to the `task_status_write_override` table's own policies (same single-compound-`WITH CHECK`-or-`AS RESTRICTIVE` requirement; never multi-permissive-intended-to-AND). The producer DDL and the -09/-06 certification assert the realized policies are either single-compound or `AS RESTRICTIVE` (never separate permissive policies expected to AND) — a lint/catalog check over `pg_policy.polpermissive` backs it. + - **[v6 — N1: strengthened `pg_policy.polpermissive` lint (supplementary, NOT sufficient).]** The `pg_policy.polpermissive` lint (continuous -06 scan, §2.7) MUST (a) count permissive policies **grouped by EFFECTIVE role — including `PUBLIC` and role inheritance**, not merely by the policy's named `TO` role: a `TO PUBLIC` (or inherited-through-a-capability-role) permissive policy applicable to a rung's command is counted **alongside** that rung's status-pin, because at evaluation time it OR-widens exactly like a second same-role permissive policy — so a `TO PUBLIC` permissive next to a rung's status-pin is a **lint failure**; and (b) ideally **reject an internally-disjunctive single `WITH CHECK`** — a single policy whose expression is itself `status_pin OR workspace_bind` satisfies the naive one-permissive-policy count yet violates the composition mandate exactly as two permissive policies would, so the lint should detect a top-level `OR` between the status-pin and the workspace-bind/override conjuncts within one policy expression. **The lint is SUPPLEMENTARY, not sufficient:** the **behavioral positive-controls remain the PRIMARY proof** of correct composition (dropping the workspace conjunct — resp. the override subquery — alone flips the hardened negative to a PASS; §2.4 clause 5 / §2.9). The lint backstops the catalog shape; it does not replace the behavioral proof. + - **[v6 — N2: option-(b) vacuous-deny guard invariant — ≥1 permissive per writable (rung, command).]** Under option (b) (status-pin as the **sole PERMISSIVE** policy + workspace-bind/override `AS RESTRICTIVE`), PostgreSQL admits a row only if **≥1 PERMISSIVE `WITH CHECK` is TRUE AND all RESTRICTIVE checks pass** — so if a writable (rung, command) has **zero** permissive policies, every restrictive-only evaluation denies and the command **over-denies / fails closed** (nothing can ever be written). Therefore there **MUST be ≥1 PERMISSIVE policy per writable (rung, command)** — this "≥1 permissive per writable (rung, command)" is the **load-bearing option-(b) guard invariant** (the workspace-bind/override being restrictive is safe _only_ because the status-pin supplies the required permissive). It is verified by the mandated **"same-workspace valid-status write succeeds" positive tests** for each writable command — INSERT, transition/UPDATE, and SELECT — which would FAIL (spurious over-deny) if the permissive were dropped, and by the N1 lint confirming exactly one permissive status-pin (grouped by effective role) is present per writable (rung, command). +4. **B-3 migration ordering (TERMINAL FORCE):** all expand/backfill/repair DML on `tasks` (SHARED-CONTRACT §5.1 phase-3 L165-171, §5.3 L188) runs **FIRST**, while `tasks` has RLS **disabled**; the `ENABLE`/`FORCE ROW LEVEL SECURITY` statements are the **TERMINAL** DDL of the activation migration. **No standing `mosaic_schema_owner` ALLOW write policy exists after activation** (that would recreate the unconstrained owner write path = U-Connect mode). Any schema_owner maintenance policy, if ever needed, is **migration-only** and must be proven **unreachable by the 3 runtime LOGIN roles** + a negative test. + +**CONDITION (a):** every runtime per-role LOGIN role (`mosaic_runtime`, `mosaic_runtime_user`, `mosaic_runtime_orchestrator`) **MUST remain NON-OWNER** of every sink table **and the override table** (owner stays `mosaic_schema_owner`). Enforced by -00 bootstrap catalog assertion + re-verified at each checkout by -01 (F2). A rung role that owns a sink/override table is a fail-closed contract violation. + +**Break-glass boundary (B-3, named explicitly in KBN-101-09 cert):** the ONLY identities that legitimately bypass `FORCE RLS` are the sanctioned break-glass actors named in the frozen contract — `mosaic_extension_owner` (`NOLOGIN SUPERUSER`, §4.1 L146) and the external platform bootstrap actor (superuser, §4/§7; §9 L266 residual authority). Both are `NOLOGIN`/external, carry no runtime credential, and are outside application containment. The -09 cert **names these two as the accepted, audited boundary** — not a new hole — and asserts the three runtime LOGIN rungs are NOT superuser/NOT BYPASSRLS and cannot assume either. + +**[NB-4 — owner-toggle-FORCE containment is NON-REACHABILITY, not incapability.]** The -09 break-glass enumeration must **additionally name `mosaic_schema_owner`** (frozen §4 L145, `NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`) as a **contained-but-CAPABLE** path. A table **owner** — regardless of `NOSUPERUSER`/`NOBYPASSRLS` — can `ALTER TABLE tasks DISABLE ROW LEVEL SECURITY` or `ALTER TABLE tasks NO FORCE ROW LEVEL SECURITY` and then write freely; `FORCE`/`NOBYPASSRLS` do **not** make an owner incapable. So the containment claim is **NOT** "the owner cannot bypass RLS" — it is **"the owner role is NOT REACHABLE from the three runtime LOGIN rungs."** The -09 cert asserts this non-reachability **explicitly**: (i) `mosaic_schema_owner` is `NOLOGIN` (no direct credential); (ii) it is reachable only via `mosaic_migrator`'s `SET ROLE` (frozen §4 L147, migrator runner-before-Gateway, `max:1` locked migration session), which is **not** one of the three runtime rungs and holds no runtime pool; (iii) none of `mosaic_runtime` / `mosaic_runtime_user` / `mosaic_runtime_orchestrator` is a member of, or can `SET ROLE` to, `mosaic_schema_owner` or `mosaic_migrator` (catalog-proven at -00, re-verified at every checkout by -01's L222 non-owner/assumability assertion). **[NB-4-transitive — Gate B-delta-3 nit, folded]** the non-reachability assertion in the -09 cert MUST be stated as **TRANSITIVE membership**, not merely the absence of a **direct** `pg_auth_members` row: PostgreSQL's ownership/privilege check returns true for an **inherited** membership reached _through a capability role_, which would confer the owner's `ALTER TABLE … NO FORCE` / `DISABLE ROW LEVEL SECURITY` right. The assertion therefore uses the recursive predicates — `NOT pg_has_role('mosaic_runtime_user', 'mosaic_schema_owner', 'USAGE')` (and the same for each runtime rung against `mosaic_schema_owner` and `mosaic_migrator`), equivalently `NOT has_privs_of_role(, 'mosaic_schema_owner')` — which fold in transitive/inherited membership through any intermediate (capability) role. The frozen §6 L222 fail-closed-on "owner/migrator identity **or assumability**" check plausibly already covers this transitive case; v5 states "**transitive membership**" **explicitly** in the -09 cert assertion so no implementation reads "non-member" as only the direct-edge check. **[v6 — F-NB4: cover SET-ROLE assumability, not only inherited privilege.]** The v5 predicates `NOT pg_has_role(, 'mosaic_schema_owner', 'USAGE')` / `NOT has_privs_of_role(, 'mosaic_schema_owner')` capture the **inherited-privilege** axis, but a membership granted `WITH INHERIT FALSE, SET TRUE` yields `has_privs_of_role = FALSE` **yet still allows `SET ROLE mosaic_schema_owner`** followed by `ALTER TABLE … NO FORCE ROW LEVEL SECURITY` — an assumability path the `USAGE`/`has_privs_of_role` predicate alone does **not** close. The -09 cert assertion therefore **conjoins the `MEMBER` variant** — `NOT pg_has_role(, 'mosaic_schema_owner', 'MEMBER')` **AND** `NOT pg_has_role(, 'mosaic_migrator', 'MEMBER')` — for **each of the three runtime rungs** (`mosaic_runtime`, `mosaic_runtime_user`, `mosaic_runtime_orchestrator`); the `MEMBER` privilege captures SET-ROLE reachability **regardless of `INHERIT`**, so `pg_has_role(rung, owner, 'USAGE')` **AND** `pg_has_role(rung, owner, 'MEMBER')` are asserted false together. This is stated explicitly rather than leaning on "frozen §6 L222 assumability plausibly covers it." (The intended role graph is already safe — per envelope §1.3 + frozen §4 L150, each rung is a member of **only** its own capability role, `WITH INHERIT TRUE, SET FALSE, ADMIN FALSE`, and of no owner/migrator role — so no live `SET TRUE` edge to the owner/migrator exists; this fold is **assertion-completeness hardening, not a live hole.**) The owner's toggle-then-write capability is therefore real but confined to the migrator/owner phase (frozen §5, runner-before-Gateway), never to a live runtime rung — that is the containment, stated as **transitive non-reachability** rather than incapability. + +**Red-team requirement (baked into §3/§4; v5-HARDENED so the negatives cannot false-green under the OR-trap):** each per-rung policy is proven **deny-by-default via NEGATIVE tests PER TIER** in real PostgreSQL: + +- an unauthorized write-source (base terminal-status INSERT/transition) that **ERRORS**; +- **[B2-INSERT — hardened] a cross-workspace INSERT/UPSERT using a row OTHERWISE VALID for the rung** — a status the rung's pin **ACCEPTS** (e.g. **base rung + `status='backlog'`**, and no active override), differing from a passing insert ONLY in `workspace_id = ` — so the rejection **isolates the workspace bind** (not a status/override rejection). Tested both directly and via the status-upsert INSERT arm; it **ERRORS at the sink**. **Positive-control:** removing the workspace-bind conjunct **alone** (leaving the status-pin) flips this negative to a **PASS**, proving the _workspace bind_, not the status-pin, is doing the rejecting (and proving the composition is a single enforced conjunction / RESTRICTIVE, not an OR of separate permissive policies). +- **[B-1 — hardened] an Orchestrator write vetoed by an active User-override deny row, using a transition the Orchestrator pin OTHERWISE ACCEPTS** (a legal non-terminal transition on `(W, T)` that would succeed absent the deny row) — so the rejection **isolates the override subquery**. It **ERRORS at the sink**. **Positive-control:** removing the override subquery **alone** flips this negative to a **PASS**, proving the _override_, not the status-pin/workspace-bind, is doing the rejecting. +- a **cross-workspace `SELECT` under the wrong/absent session predicate** → zero rows; +- **[B2-INSERT] a NULL-GUC INSERT** (absent `mosaic.workspace_id`) that **ERRORS** (fail-closed on NULL); +- a **same-workspace INSERT with a valid status** that **succeeds** (proving the predicate composes with F1/N-3 and does not over-deny); +- an **owner/maintenance connection attempting a post-activation `tasks` write** that must **fail under `FORCE`**. + All rejections **ERROR at the sink**. The two positive-controls are load-bearing: they are the direct proof that the composition is single-compound-`WITH CHECK`-or-`AS RESTRICTIVE` (delta-3), because under the forbidden multi-permissive-OR realization the hardened cross-workspace / override negatives would already PASS (false-green) without removing anything. + +--- + +## 2. Design + +### 2.1 Declarative RBAC policy (source-controlled artifact + schema) + +- **Artifact:** `packages/db/src/sink-rbac/policy.task-status.v1.ts` — a source-controlled, versioned, deny-by-default policy object; the single source of truth for "who may write the sink." No policy lives in the database except the _derived_ GRANT/RLS state the KBN-100 producer emits from it. +- **Schema:** `packages/db/src/sink-rbac/policy.schema.ts` — a typed schema (zod/TS) validating: `rungs` (exactly the fixed ladder), per-rung `allow`/`deny` verbs (`insert`, `insert:status`, `update:`, `transition:status`, `delete` — always deny), `federationMap` (federated-identity-claim → **rung**, never a role/credential per user), and `userOverride`. + - **[F6 FIX] `userOverride` scope guardrail:** the schema **constrains** override predicates to **tier- + task-identity (row) scope `(workspace_id, task_id, tier)`** and **explicitly FORBIDS** a per-federated-writer identity as an override or authority key (the Q1 re-open trigger). A policy that names a per-federated-writer identity as an override/authority key **fails schema validation** (fail-closed at load). The override is realized as the sink-resident `task_status_write_override` table (§2.9), NOT as app-resolved state. Per-writer attribution is not an authority input; it flows only to the audit metadata column (§2.6). +- **Evaluator:** `packages/db/src/sink-rbac/evaluate.ts` — pure `(identityClaims, requestedWrite) → Decision ∈ {allow(rung), deny(reason)}`, **deny-by-default**, tier resolution only. It **only chooses a rung**; it never mints credentials and — critically (B-1) — **it is NOT the enforcement point for the User-override**: the override is enforced in PostgreSQL by the write-policy subquery, so a compromised Gateway that skips the evaluator still cannot bypass a User deny. +- **Ladder / federation-awareness:** `packages/db/src/sink-rbac/ladder.ts` — fixed order `User > Orchestrator > others` and `resolveRung(federatedIdentity) → rung` at authorization time (Q1: federation-awareness lives in the resolver; the sink authorizes by the writer's declared ROLE/tier). +- **Enforcement is AT THE SINK, fail-closed:** the evaluator's rung choice only selects _which pre-provisioned connection_ is used; PostgreSQL then enforces via (a) revoked base privileges, (b) column-level `INSERT(status)`/`UPDATE(status)` granted only to the User/Orchestrator capabilities, (c) `FORCE ROW LEVEL SECURITY` + deny-all default + per-rung `WITH CHECK` policies **including the override subquery** (B-1), (d) per-rung `SELECT`/`UPDATE` `USING` **workspace predicate** (B-2, tenant isolation at the sink), (e) `DELETE/TRUNCATE` revoked from all runtime rungs, and (f) `UPDATE` on key/tenancy/invariant columns revoked from **all** rungs (F7). A mis-authorized OR override-vetoed OR cross-workspace write **fails closed in PostgreSQL** because the connection's effective DB role lacks the privilege or the policy predicate rejects the row — caller discipline is not trusted. + +### 2.2 Per-role credential + connection-selection model (NO per-user roles; import direction pinned) + +``` +federated writer ──(verified identity claim)──► evaluate.ts ──► rung (one of 3, fixed) + │ + connection-selection.ts ───┤ selects the PRE-PROVISIONED + (in -10; consumes -01) │ pool for that rung; sets + │ SET LOCAL mosaic.workspace_id + ▼ + mosaic_runtime_user | mosaic_runtime_orchestrator | mosaic_runtime + └── PostgreSQL enforces grants + FORCE RLS WITH CHECK(+override) + USING(workspace) ──┘ +``` + +- **`packages/db/src/sink-rbac/connection-selection.ts`** maps `rung → pooled connection`. **Exactly three** connection pools, one per rung, each opened with that rung's **fixed** mounted-secret DSN. Credentials = rungs = **3**, constant. +- **[AD-2/N-5 — import direction pinned]** `-01` (`database.module.ts`) **provisions ONLY the three generic rung pools** at boot and knows nothing of rung _selection_. The rung→pool SELECTION lives entirely in **-10's `connection-selection.ts`**, which is **consumed by the write-path cards (`-02`/`-03`/`-05`, which already `depends on 10`)** and is **NEVER imported by `-01`**. Direction is therefore `10 → 01` (10's selection consumes 01's pools) — **acyclic; there is no `01 → 10` cycle.** An impl lane must not read "01 wires 10" as `01 → 10`. +- **[B-2 — workspace session predicate]** on checkout, before the first query, -01 sets `SET LOCAL mosaic.workspace_id = ` inside the request transaction; the per-rung `USING` policies **and the B2-INSERT `WITH CHECK`** read `current_setting('mosaic.workspace_id', true)::uuid`. The predicate is set from the **server-verified** tenant context, never from a body field (SHARED-CONTRACT §7.1 "Body workspace fields are forbidden"). A missing/invalid setting → `USING` yields no rows (fail-closed read) and INSERT `WITH CHECK` yields NULL→ERROR (fail-closed write), never cross-workspace exposure. + - **[NB-3 — compromise scope]** the GUC is **app-set with no per-tenant DB authentication**, so B-2 (reads/UPDATE `USING`) and B2-INSERT protect a **buggy** Gateway (omitted/wrong predicate → fail-closed), NOT a **compromised** Gateway that forges `mosaic.workspace_id` to a victim tenant (accepted Mos Q1 co-resident-pool residual; defending it requires per-user DB creds = the HALT boundary). The genuine closure B2-INSERT delivers is the **buggy / unbound-INSERT** hole: even with a correct GUC, v3 left `workspace_id` unbound on INSERT so any rung could stamp a foreign workspace — now impossible. + - **[Gate B 2(a) — poisoned-pool / SET LOCAL negative]** the `mosaic.workspace_id` GUC is folded into the frozen L174 **poisoned-pooled-session-reset + transaction `SET LOCAL` restoration** negative test (which already covers `search_path`): a negative proves `mosaic.workspace_id` is **transaction-scoped via `SET LOCAL`** (mirroring the frozen `SET LOCAL search_path` discipline, §4 L152) so a pooled connection **cannot leak a stale workspace** across requests — an un-reset/poisoned session fails closed, and a committed/rolled-back transaction does not carry the prior request's workspace. +- **[F2 FIX] Identity + attribute verification at checkout** (`connection-identity.ts`): verify effective role == the rung's expected DB role **AND** assert the safe attributes — fail closed (`DATABASE_ROLE_UNSAFE`) on SUPERUSER, CREATEROLE, CREATEDB, REPLICATION, **BYPASSRLS**, or ownership of any sink/override table. Extends the frozen L222 check to **each** rung connection. +- **No per-federated-user role/credential:** a new federated user needs **zero** new DB roles/credentials/secrets/pools — it resolves to an existing rung. Federation growth is O(1) in DB-role count. + +### 2.3 Status-preserving UPSERT on a stable natural key (F4 bound) + +- **`packages/db/src/sink-rbac/status-upsert.ts`** — the single sanctioned refresh helper: `INSERT INTO tasks (natural_key…, ) VALUES (…) ON CONFLICT () DO UPDATE SET ` — **`status` is never in the `DO UPDATE SET` list**, so a refresh preserves existing `status` byte-for-byte. +- **[B-2 dependency]** the `ON CONFLICT` conflict-probe/refresh reads the existing row; under `FORCE RLS` this requires the per-rung `SELECT`/`UPDATE` `USING` policy to admit the row (workspace-scoped). Without B-2's `USING` policies the probe would see zero rows and the UPSERT would spuriously INSERT-conflict or mis-refresh — B-2 is what makes the sanctioned UPSERT actually work post-activation. +- **[B2-INSERT dependency]** the UPSERT's **INSERT arm** is a `tasks` INSERT and is therefore subject to the B2-INSERT workspace `WITH CHECK` (§1.4/§2.4): a status-upsert that supplies a foreign `workspace_id` (or runs under an absent GUC) **ERRORS at the sink**, closing the sanctioned-UPSERT INSERT path that v3 left tenant-unbound. The helper sets `workspace_id` from the server-verified tenant context (never a body field, §7.1); it matches the session GUC by construction. +- **[F4 FIX] Natural key:** the tenant-scoped stable identity **`(workspace_id, id)`**. No frozen `tasks(workspace_id,id)` unique key exists (only `missions_workspace_id_uidx`, rc.4 L94) — `ON CONFLICT` against a nonexistent unique index is a **HARD planning error** that fails loud. Therefore **KBN-100 MUST create `tasks (workspace_id,id)` UNIQUE** (aligned to the SI-001 pattern, SHARED-CONTRACT §5.2). **Fallback:** if KBN-100 keys `tasks` on global `id` only, the UPSERT natural key **re-binds to `(id)`** (spec-level, no HALT). +- **Status changes only via an authorized transition:** `transitionStatus()` guarded by (a) the rung's `transition:status` policy verb, (b) `UPDATE(status)` present only on User/Orchestrator capabilities, (c) the RLS `WITH CHECK` transition policy (admits the update only as an explicit transition, never as a refresh/grant side effect) **plus the override subquery for the Orchestrator rung (B-1)**, and (d) the `USING` workspace predicate to see/lock the row (B-2). `DELETE` is denied to all runtime rungs → **DELETE + default-INSERT status reset is impossible at the sink** (fails closed on the DELETE). + +### 2.4 INSERT-time status constraint (F1 BLOCKING + N-3 hardening) + +Base `mosaic_runtime` must not stamp an arbitrary terminal status at creation, **and the Orchestrator rung must not create a task directly at a terminal status** (N-3). Layered fail-closed controls (homed in the KBN-100 producer DDL): + +1. **Column-privilege:** `GRANT INSERT () ON tasks TO mosaic_runtime_capability;` — the base rung **cannot name `status` on INSERT**, so `status` takes its column **DEFAULT** (`backlog`). `INSERT(status)` is granted **only** to the User/Orchestrator capabilities. +2. **RLS `INSERT … WITH CHECK` (base):** pins **new-row `status = 'backlog'`** for the base rung. +3. **[N-3 FIX] RLS `INSERT … WITH CHECK` (Orchestrator):** pins the Orchestrator rung's new-row status to a **non-terminal initial status** (`status IN {backlog, ready}`; never `done`/`cancelled`/`in_review`). **Only the User (god) rung may INSERT an arbitrary/terminal status.** This prevents an Orchestrator creating a task directly at `done`/`cancelled`, skipping the transition/lease/review trail (SHARED-CONTRACT §7.2 `POST /tasks` L272 vs `POST /tasks/:taskId/transition` L275). The exact terminal/non-terminal set is bound by KBN-100 to the §3 L134 vocabulary. +4. **[B2-INSERT FIX] RLS `INSERT … WITH CHECK` (EVERY rung, tenant binding):** every rung's INSERT sink-check MUST include `workspace_id = current_setting('mosaic.workspace_id', true)::uuid` as a conjunct of the **same enforced check** that carries the status-pin (base=`backlog`, Orch=non-terminal, User=any) **and** the B-1 override subquery on the Orchestrator rung. `status` is bound by clause 2/3; `workspace_id` is bound by this conjunct; they are orthogonal columns so there is no conflict. **[v5 — corrected PostgreSQL semantics; the delta-3 fix]** the earlier justification "because PostgreSQL requires all applicable `WITH CHECK` clauses to pass, they AND" is **FALSE and is struck**: multiple **PERMISSIVE** policies for a command combine with **OR** (a row is admitted if it satisfies **ANY** one permissive `WITH CHECK`, ANDed with all `AS RESTRICTIVE` checks). The AND this closure needs holds ONLY (a) within a _single_ policy whose `WITH CHECK` is one AND-conjoined expression, or (b) across `AS RESTRICTIVE` policies. **Fail-closed on NULL:** an absent GUC → NULL equality → INSERT ERRORS — _provided_ the workspace conjunct sits in the single enforced check (clause 5). This closes the v3 gap where the explicit status `WITH CHECK` suppressed the `USING`→`WITH CHECK` substitution, leaving `workspace_id` unbound on INSERT and allowing any rung to INSERT into a foreign workspace. +5. **[v5 COMPOSITION MANDATE — LOAD-BEARING] Single-compound-`WITH CHECK`-or-`AS RESTRICTIVE`, never multi-permissive-intended-to-AND.** The status-pin (clause 2/3), the workspace-bind (clause 4), and (Orchestrator) the B-1 override subquery MUST be realized per rung, per command as EITHER **(a)** a **SINGLE policy** whose `WITH CHECK` is the full conjunction ` AND workspace_id = current_setting('mosaic.workspace_id', true)::uuid [AND NOT EXISTS()]`; **OR (b)** the status-pin as the **sole PERMISSIVE** policy **plus** the workspace-bind and (Orchestrator) the override authored **`AS RESTRICTIVE`**. Realizing them as **multiple _permissive_ policies intended to AND is EXPLICITLY FORBIDDEN** — permissive policies OR, so a foreign-workspace INSERT satisfying only the status-pin (e.g. base + `backlog` + foreign `workspace_id`) would be OR-admitted, re-opening B2-INSERT, and an override-vetoed Orchestrator write satisfying the status-pin would likewise slip the B-1 veto. The **identical mandate applies to `task_status_write_override`'s own policies** (single compound `WITH CHECK` or `AS RESTRICTIVE`; never multi-permissive-AND). A `pg_policy.polpermissive` catalog check + the hardened positive-control negatives (below) prove the realization. **[v6 — N1]** the `pg_policy.polpermissive` check is strengthened to count permissive policies **grouped by EFFECTIVE role (including `PUBLIC` and role inheritance)** — a `TO PUBLIC`/inherited permissive applicable to a rung's command counts alongside its status-pin — and to **reject an internally-disjunctive single `WITH CHECK`** (a lone policy whose expression is `status_pin OR workspace_bind` OR-widens exactly like two permissive policies); the lint is **supplementary, and the behavioral positive-controls below remain the PRIMARY proof** (§1.4 item 3-bis). **[v6 — N2]** because option (b) admits a row only when **≥1 permissive `WITH CHECK` is TRUE AND all restrictive checks pass**, there **MUST be ≥1 PERMISSIVE policy per writable (rung, command)** — the load-bearing option-(b) vacuous-deny guard — else the command over-denies (fails closed); this invariant is proven by the mandated "same-workspace valid-status write succeeds" positive tests (INSERT, transition/UPDATE, SELECT). + +**Red-first tests (v5-hardened):** base-rung `INSERT … status='done'` **ERRORS** (column-privilege and/or RLS); base-rung INSERT omitting status succeeds at DEFAULT; **Orchestrator `INSERT … status='done'` ERRORS (N-3)**, Orchestrator `INSERT … status='backlog'` succeeds; User-rung `INSERT … status='done'` succeeds. **[B2-INSERT — hardened valid-status form]** the cross-workspace negative uses a row **otherwise valid for the rung** — **base rung + `status='backlog'` + `workspace_id=`, no active override** — differing from a passing insert ONLY in `workspace_id`, so the **ERROR** isolates the _workspace bind_ (not a status/override rejection); tested direct and via the status-upsert INSERT arm. **Positive-control:** removing the workspace-bind conjunct **alone** flips this to a **PASS** (proving the bind — not the status-pin — rejects, hence a single enforced conjunction / RESTRICTIVE, not a permissive OR). An INSERT under an absent `mosaic.workspace_id` GUC **ERRORS** (fail-closed on NULL); a same-workspace INSERT with a valid status **succeeds** (predicate composes with F1/N-3, does not over-deny). + +### 2.5 Invariant / key / tenancy field-set (F7 — closure) + +- **`packages/db/src/sink-rbac/invariant-set.ts`** defines the invariant field-set = every sink column NOT in the explicitly-mutable set: `{ natural-key columns (workspace_id, id), status, creation/tenancy columns, immutable metadata }`. The complement (title, tags-normalized, due_at, rank, …) is the mutable set the UPSERT `DO UPDATE` touches. +- **[F7 FIX] `UPDATE` on key/tenancy/invariant columns (`workspace_id`, `id`, tenancy columns) is REVOKED from ALL rungs — including User and Orchestrator.** A re-key would otherwise **launder status** (relocate a row into a new identity to escape the status invariant). Column-level `UPDATE` grants for every rung exclude these columns. +- **Grant/data-plane disjointness (RC19-B1-03):** grants are declarative + physically separate from the data plane, so a grant/policy mutation writes **zero** `tasks` rows. A test applies an arbitrary policy/grant delta → asserts **zero byte delta** on any `tasks` row (closes RC19-B1-03 by construction). + +### 2.6 Per-writer attribution → metadata column (Q1 audit channel) + +- Per-writer attribution (which federated writer inside a tier acted) is an **AUDIT** concern, not an authority concern. It is captured as an **app-supplied `actor_id` metadata column on `task_events`** (append-only, already runtime `INSERT/SELECT`-only per frozen §4 L178). KBN-100 owns the column; the application supplies the verified federated-writer identity as **data**. +- It is **never** a DB credential, role, or RLS predicate key, and (per F6) never a `userOverride`/authority key (the override is keyed on task+tier, not writer). This keeps "which writer acted" auditable **without** a per-writer DB topology — i.e. without hitting the Q1 HALT trigger. + +### 2.7 No-status-write via trigger / view / rule (F3 BLOCKING + N-1 + N-2) + +Column-level `UPDATE(status)` alone does not close every indirect status-write path. Three complementary invariants (homed in KBN-100 producer; enumerated by -10's `invariant-set.ts`/no-status enumeration; certified deployed by KBN-101-09): + +- **F3 — no status-normalizing trigger on `tasks`:** no trigger on `tasks` writes/normalizes `status` outside the sanctioned `transitionStatus()` path. The transition path is the **only** status writer. +- **[N-2 FIX + NB-1 + continuous-scan ADOPTED] Complete no-status-write enumeration (F3 completeness):** the cert asserts, in addition to F3: **no AFTER trigger** on `tasks` executing `UPDATE tasks SET status`; **no `SECURITY DEFINER` function** that writes `tasks.status` (frozen §4 L176 already forbids `SECURITY DEFINER` unless a separately reviewed exception — cited as the backstop); **no trigger on a RELATED runtime-writable table** (`task_events` and the other §4 L178 relations) that writes `tasks.status`; and **no `CREATE RULE`** on `tasks`. **[NB-1]** The enumeration is **extended to the `task_status_write_override` relation**: no status-writing trigger / RULE / `SECURITY DEFINER` function on the override relation may write `tasks.status` (the override table is runtime-writable by the User rung, so it is exactly the class §2.7's related-table clause must cover). The enumeration **scans function BODIES** (`pg_proc.prosrc` / dependency graph) for `tasks.status` writes, not merely `pg_trigger` rows. **[Gate A-delta-2 obs #2 — ADOPTED, not just recommended]** this enumeration is now a **continuous -06 CI catalog scan run on EVERY migration** (promoted from the v3 one-shot -09 recommendation), so a _later_ migration that adds a status-writing trigger/function/rule/view on `tasks` **or** on `task_status_write_override` **fails CI**, not only the one-time -09 cert. The -09 deployed cert still records the final deployed proof; the -06 scan is the standing gate. **[v6 — N1]** the same continuous -06 scan family carries the **strengthened `pg_policy.polpermissive` composition lint** (§1.4 item 3-bis / §2.4 clause 5): on every migration it counts permissive policies **grouped by effective role — including `PUBLIC` and role inheritance** (a `TO PUBLIC`/inherited permissive applicable to a writable command counts alongside that rung's status-pin) and flags an **internally-disjunctive single `WITH CHECK`** (`status_pin OR workspace_bind` within one policy). This lint is **supplementary**: it backstops the catalog shape but is **not sufficient** — the behavioral positive-controls (§2.4 clause 5 / §2.9) remain the PRIMARY proof that the composition is single-compound/`AS RESTRICTIVE` rather than an OR of permissive policies. +- **[N-1 FIX] no status-write via VIEW or RULE:** forbid any `VIEW` or `RULE` on `tasks` that yields a status-write path. An owner-owned view runs with the owner's privileges (`security_invoker = false` by default), side-stepping rung grants; an `ON INSERT/UPDATE DO INSTEAD` rule rewrites a write onto `tasks` similarly. The `invariant-set.ts` enumeration **forbids such views/rules OR mandates `security_invoker = true` + ZERO runtime grants** on any view over `tasks`. A negative test proves a rung cannot mutate `tasks.status` through any view/rule. + +### 2.8 B2 — SCM_RIGHTS credential-acquisition + non-dumpable re-verify (F5 hardened — unchanged from v2) + +- **`packages/db/src/credential-handoff/scm-rights.ts`** — the privileged process (the **-03 migrator launch wrapper**) opens the privileged fd (attestation signing key, §5 L191) **before** the UID drop and passes the **descriptor itself** over a Unix-domain socket via an `SCM_RIGHTS` ancillary message. The receiver **never** re-opens `/proc/self/fd/N` (the kernel re-checks permission and returns EACCES after the drop — the FD5 failure). `O_CLOEXEC` managed explicitly on both ends. + - **[F5 FIX] Socket authentication:** the transfer socket **MUST** be a `socketpair()` created **pre-fork** (no filesystem socket) **OR** a filesystem socket in a `0700` directory verified with **`SO_PEERCRED`** (assert peer UID/GID/PID). The receiver sets **`MSG_CMSG_CLOEXEC`** on `recvmsg`. An unauthenticated/anonymous peer is rejected fail-closed. +- **`packages/db/src/credential-handoff/process-hardening.ts`** — after **every** credential/UID transition, re-assert `prctl(PR_SET_DUMPABLE, 0)` and **verify** `prctl(PR_GET_DUMPABLE) == 0`; treat `dumpable != 0` as a fail-closed abort. +- **Consumers:** KBN-101-03 migrator wrapper (drop to `10003:10003`), KBN-101-02 importer (privileged-fd case, drop to `10002:10002`). The runtime rung-connection selection is a **DB-role choice, NOT an OS-UID transition** — no new setuid surface, no dumpability handling, reinforcing the no-per-user / no-new-privilege-drop property. + +### 2.9 [B-1 FIX] Sink-resident, task/tier-scoped User-override table (the User deny is now enforced IN PostgreSQL) + +**Problem (Gate B-delta B-1):** in v2 the evaluator resolved the User-override in-app _before_ selecting a connection, so a compromised/buggy Gateway on the Orchestrator pool could bypass a User deny — contradicting v2 §1.4/Q2, which mandates that "an Orchestrator write vetoed by a User-override deny must ERROR at the sink." App-resolution is not sink-enforcement. + +**Fix (option a — DB-resident override, RLS-forced):** + +- **New relation `mosaic.task_status_write_override`** (owned/created by the KBN-100 producer, exactly like `tasks`), columns: `workspace_id`, `task_id`, `tier` (enum `{orchestrator}` for v1 — the only vetoable sub-god tier; extensible to a future 4th rung), `active boolean`, `created_by_actor_id` (audit metadata, NOT an authority key), timestamps. **Primary/unique key `(workspace_id, task_id, tier)`** — **task-identity + tier ONLY; NO per-federated-writer column is part of the key or an authority input** (preserves F6 + the Q1 HALT boundary: every Orchestrator-tier writer is vetoed identically for a given task). +- **Who may WRITE it (sink-enforced):** `INSERT/UPDATE/DELETE` on the override table is granted **ONLY** to `mosaic_runtime_user_capability` (the User god rung), with the table under `FORCE RLS` and a `WITH CHECK` policy binding the row's `workspace_id` to the session workspace predicate. **[B2-INSERT parity]** that `WITH CHECK` workspace binding applies to the override table's **INSERT** path too (`workspace_id = current_setting('mosaic.workspace_id', true)::uuid`, fail-closed on NULL), so the override table's write path carries the identical symmetric tenant isolation as `tasks` — a User-rung INSERT of a veto row for a foreign workspace, or under an absent GUC, **ERRORS at the sink**. **[v5 COMPOSITION MANDATE parity]** the override table's write policies are subject to the SAME composition rule as `tasks` (§1.4 item 3-bis / §2.4 clause 5): the workspace-bind (and any status/authority conjunct) MUST be realized as a **single compound `WITH CHECK`** OR **`AS RESTRICTIVE`** — **never as multiple _permissive_ policies intended to AND** (which would OR-admit a foreign-workspace veto row). The Orchestrator and base rungs have **no** write privilege — a non-User attempt to author/clear a veto **ERRORS at the sink**. Thus only the User rung can raise or lift a veto. +- **Who READS it:** all three rung capabilities get **`SELECT`** on the override table (workspace-scoped `USING`), because the `tasks` write policy subquery evaluates as the current (querying) rung role. The read is confined to the querying connection's workspace by the override table's own `USING` predicate. +- **How it vetoes (the subquery):** every `tasks` **write policy** for the **Orchestrator rung** (`INSERT … WITH CHECK`, `UPDATE … WITH CHECK`, and the transition `WITH CHECK`) is extended with: + + ```sql + AND NOT EXISTS ( + SELECT 1 FROM mosaic.task_status_write_override o + WHERE o.workspace_id = tasks.workspace_id + AND o.task_id = tasks.id + AND o.tier = 'orchestrator' + AND o.active + ) + ``` + + So an Orchestrator-tier write to a task carrying an active User deny is **REJECTED in PostgreSQL**, regardless of app-layer behavior. **[v5 COMPOSITION MANDATE]** this `NOT EXISTS()` subquery MUST be a **conjunct of the Orchestrator rung's single compound `WITH CHECK`** (alongside the status-pin and the workspace-bind), OR authored **`AS RESTRICTIVE`** — it may **NOT** be a separate _permissive_ policy intended to AND, because permissive policies OR and an Orchestrator write satisfying the status-pin permissive policy would then be OR-admitted despite an active deny (bypassing B-1). The User rung's own `tasks` policies do **not** subquery the override (User = god; the User authored the deny and overrides it). The base rung already cannot write status. + +- **Per-tier NEGATIVE test (real PostgreSQL, red-first; v5-HARDENED):** insert an active override row for `(W, T, orchestrator)` via the User rung; then an Orchestrator-rung write on `(W, T)` **using a transition the Orchestrator pin OTHERWISE ACCEPTS** (a legal non-terminal `UPDATE`/transition that would succeed absent the deny row, and under the correct workspace GUC) **must ERROR at the sink** — so the rejection **isolates the override subquery** (not a status/workspace rejection). **Positive-control:** removing the override subquery **alone** flips this negative to a **PASS**, proving the _override_ — not the status-pin or workspace-bind — is doing the rejecting (and proving the subquery is a single-enforced conjunct / RESTRICTIVE, not an OR of separate permissive policies). Clearing the row (`active=false`, User rung only) re-permits the Orchestrator write; a non-User attempt to write the override table **ERRORS**. This proves the User-override is **sink-enforced** (satisfies v2 Q2) and stays within Mos Q1 tier-level (task/tier-scoped, not per-writer). + +**[NB-2 — compromise-resistance scope of B-1.]** B-1 sink-enforces the veto against the **Orchestrator-rung path** — an Orchestrator-pool write to a vetoed task ERRORS in PostgreSQL regardless of app behavior. It does **NOT** claim to stop a Gateway compromised badly enough to hold the **User** pool: User = god and holds the sole write on the override table, so a compromised-User-pool Gateway can itself clear the veto. That is the **accepted co-resident-pool residual** (Mos Q1, NO HALT), not a defended boundary. The v3 §0 framing "a compromised Gateway cannot bypass a User deny" is corrected to "an Orchestrator-**path** write cannot bypass a User deny at the sink." Defending against a compromised User-pool Gateway would require per-federated-user DB credentials = the HALT boundary, which Mos Q1 explicitly declines. + +**HALT note:** because the key is `(workspace_id, task_id, tier)` and never `writer`, this fix does **not** give two same-tier writers different authority — it gives the _whole_ Orchestrator tier the _same_ per-task veto. The B2-INSERT workspace predicate is likewise applied uniformly to every writer in a tier. NO HALT. + +--- + +## 3. Findings-closure map (each → fail-closed, red-first testable) + +| Finding | Closure | Fail-closed enforcement point | Test (red-first, real PostgreSQL / real UID drop) | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **RC19-B1-01** raw sink has no enforceable authority boundary | Declarative deny-by-default RBAC (§2.1) → fixed rung → per-rung DB credential; PG enforces via revoked base privileges + `FORCE RLS` deny-all + per-rung `WITH CHECK`. | At the sink: effective DB role lacks the privilege; mis-authorized write errors in PG. | -00: rung roles created, safe attrs, non-owner. KBN-100: base `UPDATE(status)`/`DELETE` denied, User/Orch allowed. -01: checkout identity==rung + safe-attr. | +| **RC19-B1-02** DELETE+default-INSERT resets status | Status-preserving UPSERT on `(workspace_id,id)`; `status` excluded from `DO UPDATE`; `DELETE` revoked all rungs; status only via authorized transition. | At the sink: DELETE fails closed; refresh cannot touch `status`. | KBN-100 + -09: refresh preserves `status` byte-stable; DELETE denied; transition-only status change. | +| **RC19-B1-03** grant change breaks non-status compatibility | Invariant field-set (§2.5); grants declarative + physically separate → grant mutation writes zero `tasks` rows. | Structural: data plane and grant plane disjoint. | Apply arbitrary policy/grant delta → **zero byte delta** on all `tasks` rows + invariant-set membership test. | +| **RC19-B2-01** FD5 `/proc/self/fd` EACCES after UID drop | SCM_RIGHTS descriptor-passing (§2.8); never re-open `/proc/self/fd/N` post-drop; explicit `O_CLOEXEC`; authenticated socket (F5). | Post-drop process receives a live fd; no privileged re-open. | Drop UID → `/proc/self/fd` re-open EACCES/absent vs SCM_RIGHTS receive succeeds; unauthenticated peer rejected. | +| **RC19-B2-02** dumpability resets after credential transition | Re-assert + **verify** `PR_SET_DUMPABLE=0` after every transition; fail closed if `PR_GET_DUMPABLE != 0`. | Process aborts if dumpable ≠ 0 post-transition. | After simulated setuid: assert `PR_GET_DUMPABLE == 0`; inject reset → fail-closed abort. | +| **F1 (BLOCKING)** INSERT-time status unconstrained for base rung | `GRANT INSERT (non-status list)` → status DEFAULT for base; RLS `INSERT … WITH CHECK` pins new-row status to initial; `INSERT(status)` only User/Orch (§2.4). | At the sink: base status-on-insert denied by column-privilege and/or RLS `WITH CHECK`. | **Red-first:** base `INSERT … status='done'` ERRORS; base INSERT omitting status → DEFAULT; User/Orch initial-status INSERT succeeds. | +| **F2 (BLOCKING)** rung LOGIN roles lack declared/verified safe attributes | Declare both rung roles `NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS` + non-owner (§1.3); extend L222 checkout assertion (§2.2). | Bootstrap catalog + every checkout: fail closed on SUPERUSER/BYPASSRLS/ownership. | -00 catalog asserts attrs + non-ownership; -01 injects unsafe attr/ownership → `DATABASE_ROLE_UNSAFE`. | +| **F3 (BLOCKING)** BEFORE-trigger status bypass | Invariant: no trigger on `tasks` writes/normalizes `status` outside `transitionStatus()` (§2.7); homed in KBN-100 producer. | Producer DDL admits no status-normalizing trigger; transition path is only status writer. | KBN-100 static: enumerate triggers on `tasks`, none assign status. **-09 deployed:** base UPDATE of a permitted col cannot change status via any trigger. | +| **F4 (non-blocking)** no frozen `tasks(workspace_id,id)` unique key | Bind `tasks (workspace_id,id)` UNIQUE as explicit KBN-100 req; else re-bind UPSERT to `(id)` (§2.3). | `ON CONFLICT` resolves to a real unique index; else hard planning error (fails loud). | KBN-100 migration: unique key exists; UPSERT plans/executes; missing-key variant fails loud. | +| **F5 (non-blocking)** SCM_RIGHTS socket unauthenticated | `socketpair()` pre-fork **or** `SO_PEERCRED` + `0700` dir; `MSG_CMSG_CLOEXEC` on `recvmsg` (§2.8). | Fd transfer only over an authenticated peer; received fd close-on-exec. | Unauthenticated/wrong-peer rejected; `SO_PEERCRED` mismatch fails closed; `MSG_CMSG_CLOEXEC` asserted. | +| **F6 (non-blocking)** `userOverride` scope | Schema constrains override to `(workspace_id,task_id,tier)`; **forbids** per-writer key (§2.1); realized as the sink-resident override table (§2.9). Attribution → metadata (§2.6). | Fail-closed at policy load: a per-writer authority key **fails schema validation**. | Schema test: per-writer override/authority key **rejected**; tier/task-scoped accepted. | +| **F7 (non-blocking)** re-key launders status | Revoke `UPDATE(workspace_id,id,tenancy)` from **all** rungs incl. User/Orch (§2.5). | At the sink: no rung may UPDATE key/tenancy columns. | Each rung (incl. User) `UPDATE workspace_id`/`id` **ERRORS**. | +| **B-1 (BLOCKING; composition PINNED in v5)** User-override was app-enforced, not sink-enforced | Sink-resident `task_status_write_override` table `(workspace_id,task_id,tier)`, User-rung-write-only; the `NOT EXISTS()` subquery is a **conjunct of the Orchestrator rung's single compound `WITH CHECK`** (or authored **`AS RESTRICTIVE`**), **never a separate permissive policy** — else an Orchestrator write satisfying the status-pin permissive policy is OR-admitted despite the deny (§2.9 / §1.4 item 3-bis). | At the sink: an active User deny → Orchestrator write REJECTED in PG (single enforced check, not OR-bypassable); non-User cannot author the veto. | **Per-tier NEGATIVE (real PG; v5-hardened):** User inserts deny row → Orchestrator write **using a transition the pin OTHERWISE ACCEPTS** **ERRORS** (isolates the override). **Positive-control:** dropping the override subquery alone flips it to PASS (proves single-conjunction/RESTRICTIVE, not permissive-OR). Clear row → re-permitted; Orch/base write to override table **ERRORS**. | +| **B-2 (BLOCKING)** FORCE + `WITH CHECK`-only bricks all reads (SELECT returns 0 rows for every rung and owner) | Add per-rung `SELECT`/`UPDATE` `USING` policies, **workspace-scoped** via `current_setting('mosaic.workspace_id')`; forbid `USING(true)` (§1.4/§2.2). Homes tenant isolation at the sink. | At the sink: reads succeed only for the session's workspace; wrong/absent predicate → zero rows (no cross-tenant leak). | Post-activation: each rung `SELECT`/UPSERT-probe within its workspace **succeeds**; a `SELECT` under a foreign/absent `mosaic.workspace_id` returns **zero** rows; a `USING(true)` variant **fails the isolation negative**. | +| **B-3 (BLOCKING)** owner/backfill write path under FORCE RLS | Backfill/repair DML runs FIRST (RLS disabled); `ENABLE`/`FORCE RLS` is the **TERMINAL** migration step; **no standing schema_owner ALLOW write policy** after activation; break-glass (`mosaic_extension_owner` §4.1 L146 + external bootstrap actor §9 L266) named in -09 cert (§1.4). | Migration ordering: backfill completes before FORCE binds; post-activation owner write has no policy → fails closed. | KBN-100 migration test: backfill INSERTs succeed pre-FORCE; **post-activation `mosaic_schema_owner` `tasks` write ERRORS under FORCE**; no runtime rung is superuser/BYPASSRLS; -09 names the two break-glass actors as the accepted boundary. | +| **N-1 (non-blocking)** VIEW/RULE status-write path | Forbid views/rules on `tasks` yielding a status write, OR mandate `security_invoker=true` + zero runtime grants; enumerated by `invariant-set.ts` (§2.7). | Enumeration admits no status-writable view/rule; any view over `tasks` is invoker-rights + ungranted. | Negative: a rung cannot mutate `tasks.status` through any view/rule; enumeration fails an owner-rights view with runtime grants. | +| **N-2 (non-blocking)** F3 enumeration incomplete | Extend enumeration: no AFTER trigger `UPDATE tasks SET status`; no `SECURITY DEFINER` fn writing `tasks.status` (L176 backstop); no trigger on related runtime-writable table (`task_events`, L178) writing `tasks.status`; no `CREATE RULE`; scan function **BODIES** (§2.7). One-shot -09 cert; recommend continuous -06 CI scan. | Producer/-09 cert admits no indirect status-write path. | Catalog scan of `pg_proc.prosrc`/triggers/rules asserts none write `tasks.status`; deployed behavioral proof; (recommended) -06 CI re-scan per migration. | +| **N-3 (non-blocking)** Orchestrator can INSERT terminal status | Orchestrator `INSERT(status)` `WITH CHECK` pinned to non-terminal initial status; only User may INSERT arbitrary/terminal (§2.4). | At the sink: Orchestrator terminal-status INSERT rejected by `WITH CHECK`. | Orch `INSERT … status='done'` **ERRORS**; Orch `status='backlog'` succeeds; User `status='done'` succeeds. | +| **N-4 (non-blocking)** transition-graph legality not DB-enforced (`done→backlog` reset by another name) | **DECISION (documented, §2.10):** edge-legality is **app-enforced** within near-god/god authority; the sink closes RC19-B1-02 **for base only** (DELETE-denial + no base status write). No status-writing trigger is added (would collide with F3/N-2); a validation-only guard is deferred. | Documented boundary: User/Orch hold near-god/god authority; illegal edges are an app-layer concern, not a sink guarantee. | -09 records the decision + asserts base cannot reset status at the sink; app-layer transition-graph tests are owned by the Gateway command lane (out of KBN-101 scope). | +| **N-5 / AD-2 (non-blocking)** import direction ambiguity | `-01` provisions generic pools only; rung→pool SELECTION lives in `-10`'s `connection-selection.ts`, consumed by `-02/-03/-05`; **never imported by `-01`** → `10→01`, acyclic (§2.2). | Structural: no `01→10` edge exists. | Static import test: `-01` has no import of `sink-rbac/connection-selection`; `-02/-03/-05` do; graph acyclic. | +| **AD-1 (completeness)** frozen §4 L176 CONNECT cert reds on the 2 new logins | Amend the L176 allowlist to admit `mosaic_runtime_user`/`mosaic_runtime_orchestrator` + assert their `TEMPORARY` denial; named in -00 row/rc.20 (§1.3). | -00 cert: CONNECT allowlist includes the 2 new logins; all 3 runtime rungs `TEMPORARY`-denied. | -00 cert asserts CONNECT granted to the 2 new logins and no runtime rung retains `TEMPORARY`; an un-amended allowlist REDS. | +| **B2-INSERT (BLOCKING; composition PINNED in v5)** tenant isolation not enforced on the INSERT write path (v3 bound workspace only in `SELECT`/`UPDATE` `USING`; PostgreSQL does not apply `USING` to INSERT, and the explicit status `WITH CHECK` suppresses `USING` substitution → `workspace_id` unbound on INSERT → any rung can INSERT/UPSERT into a foreign workspace). **[v5 delta-3]** the v4 justification ("all `WITH CHECK` AND") was FALSE — multiple permissive policies OR — so the workspace conjunct must be pinned into a _single compound check or `AS RESTRICTIVE`_, else the bind is bypassable. | Include `workspace_id = current_setting('mosaic.workspace_id', true)::uuid` as a **conjunct of the single compound INSERT `WITH CHECK`** (with the F1/N-3 status-pin and, Orchestrator, the B-1 override subquery) **OR** author it **`AS RESTRICTIVE`**, on `tasks` (and the UPSERT INSERT arm) + symmetric on `task_status_write_override`. **Multiple _permissive_ policies intended to AND are EXPLICITLY FORBIDDEN** (they OR). Corrected semantics + realization mandate in §1.4 item 3-bis / §2.4 clauses 4–5 / §2.9. | At the sink: an INSERT/UPSERT with a foreign `workspace_id` fails the single enforced check; a NULL GUC → NULL predicate → INSERT ERRORS (fail-closed). Write-side tenant isolation homed at the sink and no longer OR-bypassable. | **Red-first (real PG; v5-hardened):** the cross-workspace negative uses an **otherwise-valid row** (base + `status='backlog'` + no override, differing only in `workspace_id=`) so the **ERROR** isolates the workspace bind; direct and via status-upsert INSERT arm. **Positive-control:** dropping the workspace conjunct alone flips it to PASS (proves single-conjunction/RESTRICTIVE, not permissive-OR). NULL-`mosaic.workspace_id` INSERT **ERRORS**; same-workspace valid-status INSERT **succeeds** (no over-deny); override-table foreign-workspace INSERT **ERRORS**. | +| **NB-1 (non-blocking)** override relation absent from the no-status enumeration | Extend the F3/N-1/N-2 no-status-write enumeration **and the -09 cert / continuous -06 scan** to `task_status_write_override`: no status-writing trigger / RULE / `SECURITY DEFINER` function on the override relation writes `tasks.status` (§2.7). | Enumeration/CI admits no indirect status-write path via the override relation. | Catalog + fn-body scan asserts no trigger/rule/SECURITY-DEFINER on `task_status_write_override` writes `tasks.status`; a planted one REDS the -06 scan. | +| **NB-2 (non-blocking)** overstated B-1 compromise claim | Scope §0/§2.9 wording: B-1 sink-enforces the veto against the **Orchestrator-rung path**; a compromised **User**-pool Gateway (User=god) can clear a veto — accepted Mos Q1 co-resident residual, NO HALT (§0/§2.9). | Documented boundary: sink-enforcement is Orchestrator-path-scoped; the User-pool residual is accepted, not defended. | Doc/claim assertion in -09 cert notes: the override-veto negative proves the **Orchestrator-path** ERROR; the framing no longer claims User-pool compromise resistance. | +| **NB-3 (non-blocking)** overstated B-2 compromise claim | Scope §0/§2.2 wording: the workspace GUC is app-set with no per-tenant DB check → B-2 + B2-INSERT protect a **buggy** Gateway (omitted predicate → fail-closed), NOT a **compromised** one forging the GUC (accepted residual). The B2-INSERT unbound-INSERT closure remains a real fail-closed win (§0/§2.2). | Documented boundary: fail-closed on omission/NULL; forged-GUC cross-tenant is the accepted per-user residual. | Cross-workspace read → zero rows; NULL/foreign-GUC INSERT ERRORS (buggy-Gateway closure proven); doc states forged-GUC is out of scope (HALT boundary). | +| **NB-4 (non-blocking; v5 states transitive)** owner-toggle-FORCE containment mis-stated as incapability | Name `mosaic_schema_owner` (reachable via `mosaic_migrator` `SET ROLE`) in the -09 break-glass enum as **contained-but-CAPABLE**: an owner can `ALTER TABLE tasks DISABLE/NO FORCE RLS` then write regardless of NOSUPERUSER/NOBYPASSRLS → containment = **transitive non-reachability from the 3 runtime LOGIN rungs**, asserted explicitly (§1.4). | -09 cert: owner toggle-then-write is real but confined to the migrator/owner phase; runtime rungs cannot reach `mosaic_schema_owner`/`mosaic_migrator` through any inherited (capability-role) path. | **[NB-4-transitive]** -09 asserts non-reachability as **TRANSITIVE membership** — `NOT pg_has_role(, 'mosaic_schema_owner', 'USAGE')` / `NOT has_privs_of_role(...)` recursion, NOT merely the absence of a direct `pg_auth_members` row (ownership check returns true for an _inherited_ membership through a capability role). Frozen §6 L222 "assumability" plausibly already covers this; "transitive membership" is stated explicitly. **[v6 — F-NB4]** the assertion additionally **conjoins the `MEMBER` variant** — `NOT pg_has_role(, 'mosaic_schema_owner', 'MEMBER')` **AND** `NOT pg_has_role(, 'mosaic_migrator', 'MEMBER')` for each of the 3 runtime rungs — because a `WITH INHERIT FALSE, SET TRUE` membership yields `has_privs_of_role = FALSE` yet still permits `SET ROLE … ; ALTER TABLE … NO FORCE`; the `MEMBER` privilege captures SET-ROLE reachability regardless of `INHERIT` (the intended graph has each rung `SET FALSE` to only its capability role, so this is assertion-completeness, not a live hole). `mosaic_schema_owner` is `NOLOGIN`, reachable only via `mosaic_migrator` `SET ROLE`; the 3 runtime rungs are transitive non-members and non-assumers (catalog + L222 checkout). | +| **NB-5 (non-blocking)** 2 new capability roles lack baseline runtime grants → fail frozen §6 L222 verify | Grant `mosaic_runtime_user_capability` / `mosaic_runtime_orchestrator_capability` the frozen §4 L176 baseline runtime grants (`USAGE ON SCHEMA mosaic`; `USAGE ON SCHEMA drizzle` + `SELECT` on the two ledger relations; relevant sequence `USAGE`/`SELECT`) at -00 (§1.1/§1.3). | Bootstrap: the 2 new rungs hold the baseline grants → they connect/operate and pass the L222 runtime verify. | -00 catalog asserts each new capability role holds `USAGE ON mosaic`, ledger `SELECT`, sequence grants; an omission → L222 runtime verify fails closed (missing inherited grant). | +| **Q2 per-tier deny-by-default (red-team requirement; v5 composition-pinned)** | Per-rung **single compound `WITH CHECK`** (status-pin + B2-INSERT workspace bind + Orchestrator override) **or `AS RESTRICTIVE`** — **never multi-permissive-intended-to-AND** — plus `USING` + `FORCE RLS`; deny-all default (§1.4 item 3-bis). | At the sink, every tier: unauthorized write-source rejected even for owner/maintenance (`FORCE`); tenant/override binds not OR-bypassable. | **NEGATIVE per tier (v5-hardened):** base terminal-status INSERT/transition ERROR; **Orchestrator write under User-override deny, using an otherwise-accepted transition, ERROR (+ positive-control: drop override subquery → PASS)**; **cross-workspace INSERT/UPSERT with an otherwise-valid status ERROR (+ positive-control: drop workspace conjunct → PASS)**; NULL-GUC INSERT ERROR; owner/maintenance post-activation write ERROR (`FORCE` proven); cross-workspace read → zero rows; **same-workspace valid-status write SUCCEEDS per writable command (INSERT, transition/UPDATE, SELECT) — the [v6 — N2] ≥1-permissive-per-writable(rung,command) option-(b) vacuous-deny guard**; `pg_policy.polpermissive` check confirms single-compound-or-RESTRICTIVE, **[v6 — N1] grouped by effective role incl. `PUBLIC`/inheritance and rejecting an internally-disjunctive single `WITH CHECK` — supplementary to, never a substitute for, the behavioral positive-controls (PRIMARY proof)**. | + +--- + +## 4. Implementation card DAG (owner + disjoint manifest, red-first at Gate A/B) + +``` +contract(amended) ─► KBN-101-00 (rung-ROLES + capability roles, safe attrs [F2], + │ deny-default base, CONNECT + [AD-1] L176 allowlist + │ amend (2 new logins CONNECT, TEMPORARY-denied); + │ bootstrap catalog tests. NO tasks/override DDL [GA-1]) + │ + KBN-101-01 ──┤ (provisions 3 generic rung pools; sets mosaic.workspace_id + │ session predicate [B-2]; checkout identity==rung AND + │ safe-attribute/non-owner verify [F2, extended L222]. + │ Does NOT import -10 [AD-2/N-5]) + │ + KBN-101-10 ──┘ depends 00,01: + packages/db/src/sink-rbac/** (policy+schema[F6]+evaluator+ladder+ + connection-selection[consumes 01; 10→01, N-5]+ + status-upsert[F4]+invariant-set[F7]+ + no-status trigger/view/rule enum[N-1,N-2]) + packages/db/src/credential-handoff/** (scm-rights[F5] + process-hardening +specs) + │ + ┌─────────────┬────────┼──────────────────────┬──────────────────────────────┐ + KBN-101-03 KBN-101-05 KBN-101-02 KBN-100 (producer lane) KBN-101-09 (evidence-only) + (dep 00,01,10) (dep 00,03, (dep 01,03,10) SPEC target of -10: (dep KBN-100,08) + migrator 10) importer fd + homes tasks + override DDL DEPLOYED cert: + wrapper: renderer: sink adapter uses AFTER create: real rung INSERT/ + SCM_RIGHTS 3 rung DSN status-upsert - rung column-grant matrix transition success; + SEND + dumpable secret (drop 10002) [F1,F7,N-3] override-veto [B-1]; + re-assert mounts; - RLS WITH CHECK(+override) cross-workspace read + (drop 10003) Gateway + USING workspace pred zero-rows [B-2]; + [GA-4(ii): Dockerfile; [Q2,B-1,B-2, conds a&b] backfill-then-FORCE + docker/db- NO SCM_RIGHTS - task_status_write_override ordering + owner-write + migrator. (DB-role, no table [B-1] denial [B-3] + break- + Dockerfile] UID drop) - tasks(workspace_id,id) UNIQUE glass named; + │ [F4] base status + KBN-101-06 (dep 02,03,05,07,10): inventory += KBN-101-10; INSERT/UPDATE/DELETE + overlap/ownerless/path-existence green; denial; refresh + matrix += rung selection + SCM_RIGHTS[F5] + status-preservation; + per-tier RLS negatives + override-veto[B-1] + no-status trigger/ + cross-workspace read[B-2] + backfill-order[B-3] view/rule [F3,N-1,N-2]; + + ADOPTED continuous no-status scan[N-2,NB-1] grant-delta zero-byte; + + cross-workspace/NULL-GUC INSERT neg[B2-INSERT] + │ dumpable=0 evidence + KBN-101-08 (dep 00…07,10): foundation + atomic activation cert +``` + +- **Gate A (re-review):** every element traces to a manifest owner; disjointness/overlap/ownerless proven against amended §7; the SPEC-vs-implement split for `tasks`+override sink DDL matches the frozen §4 L178 precedent (producer = KBN-100, applied **after** table creation — GA-1); dep edges acyclic with `10→01` import direction (AD-2/N-5); the L176 CONNECT amendment named (AD-1). +- **Gate B (red-team):** each B1/B2 finding **and** F1–F7 **and** B-1/B-2/B-3 **and** B2-INSERT **and** N-1..N-4 **and** NB-1..NB-5 **and** the Q2 per-tier deny-by-default has a **red-first** test that fails on the current design and passes only with the enforcement point; sink privilege/RLS/override/tenant denials — **including cross-workspace and NULL-GUC INSERT/UPSERT [B2-INSERT]** — proven **in PostgreSQL** (not PGlite — §5 L218); SCM_RIGHTS/dumpable against a real UID drop with an authenticated socket. + +--- + +## 5. Executability self-audit (re-run for v5) + +**Every element has an owner in the amended manifest — nothing ownerless:** + +| Element | Owner | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Declarative RBAC policy artifact + schema [F6] + evaluator + ladder + no-status trigger/view/rule enum [N-1,N-2] | KBN-101-10 (`packages/db/src/sink-rbac/**`) | +| Rung→credential connection-selection module (consumes -01 pools; `10→01` [N-5]) | KBN-101-10; pools provisioned in KBN-101-01 (`database.module.ts`) | +| Status-preserving-UPSERT [F4] + invariant-set [F7] module | KBN-101-10 (`packages/db/src/sink-rbac/**`) | +| SCM_RIGHTS handoff [F5] + non-dumpable assert/verify | KBN-101-10 (`packages/db/src/credential-handoff/**`) | +| Fixed rung DB roles + safe attributes [F2] + deny-default base + CONNECT + **[AD-1] L176 allowlist amend + TEMPORARY denial** + **[NB-5] baseline L176 runtime grants (`USAGE mosaic`, ledger `SELECT`, sequence grants) on the 2 new capability roles** | KBN-101-00 (`infra/pg-bootstrap/roles.sql` + tests) — **no tasks/override DDL [GA-1]** | +| `tasks` sink DDL: rung grants [F1,F7,N-3] + RLS `WITH CHECK`(+override) [Q2,B-1] + **INSERT `WITH CHECK` workspace bind on every rung [B2-INSERT]** + `SELECT`/`UPDATE` `USING` workspace predicate [B-2] + `FORCE RLS` terminal-after-backfill [B-3, conds a&b] + `(workspace_id,id)` UNIQUE [F4] + `actor_id` col [Q1] + no-status trigger/view/rule invariant on `tasks` **and override relation** [F3,N-1,N-2,NB-1] | **KBN-100 producer** (SPEC by -10; §4 L178 precedent; scope amended per Q2) | +| **`task_status_write_override` table [B-1]** (create + grants + FORCE RLS + policies; User-write-only incl. **INSERT `WITH CHECK` workspace bind [B2-INSERT parity]**, all-rung-SELECT) | **KBN-100 producer** (SPEC by -10; same L178 precedent) | +| `mosaic.workspace_id` session predicate set at checkout [B-2] | KBN-101-01 (`database.module.ts` / `connection-identity.ts`) | +| Rung DSN secret mounts (3 fixed) | KBN-101-05 (renderer, extends runtime-URL row) | +| Checkout identity==rung + safe-attribute/non-owner verify [F2] | KBN-101-01 (`connection-identity.ts`, extended L222) | +| Migrator launch wrapper SCM_RIGHTS send + dumpable re-assert | KBN-101-03 (`docker/db-migrator.Dockerfile` [GA-4(ii)]) | +| Importer privileged-fd case | KBN-101-02 | +| Deployed enforcement certification (F3/N-1/N-2 **+ NB-1 override-relation scan**, per-tier RLS negatives, B-1 override-veto, B-2 cross-workspace read, **B2-INSERT cross-workspace/NULL-GUC INSERT negatives**, B-3 ordering + break-glass naming **incl. [NB-4] `mosaic_schema_owner` contained-but-capable / non-reachability assertion**, N-4 decision) | KBN-101-09 (evidence-only) | +| Inventory/overlap/path-existence + command matrix (rung sel + SCM_RIGHTS + per-tier RLS + override-veto + cross-workspace read + **[B2-INSERT] cross-workspace/NULL-GUC INSERT negatives** + backfill-order + **[ADOPTED] continuous no-status scan on `tasks` + override relation**) | KBN-101-06 (fixture += -10) | + +**Manifest closure (KBN-101-06 green):** the only NEW ownership is the two disjoint -10 globs. The B-1 override table, B-2 `USING` policies, B-3 terminal-FORCE ordering, **and the v4 B2-INSERT INSERT `WITH CHECK` workspace binding (+ its override-table parity) and the NB-1 override-relation no-status enumeration** are ALL producer DDL homed in KBN-100's already-owned `schema.ts`/`drizzle/**` (a _responsibility_ widening, not a glob change — identical treatment to the v3 `tasks` sink DDL). The **NB-5 baseline capability-role grants** amend -00's already-owned `roles.sql`/tests (like AD-1); the **continuous -06 no-status scan** lives in -06's already-owned `tools/ci/fixtures/kbn101-ddl-inventory.json` + `.woodpecker/ci.yml`; the NB-2/NB-3/NB-4 claim-scopings/enumeration are doc + -09 cert wording on already-owned paths. N-1/N-2/N-5 additions live in -10's already-declared `sink-rbac/**`. **No glob is split/shared/narrowed → overlap/ownerless/path-existence stay green (KBN-101-06 unchanged-green vs v3).** + +**NO per-federated-user topology (HALT boundary respected — re-verified for v5):** DB-role/credential cardinality = **3** (User / Orchestrator / others), fixed and independent of the federated-user count. Federated identity → rung happens in the evaluator at authorization time. A new federated user creates **zero** roles/credentials/secrets/pools. The **B-1 override table is keyed `(workspace_id, task_id, tier)` — task+tier, never writer** — so it homes a tier-level veto, not a per-user distinction. The **B-2 read `USING` predicate AND the B2-INSERT INSERT check predicate are the SAME shared per-request session var** (`mosaic.workspace_id`), applied uniformly to whichever rung role holds the connection — tenant isolation, not writer discrimination (and, per v5, composed into a single compound check or `AS RESTRICTIVE` rather than an OR of separate permissive policies). Per-writer **attribution** is a metadata column (`task_events.actor_id`, §2.6), not a DB principal. `userOverride` forbids a per-writer authority key (F6, fail-closed at load). The NB-1..NB-5 fixes add no principal, key, or predicate that varies by writer. Enforce-at-sink is satisfied by the fixed rung roles' grants + `FORCE RLS WITH CHECK`(+override, +workspace-bound INSERT) + `USING` — it does **not** require, and this design does **not** use, a DB role/credential per federated user. + +**Q1 same-tier / different-authority check (the ONLY HALT trigger):** no v5 change gives two SAME-TIER writers different WRITE authority. The v5 composition pin fixes only **how** the already-uniform predicates are physically composed (single compound `WITH CHECK` or `AS RESTRICTIVE`, never multi-permissive-OR) — it changes no predicate's content and introduces no writer-varying axis. F1/F2/F3/F7/N-3 tighten authority uniformly per tier; **B2-INSERT binds the same workspace predicate uniformly into every rung's INSERT check** (tenant isolation, not writer discrimination); B-1 vetoes the _entire_ Orchestrator tier per task (not a subset of Orchestrator writers); B-2 isolates by workspace uniformly; F6 forbids per-writer authority keys; attribution is audit-only. The NB-4-transitive nit only _strengthens_ the owner non-reachability assertion (transitive membership) — no authority axis. **No same-tier-different-authority requirement exists → NO HALT — an executable envelope is delivered.** + +--- + +## 6. Flagged ambiguities in the frozen contract (for reviewer awareness) + +1. **Sink identity of the status store.** The RBAC-guarded sink is `tasks.status`; legacy `mission_tasks.status` stays frozen read-only. Confirm no reviewer reads "raw sink" as the legacy column. +2. **Frozen "role graph unchanged" + "no RLS" + L176 CONNECT allowlist vs. the rulings.** SHARED-CONTRACT rc.5 L82/L88-90 froze the role graph and grant/revoke-only (no RLS); frozen §4 L176 froze the CONNECT allowlist. Jason B1 + Mos OPTION A add fixed rung-roles; **Mos Q2** authorizes RLS `WITH CHECK`/`USING` + `FORCE RLS` + the override table; **AD-1** amends the L176 allowlist for the 2 new logins. The rc.20 amendment text (§1.3/§1.4) records all three as deliberate, human-authorized departures. Reviewer should confirm rc.20 is **adopted** (not treated as a violation), that KBN-100's DDL scope is amended to include RLS + the override table on `tasks`, and that the -00 row amends the L176 allowlist. +3. **Natural-key column list for `tasks` (F4).** `tasks (workspace_id, id)` UNIQUE is asserted by the SI-001 pattern but must be **bound + created by KBN-100**. If KBN-100's `tasks` identity differs, the UPSERT natural key re-binds to `(id)` (spec-level, no HALT). +4. **Initial/terminal status values (F1/N-3).** F1 pins base new-row status to `backlog` (§5.4 L214); N-3 pins the Orchestrator's INSERT set to non-terminal (`{backlog, ready}`) and reserves terminal (`{done, cancelled}`; `in_review` treated as non-initial) to the User rung. KBN-100 binds the exact column DEFAULT and the RLS `WITH CHECK` literals against the §3 L134 vocabulary so they agree. +5. **Workspace session-var name + set-site (B-2 + B2-INSERT).** The `USING` predicate **and the v4 INSERT `WITH CHECK` predicate** both use `current_setting('mosaic.workspace_id', true)`. -01 sets it per request from the server-verified tenant (never a body field, §7.1). Reviewer should confirm the exact GUC name and that a transaction-local `SET LOCAL` is used so a pooled connection cannot leak a stale workspace across requests (mirrors the frozen `SET LOCAL search_path` discipline, §4 L152) — now **explicitly folded into the frozen L174 poisoned-pooled-session-reset + `SET LOCAL` restoration negative test** (Gate B 2(a), §2.2). Reviewer should also confirm the accepted scope (NB-3): fail-closed on an omitted/NULL GUC (buggy Gateway), but a _forged_ GUC to a victim tenant is the accepted per-user residual (Mos Q1), not defended. +6. **N-4 transition-graph decision.** Edge-legality (illegal/backward transitions such as `done→backlog`) is **app-enforced** within near-god/god authority; the sink closes RC19-B1-02 for **base only** (DELETE-denial + no base status write). A validation-only sink guard is deferred to avoid colliding with the F3/N-2 no-status-trigger invariant. Reviewer should confirm this decision is acceptable (it is stated, not silently dropped). +7. **N-2 continuous scan — now ADOPTED.** The complete no-status-write enumeration (trigger/view/rule/SECURITY DEFINER/function-body), covering `tasks` **and `task_status_write_override` (NB-1)**, is now a **continuous -06 CI catalog scan run on every migration** (promoted from the v3 one-shot -09 recommendation, per Gate A-delta-2 obs #2); the -09 deployed cert still records the final proof. Reviewer should confirm the -06 scan is owned/wired in `tools/ci/fixtures/kbn101-ddl-inventory.json` + `.woodpecker/ci.yml` (already-owned -06 paths). +8. **KBN-101-09 is evidence-only.** B-1/B-2/B-3/F3/N-1/N-2 negative tests are _authored/owned_ by the producing implementation cards (KBN-100 producer for `tasks`/override triggers/RLS; -10/-01 for the rung path); KBN-101-09 records the **deployed** certification evidence and changes no implementation path. Confirm the test-ownership split is acceptable (mirrors the frozen -09 "evidence-only" role). +9. **B2-INSERT / B-1 `WITH CHECK` composition — SEMANTICS CORRECTED + REALIZATION MANDATED (v5; the delta-3 fix).** v4 justified the composition with a **FALSE** PostgreSQL claim — "multiple applicable `WITH CHECK` clauses must all pass (they AND)". **That is struck.** The **correct** semantics: multiple **PERMISSIVE** policies for a command combine with **OR** (a row is admitted if it satisfies **ANY** one permissive `WITH CHECK`, ANDed with all `AS RESTRICTIVE` checks); the AND this closure needs holds **ONLY** (a) within a _single_ policy whose `WITH CHECK` is one AND-conjoined expression, or (b) across `AS RESTRICTIVE` policies. Accordingly v5 **MANDATES** (§1.4 item 3-bis / §2.4 clause 5 / §2.9) that, per rung per command, the status-pin **AND** the workspace-bind **AND** (Orchestrator) the override subquery be realized as EITHER a **single compound `WITH CHECK`** OR the status-pin-as-sole-permissive **plus** the workspace-bind/override **`AS RESTRICTIVE`**, and **EXPLICITLY FORBIDS** realizing them as multiple _permissive_ policies intended to AND (which would OR-admit a foreign-workspace INSERT satisfying only the status-pin — cross-tenant injection — and equally bypass the B-1 veto). Reviewer should confirm: (i) the corrected OR-semantics statement replaces every "all `WITH CHECK` AND" assertion; (ii) the realized policies are single-compound or `AS RESTRICTIVE` (a `pg_policy.polpermissive` catalog check backs it); (iii) the hardened red-first negatives use an _otherwise-valid_ row (cross-workspace: base + `backlog` + foreign ws; override: an otherwise-accepted Orchestrator transition) so each rejection isolates the bind/override under test, **and each carries a positive-control** (dropping the workspace conjunct — resp. the override subquery — alone flips the negative to a PASS, proving the bind/override, not the status-pin, is rejecting). Confirm the accepted scope (NB-2/NB-3) is unchanged: write-side sink tenant isolation is complete against a _buggy_ Gateway; a _compromised_ Gateway forging the GUC or holding the User pool is the accepted Mos Q1 residual. + +--- + +_Envelope A **v6** authored from base `b0d78d86`. **v6 = v5 + N1/N2/F-NB4 non-blocking test/lint/cert hardening ONLY — NO design predicate, policy, grant, role, or table is changed** (only test/lint/cert wording). v5 CONVERGED with BOTH terminal re-gate-4 gates GO (Gate A-delta-4 `a4ce075a`; Gate B-delta-4 `a379e0de`), NO HALT; Gate B-delta-4 flagged 3 EXPLICITLY NON-BLOCKING hardening notes "for the trunk-commit record," folded here additively: **N1** — the `pg_policy.polpermissive` lint is strengthened to count permissive policies grouped by EFFECTIVE role (incl. `PUBLIC`/inheritance) and to reject an internally-disjunctive single `WITH CHECK`, stated as SUPPLEMENTARY with the behavioral positive-controls remaining the PRIMARY proof (§1.4 item 3-bis / §2.4 clause 5 / §2.7 / §3 Q2 row); **N2** — the option-(b) vacuous-deny guard is stated explicitly as the load-bearing "≥1 PERMISSIVE policy per writable (rung, command)" invariant, verified by the "same-workspace valid-status write succeeds" positive tests for INSERT / transition-UPDATE / SELECT (§1.4 item 3-bis / §2.4 clause 5 / §3 Q2 row); **F-NB4** — owner non-reachability is extended to SET-ROLE assumability by conjoining the `MEMBER` variant `NOT pg_has_role(, 'mosaic_schema_owner', 'MEMBER')` AND `NOT pg_has_role(, 'mosaic_migrator', 'MEMBER')` for each of the 3 runtime rungs in the -09 cert (§1.4 NB-4 para / §3 NB-4 row). All three are assertion/test/lint completeness on an already-BOTH-GO design; the intended role graph is already safe. The v6 body otherwise preserves the v5 text verbatim, which in turn preserved EVERY v4 closure (all v4 checks passed except one bounded item) and closed the SINGLE remaining BLOCKING finding raised identically by BOTH re-gate-3 gates (Gate A-delta-3 `aa5cbf73` CHANGES-NEEDED; Gate B-delta-3 `aa881074` 1 blocking) — the **RLS `WITH CHECK` composition semantics**: (1) **corrected semantics** — struck the FALSE "PostgreSQL requires ALL applicable `WITH CHECK` to pass / they AND" from §1.4/§2.4/§6.9 and stated the true rule (multiple PERMISSIVE policies OR; AND holds only within a single compound `WITH CHECK` or across `AS RESTRICTIVE`); (2) **mandated the realization** — per rung per command, status-pin + workspace-bind + (Orchestrator) override subquery MUST be a **single compound `WITH CHECK`** OR the status-pin-as-sole-permissive **plus** the bind/override **`AS RESTRICTIVE`**, with **multi-permissive-intended-to-AND EXPLICITLY FORBIDDEN**, applied to `tasks` **and** `task_status_write_override` (§1.4 item 3-bis, §2.4 clauses 4–5, §2.9); (3) **hardened the red-first negatives** — cross-workspace INSERT uses an otherwise-valid row (base + `backlog` + foreign ws) and the B-1 override negative uses an otherwise-accepted Orchestrator transition, so each rejection isolates the bind/override, **each with a positive-control** (dropping the workspace conjunct — resp. override subquery — alone flips the negative to PASS); plus **NB-4-transitive** (owner non-reachability stated as TRANSITIVE membership — `NOT pg_has_role(...)`/`has_privs_of_role(...)` recursion, not a direct `pg_auth_members` edge; frozen L222 "assumability" noted as plausibly already covering it). Every other v4 closure carries forward intact (3-rung ladder / 0 per-user; F1–F7; GA-1/3/4; B-1/B-2/B-3; the B2-INSERT workspace bind itself; N-1..N-5; AD-1/AD-2; NB-1..NB-5; continuous -06 no-status scan; L174 poisoned-pool `SET LOCAL` GUC fold; rc.20). Manifest disjoint (KBN-101-06 green) — all v5 changes land on already-owned KBN-100-producer / -09 / -06 paths (DDL realization detail + test wording; no glob change). Design/contract only; no SSOT file modified, no code, no PR. HALT self-check: NO HALT (fixed 3-rung ladder; override keyed task+tier not writer; workspace predicate shared per-request and uniform per tier, now composed as single-compound/`RESTRICTIVE`; attribution → metadata; no per-federated-user DB role/credential; no same-tier different-authority requirement — both delta-3 gates independently confirmed the boundary HOLDS). **v6 changes NOTHING in this HALT calculus: N1/N2/F-NB4 add only lint-scope, a vacuous-deny test invariant, and a `MEMBER` assumability assertion — zero new principal, key, predicate, grant, role, or table, and no writer-varying axis — so NO HALT is re-confirmed for v6.** Manifest remains disjoint (KBN-101-06 green): every v6 fold lands on already-owned paths — the -06 continuous scan / `pg_policy.polpermissive` lint in `tools/ci/fixtures/kbn101-ddl-inventory.json` + `.woodpecker/ci.yml`; the option-(b) positive tests in the KBN-100-producer / -09 evidence paths; the `MEMBER` assertion in the -09 cert — no glob split/shared/narrowed. NO code, NO PR, NO SSOT edit._ diff --git a/docs/native-kanban-sot/MISSION-MANIFEST.md b/docs/native-kanban-sot/MISSION-MANIFEST.md new file mode 100644 index 00000000..a439b4ce --- /dev/null +++ b/docs/native-kanban-sot/MISSION-MANIFEST.md @@ -0,0 +1,197 @@ +# Mission Manifest — Mosaic Native Kanban and Canonical Task SOT P0–P3 + +**Mission status:** CANON INDEPENDENTLY APPROVED; publication in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751) +**Date:** 2026-07-14 +**Human decision owner:** Jason +**Orchestrator/publication owner:** web1 control plane (`mos-claude`; `mosaic-100` acting during Claude quota outage) +**Execution topology:** USC web1, partitioned across collision-free GPT coder2/3/4/5 lanes +**Canonical requirements:** [`../requirements/native-kanban-sot.md`](../requirements/native-kanban-sot.md) +**Frozen integration contract:** `SHARED-CONTRACT.md` and `contracts/*.v1.ts` + +## 1. Mission statement + +Extend current `mosaicstack/stack` main into the sole native control plane for workspace-scoped project, mission, milestone, task, dependency, assignment, lease, approval, evidence, and audit state. First deliver a thin writable Kanban/List vertical slice; then add deterministic mechanical coordination and execute a one-way migration/cutover from jarvis-brain/Vikunja project/task stores. + +Success means every user, agent, orchestrator, specialist, and UI sees and mutates the same PostgreSQL aggregate revisions through typed Gateway commands, with no writable fallback and no hidden second authority. + +## 2. Scope boundaries + +### In scope + +- Current Drizzle/PostgreSQL schema extension and migrations. +- Workspace tenancy and authorization from the first migration. +- Projects, missions, milestones, tasks, normalized tags, dependencies, assignments, durable execution/quarantine state, links, immutable artifacts/evidence joins, outage change proposals, events, approvals, leases, checkpoints, and transactional outbox. +- NestJS Gateway queries and explicit lifecycle commands. +- MCP/CLI agent surfaces and generated read-only projections. +- Thin writable Next.js Tasks Kanban/List, task detail, minimal Projects CRUD, filters, dependency readiness, ownership/lease separation, and audit timeline. +- Non-LLM Mechanical Coordinator eligibility, proposal, approval-policy, lease/fence, heartbeat, retry, expiry, quarantine, and restart recovery. +- Planning, Enhance, Coder, Review, SecReview, PR-Monitor, and Certifier role/gate representation. +- One-way shadow importer, reconciliation, write freeze, final delta, cutover, rollback package, and legacy read-only stabilization. +- Recovery-posture configuration and health-state/fail-closed contract. + +### Out of scope + +- Greenfield services, Prisma runtime revival, or jarvis-brain flat files as runtime storage. +- Writable Markdown/JSON/Valkey/browser/provider fallback. +- Gitea issue/PR replacement or generic bidirectional provider sync. +- Calendar, email, GLPI cache, CRM, billing, time tracking, personal-brain migration. +- LLM scheduling or scope interpretation by the Coordinator. +- Autonomous gate waiver, certification, merge, release, deployment, or issue closure by Coordinator. +- Merge authority for Certifier. +- P4 full portfolio/mission designer and P5 fleet-scale policy unless separately released. + +## 3. Fixed invariants + +Every deployment MUST preserve all of the following: + +1. PostgreSQL is the sole writable SOT. +2. Drizzle on current stack main is the only persistence foundation. +3. Mutations fail closed when DB write-health cannot be proven `healthy`. +4. No file, Valkey, browser, queue, provider, or human note becomes a fallback writer. +5. `TASKS.md`, `mission.json`, and every file export are generated, read-only, non-authoritative, and never import sources. +6. Human outage notes become attributable post-recovery proposals only. +7. Workspace is the hard tenant; Team is intra-workspace authorization. +8. Valkey is expendable; PostgreSQL owns state, leases, fencing, audit, and outbox. +9. Mechanical Coordinator is deterministic/non-LLM and cannot invent scope, waive gates, certify, or merge. +10. Certifier is the final independent quality gate and has no merge authority. +11. Mutations use idempotency and optimistic aggregate versions; worker commands also require a current fencing token. +12. Recovery tier changes only backup/recovery posture, never authority or gate semantics. + +## 4. Configurable recovery posture + +Deployments select Lite, Standard, or High-assurance defaults from [`../requirements/native-kanban-sot.md`](../requirements/native-kanban-sot.md) and `contracts/recovery-posture.v1.ts`. Configurable fields are limited to: + +- backup/base-backup cadence; +- RPO and RTO targets; +- PITR retention; +- WAL archive cadence; +- restore-test frequency; +- break-glass drill frequency; +- encrypted off-cluster storage. + +High-assurance defaults are fixed reference values: RPO 15 minutes, RTO 4 hours, encrypted off-cluster WAL every 5 minutes with 35-day PITR, daily base backup, monthly restore test, and quarterly break-glass drill. + +## 5. Canonical role map + +```text +User + ↓ objectives, constraints, ratified decisions +Interaction Layer + ↓ workspace/project context; no scheduling authority +Portfolio Orchestrator + ↓ approved mission, cross-project priority/capacity +Project Sub-Orchestrator + ↓ decomposition, DAG, acceptance, release, routing policy, overrides +Gateway + ↓ authenticated/authorized typed commands +Project/Task Domain Services + ↓ transactional state + semantic event + outbox +Mechanical Coordinator + ↓ deterministic eligibility/proposal/lease/fence/retry/quarantine +Specialists + Planning → Enhance → Coder → Review → conditional SecReview → remediation + ↓ complete evidence bundle +Certifier + ↓ final pass/reject/escalate; NO merge authority +Project Sub-Orchestrator / control plane + ↓ merge authority after all gates +Post-merge validation +``` + +### Authority table + +| Role/layer | Owns | Explicitly cannot do | +| ------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| User | Objectives, constraints, Jason-owned decisions | Direct DB/file authority bypass | +| Interaction | Conversation and context resolution | Schedule, approve, lease, certify | +| Portfolio Orchestrator | Mission approval, cross-project priority/capacity/global holds | Implement or self-certify specialist work | +| Project Sub-Orchestrator | Task decomposition/DAG/acceptance, release to ready, routing policy, overrides, remediation, merge go-ahead | Bypass required independent gates | +| Gateway | Identity, tenancy, DTO validation, commands, state-machine enforcement | Accept file edits or client SQL as mutations | +| Domain services | Transactional business invariants, semantic events/outbox | Depend on Valkey/files for committed truth | +| Mechanical Coordinator | Eligibility, dependencies, proposal, approved routing, lease/fence, heartbeat, retry/quarantine | Invent/alter scope, waive gates, certify, merge | +| Specialists | Bounded planning/implementation/review artifacts under a task lease | Modify another lane's owned files or self-approve | +| Certifier | Final independent evidence/traceability/gate decision | Merge, close provider issue, release, waive policy | + +## 6. Gate model + +### Mandatory gates + +1. Requirements/contract freeze before parallel implementation. +2. P0 schema/authority threat model and tenant isolation review. +3. Author and reviewer MUST be different principals/sessions. +4. Functional review validates requirements, endpoint registry, concurrency, and negative paths. +5. **Mandatory SecReview (`secrev`)** for any auth, authorization, tenant, service-token, secret, database schema/migration, data-integrity, import/cutover, audit, lease/fencing, recovery, or destructive-retirement surface. +6. Review findings enter bounded remediation owned by the implementation lane. +7. Raising reviewer re-verifies remediation. +8. Certifier performs the final independent evidence and traceability gate. +9. Merge authority remains with `mos-claude`/Project Sub-Orchestrator control plane after gates pass. +10. Post-merge CI and situational validation must be terminal green before closure. + +### Gate outcomes + +- **PASS:** evidence complete; next authority may proceed. +- **REJECT:** findings are explicit and route to remediation. +- **ESCALATE:** policy/owner decision required; no implicit waiver. + +No role can transform a missing gate into a warning by changing status, editing a projection, or writing Valkey. + +## 7. Slice ownership rules + +1. USC web1 is the sole execution environment; coder2/3/4/5 are independent bounded lanes under Mos. +2. Every slice has one named file-tree owner and an explicit IN/OUT boundary in `TASKS.md`. +3. Two active slices MUST NOT edit the same source file, migration file, generated snapshot, lockfile, or API contract. +4. coder2 exclusively owns `packages/db/src/schema.ts`, `packages/db/drizzle/**`, migration journal/meta/tests, then its disjoint recovery-parser/runbook slice. All schema requests serialize through coder2. +5. Frozen `contracts/*.v1.ts` are read-only inputs during implementation. Contract changes require Mos approval, a version bump/amendment, and coordinated rebase before work resumes. +6. coder3 exclusively owns Gateway DTO/controllers/services and the enumerated `apps/gateway/src/mcp/**` server files. coder4 owns CLI/projection clients and never edits MCP server files. Web consumers use the exact KBN-105 endpoint/DTO freeze. +7. coder4 executes one lane order: CLI/projection → pure Coordinator → importer → cutover. The pure Coordinator under `packages/coord` does not load IDs or access DB, Gateway, Valkey, recovery I/O, or web files; coder3 owns the persistence/service adapter. +8. Migration/import tooling calls Gateway/migration-only approved ports and does not add a second database model. +9. Each lane commits only its owned files and reports any needed cross-slice change as a contract-change request instead of editing another lane's tree. +10. Cross-review is mandatory: no lane reviews its own changes. Recommended ring is coder2 ← coder5, coder3 ← coder2, coder4 ← coder3, coder5 ← coder4, followed by independent SecReview where triggered and Certifier final. +11. Integration-only edits are a separate serialized slice after component lanes are green; no opportunistic merge-conflict resolution may alter semantics. + +## 8. Delivery phases and exit gates + +### P0 — Canon and authority foundation + +- Publish this canon, frozen schema/ports/health/recovery contracts, threat model, authorization matrix, exact endpoint/DTO registry, concrete current-main field-by-field migration map, and standards amendment. +- Build hold remains active until independent author≠reviewer re-review returns GO on health proof/failures, approval binding, fencing, tenant relationships, proposals, migration map, slice ordering/API freeze, recovery validation, and vocabulary alignment. +- Exit: no unresolved second writer or contract blocker, tenant boundary frozen, all seven decisions traceable, and independent re-review GO recorded. + +### P1 — Thin native MVP + +- Schema/migration, tenant-safe Gateway, CLI/MCP/projection, writable Kanban/List/Projects, dependencies/readiness/audit. +- Exit: same revision across web/CLI/MCP/projection; cross-workspace tests fail closed; generated files cannot mutate state. + +### P2 — Mechanical coordination + +- Agent/session registry, deterministic engine, approval queue, PostgreSQL leases/fencing/checkpoints/outbox, retry/quarantine, operations UI. +- Exit: one lease winner, stale tokens rejected, dependencies/approvals enforced, DB/Valkey fault semantics proven, Certifier gate has no merge authority. + +### P3 — Shadow migration and cutover + +- Importer, lineage, reconciliation, reviewer UI, write freeze, final delta, Gateway switch, legacy read-only, stabilization and rollback package. +- Exit: signed reconciliation, zero active legacy writers, scoped Gateway identities, imported backlog cannot dispatch accidentally. + +## 9. Evidence required for mission closure + +- Requirement-to-test/evidence matrix. +- Schema/migration and N-1 rolling-deploy proof. +- Cross-workspace API/repository/import/Coordinator negative tests. +- Health-state and fail-closed fault injection. +- Valkey-loss/outbox replay and Coordinator restart tests. +- Concurrent lease and stale fencing tests. +- Endpoint-registry alignment across web/CLI/MCP/Gateway. +- Accessible real-Gateway Kanban journeys. +- Generated projection tamper/no-import proof. +- One-way migration dry-run/apply/verify and field reconciliation. +- Author-independent functional review and required SecReview. +- Certifier final decision and evidence bundle. +- Merged main SHA, terminal green CI, closed linked task/issue, and post-merge situational validation under orchestrator ownership. + +## 10. Change control + +This manifest is derived from the ratified source plan. Any change to SOT authority, workspace tenancy, fixed statuses, Coordinator/Certifier authority, health-state semantics, schema v1, migration direction, or recovery-tier field set is a contract change. Contract changes require Jason/Mos authorization and cannot be inferred by an implementation lane. + +> KBN-101 Envelope A (rc.20, `KBN-101-DB-ROLE-SPLIT.md` §4/§10) is the specific authorized instance of a schema-v1 contract change under this clause, ruled by Jason B1 + Mos OPTION A/Q1/Q2; see `KBN-101-DB-ROLE-SPLIT.md` rc.20. + +No coder lane may start while the build hold is active. KBN-010 must complete before KBN-100; KBN-105 exact endpoint/DTO freeze must complete before any API consumer implementation. diff --git a/docs/native-kanban-sot/SHARED-CONTRACT.md b/docs/native-kanban-sot/SHARED-CONTRACT.md new file mode 100644 index 00000000..e7b9eb59 --- /dev/null +++ b/docs/native-kanban-sot/SHARED-CONTRACT.md @@ -0,0 +1,343 @@ +# Native Kanban/SOT — Remediated Shared Contract v1 + +**Status:** CONTROL-PLANE rc.16 KBN-101 current generic storage-wrapper authority remediation complete; awaiting independent exact-head re-review. Prior KCR-001–016 and rc.4 SI-001 decisions retained; KBN-101 foundation certification precedes KBN-100 and real immutable-operation certification precedes KBN-105 +**Version:** 1.0.0-rc.16 +**Date:** 2026-07-15 +**Change authority:** Mosaic control plane/Jason only +**SI-001 amendment authority:** `web1:mosaic-100` control-plane decision under issue #753 + +## Amendment record + +### 1.0.0-rc.20 — KBN-101 Envelope A: declarative sink-RBAC + per-role connection-selection + RLS write-source + sink-resident User-override + +- **Choice:** adds the fixed User/Orchestrator/others runtime rung-roles (per-ROLE, deny-by-default; `mosaic_runtime_user`, `mosaic_runtime_orchestrator` + their capability roles), amends the frozen §4 L176 CONNECT allowlist for the two new logins (TEMPORARY-denied), and authorizes declarative RLS `WITH CHECK`/`USING` + `FORCE ROW LEVEL SECURITY` + a sink-resident `task_status_write_override` table on `tasks` — a schema-v1 mechanism addition beyond the prior grant/revoke-only model. +- **No per-federated-user topology:** DB-role/credential cardinality remains fixed at 3, independent of federated-user count; the override table is keyed `(workspace_id, task_id, tier)`, never per-writer; per-writer attribution is a metadata column (`task_events.actor_id`), never a DB principal. +- **Non-effect:** rc.4 SI-001, all KCR-001–016 decisions, and every prior rc (rc.5–rc.16) invariant not explicitly named above remain unchanged. Introduces no new principal, key, or per-user authority axis (Q1 HALT boundary not hit). +- **Authority:** Jason B1 ruling + Mos OPTION A (home the layer) + Mos Q1 (TIER-LEVEL) + Mos Q2 (RLS `WITH CHECK` authorized, two mandatory conditions — `ENABLE`/`FORCE ROW LEVEL SECURITY` as terminal migration step, non-owner rung roles). Exact implementation detail is normative in [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) §4/§10 and the KBN-101 Envelope A v6 record. +- **Gate:** lands as `KBN-101-10` (new owner card) + responsibility-widenings on `KBN-101-00/01/02/03/05/06`; the `tasks`/override-table DDL itself is homed in the **KBN-100** producer (starts after KBN-101-08) per the existing §4 L178 producer/consumer precedent — a SPEC note today, not an immediate implementation. + +### 1.0.0-rc.16 — Current generic storage-wrapper authority closure + +- **Current-source truth:** `packages/storage/src/cli.ts` currently shells `storage migrate --run` directly to `pnpm --filter @mosaicstack/db db:migrate` through `execSync`; no `mosaic-db-migrator` executable exists. README and user-guide command guidance therefore remove that command and any runner-delegation claim. The current wrapper is legacy N-1, uncertified, non-operative, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation. +- **Future-only boundary:** future schema migration remains non-operative and follows external bootstrap → TLS/roles → runner `--run` → runner `--verify` → readiness; tier copy uses only the separately held secure migrate-tier route. +- **Unmaskable semantic/source-consistency evidence:** before inventory, ownership, or status masking, -06 fails the exact former README commented code-fence generic-wrapper form and exact user-guide executable generic-wrapper form. Its source-consistency test proves the direct-Drizzle `execSync` target and absent runner bin, so any documentation describing current wrapper delegation to the runner fails. +- **Non-effect:** prior runner, legacy-CI, Compose, production-secret, attestation, pgvector, manifest, lock, TLS, activation, and serial-gate closures remain unchanged. + +### 1.0.0-rc.15 — Held runner and legacy-CI authority closure + +- **Held runner only:** Current operator documents cannot advertise `mosaic-db-migrator --run|--verify` as executable. The sole passing future form is one `Held future procedure` Markdown section, bounded through its next equal-or-higher heading, that explicitly says non-operative/no-current-command-authority, names KBN-101-00/-03/-05, and preserves external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. Any runner hit outside that section fails before inventory/ownership/status masking. +- **PGlite/current-CI boundary:** Fleet backlog current behavior is PGlite-only; PostgreSQL CLI/runner authority remains held until activation. README classifies the checked-in direct `db:migrate` CI job as active legacy N-1, uncertified, non-authorizing as an operator route, and pending KBN-101-06 removal; it is a known direct-DDL exception against an isolated disposable CI database, not approved ordinary behavior. The -06 fixture asserts every required status term and rejects ordinary-authority presentation. +- **Non-effect:** prior Compose, production-secret, attestation, pgvector, manifest, lock, TLS, activation, and serial-gate closures remain unchanged. + +### 1.0.0-rc.14 — Current Compose and production-secret route closure + +- **Current developer boundary:** `README.md` and `docs/guides/dev-guide.md` permit only in-process PGlite data-layer work and explicitly selected non-PostgreSQL Compose services. Gateway/Web local start is held because the current unguarded loader can inherit a daemon/project PostgreSQL DSN and reach runtime DDL; KBN-101-02 must reject it before connection. The current PostgreSQL Compose mount is legacy/unqualified; PostgreSQL and federated activation are held until KBN-101-00/-03/-05 and then follow external bootstrap → TLS/roles → runner `--run` → `--verify` → Gateway/Compose readiness. +- **Production boundary:** `docs/guides/deployment.md` is non-operative until the KBN-101-05 renderer-backed process-exec or `LoadCredential` interface exists. It contains no active production environment-file, monorepo auto-load, credential export/argv, or secret-activation lifecycle route; future units must preserve generation-pinned Vault consumer isolation. +- **Unmaskable semantic negatives:** -06 fails the exact former README/dev/deployment Compose-first sequences and every production `.env`, `EnvironmentFile=`, credential export/argv, or restart-as-secret-activation fixture before owned/status/normative classification. The held PGlite/non-PostgreSQL route and future ordered activation are the only passing fixtures. + +### 1.0.0-rc.13 — Federation-MILESTONES indirect-startup closure + +- **Complete operator inventory:** `docs/federation/MILESTONES.md` is exclusively KBN-101-07 and an exact KBN-101-06 `operator-document` `status-only` record. Its former `pgvector extension installed + verified on startup` wording is superseded and forbidden; it authorizes no current DDL, Compose/init, or runtime/startup path. +- **Unmaskable semantic negative:** before inventory disposition, the scanner fixture proves that exact former wording fails. The only passing status-only sequence is external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway readiness. + +### 1.0.0-rc.12 — Deployable importer generation and indirect-DDL-route closure + +- **Authenticated generation:** KBN-101-05 owns one canonical Vault KV-v2 importer record, `secret-{env}/mosaic-stack/database/importer` key `url`, with its version taken only from the same successful `data.metadata.version` response. Value plus provider version are one generation, never inferred from DSN bytes. The renderer creates separate immutable `0400` URL/version copies for migrator `10003:10003` binding-only access and importer `10002:10002` access; it uses fsync/atomic generation replacement for Compose and distinct versioned secret/config references for Swarm, so deployment cannot mix generations. +- **Bounded consumers:** importer alone receives its URL/version, CA at `DATABASE_TLS_CA_CERT_PATH`, pinned public key, and read-only attestation; migrator receives its own migration URL/CA, the URL/version only for no-connect/no-export binding, attestation output, and the root-wrapper-only private key. Safe fd open/fstat/digest/zeroize/close semantics, a privileged producer-only-to-importer-only attestation handoff controller (verify, exact-byte copy, fsync/atomic rename, `10002:10002` `0400` seal, then importer start), no shared writable file, no logging/oracle, provider rotation/revocation, CA/mount, consumer-isolation, and symlink/hardlink/owner/mode/TOCTOU negatives are mandatory. +- **Indirect-DDL closure:** `docs/federation/SETUP.md` is non-operative until KBN-101 activation and documents only external bootstrap → TLS/roles → runner `--run` → `--verify` → Gateway readiness. The -06 scanner performs unsuppressible semantic checks for automatic first-boot/startup extension/schema/migration language, Compose-up-before-runner, and init-script authority; the former SETUP wording fails and the remediated sequence passes. + +### 1.0.0-rc.11 — Target-bound importer attestation and exhaustive operator-route closure + +- **Target-bound proof:** trusted `mosaic-db-migrator --verify` now produces the atomic, credential-free `migrate-target.v1.json` JCS/Ed25519 artifact from a runner-only root-owned signing-key reference; the importer receives only pinned public verification keys and the artifact. Its signed v1 fields bind issued/expiry/nonce, exact secret version and SHA-256 of high-entropy target-file bytes, canonical TLS host/port/database, CA/SPKI, PostgreSQL system identifier/database OID, expected importer role, manifest/schema fingerprints, and producer invocation/build/image/correlation. No DSN, username, password, credential bytes, or signing key enters the artifact, importer, runtime, logs, or output. +- **Fail-closed importer:** `mosaic storage migrate-tier` requires both `--target-url-file /run/secrets/mosaic-migrate-target-url` and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`. Before target connection it validates files, signature/key/expiry/replay, secret version/digest, TLS/CA/role/manifest bindings and opens/digests/connects from the same in-memory URL bytes. After verified TLS but before transaction/DML it matches server ID, database OID, `current_user`, CA/SPKI, and manifest/schema; failure distinguishes zero connection from connection/zero-DML and DDL remains impossible. Rotation overlap/revocation, atomic rename, replay cache, secret rotation invalidation, and wrong/substituted/stale/tampered/file-change tests are mandatory. +- **Closed documentation surface:** KBN-101-06 inventories every current non-normative scanner hit, including `docs/guides/user-guide.md` and status-only `docs/federation/TASKS.md`; the latter is historical and cannot authorize DDL. The legacy `storage migrate` tier-copy syntax is unavailable. `storage migrate` is schema-wrapper delegation only; secure tier data copy is `migrate-tier`. Exact KBN PRD/contract/shared/task paths may be `normative-contract` scan class but are still scanned and cannot mask executable instructions. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md). +- **Non-effect:** pgvector closure, manifest, lock, role graph, TLS, activation, and KBN-100/KBN-105 serial gates are unchanged. + +### 1.0.0-rc.10 — PostgreSQL-valid untrusted pgvector owner and active migrate-tier closure + +- **Valid extension authority:** PostgreSQL 17 + pgvector 0.8.2 `vector` is untrusted (`trusted` absent; `relocatable=true`), so `mosaic_extension_owner` is exactly `NOLOGIN SUPERUSER`, not `NOSUPERUSER`. It is dedicated solely to `mosaic_extensions`, `vector`, and owner-bearing extension members; `rolcanlogin=false`, `rolsuper=true`, zero members, no runtime credential/Vault secret, and no app-container delivery are catalog and deployment proof. An externally controlled audited bootstrap-superuser session alone `SET ROLE`s for extension CREATE/UPDATE/SET SCHEMA, then `RESET ROLE`; fresh and shadow paths do so, while in-place existing work requires exact pre-existing `extowner`. +- **Explicit superuser exception:** `GRANT`/`REVOKE` cannot privilege-limit a superuser. The containment is dedicated identity, no login, no membership, external control plane, audit, independent review, backup/rollback, and maintenance window—not a false least-privilege claim. Runtime, migrator, schema owner, importer, and every service role cannot assume the role or alter/update/drop/change extension membership. Managed targets without this exact role are ineligible unless a versioned provider-owned extension-owner profile is independently approved. +- **Active secure data-migration route:** `docs/guides/migrate-tier.md` is exclusively KBN-101-07, is active rather than historical, and specifies runner-prepared/verified PostgreSQL destination plus a dedicated non-DDL importer. KBN-101-02 freezes `--target-url-file /run/secrets/mosaic-migrate-target-url`, never credential argv; raw `--target-url`, `DATABASE_URL` fallback, runtime owner, missing/unsafe file, wrong mode, and DDL all fail before target connection/DDL. KBN-101-06 inventory/matrix records the route and exact secure fields, then tests its finite operator-document closure. +- **Non-effect:** manifest, lock, `mosaic` application schema, TLS, activation, and KBN-100/KBN-105 serial gates remain unchanged. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md). + +### 1.0.0-rc.9 — KBN-101 extension-schema boundary, disjoint manifests, and scanner mechanics + +- **Extension schema owner:** `mosaic_extension_owner`, not `mosaic_schema_owner`, creates and owns `mosaic_extensions`, `vector`, and extension-member objects. The external bootstrap actor `SET ROLE`s for fresh creation or approved-owner relocation, then `RESET ROLE`s; rc.10 replaces the earlier membership wording with the PostgreSQL-valid zero-member superuser exception. Schema owner has only `USAGE` for legacy type resolution—never ownership, `CREATE`, `ALTER`, `DROP`, member change, or default-privilege authority. Runtime, migrator, and schema owner must fail catalog and direct DDL denials; shadow/resume/rollback repeat the owner/default-privilege proof. +- **Exclusive delivery DAG:** KBN-101-00…09 now has a complete, nonoverlapping exact file/glob manifest with named tests/evidence. The runner mapping is exactly `"mosaic-db-migrator": "./dist/cli.js"` and image `ENTRYPOINT ["mosaic-db-migrator"]`; `packages/storage/src/{cli,migrate-tier}.ts` belongs only to -02, and -07 is documentation only. -08/-09 own evidence paths only. -00…07 are prepared artifacts; the immutable N-1 image remains live until -08 atomic activation, so no independently deployed intermediate can bypass runtime controls. +- **Mechanical classifier:** -06 owns the exact scanner, inventory fixture, command-matrix harness, and CI wiring. Inventory records pin path/class/owner/disposition/allowed tokens/rationale/expiry/review revision; unknown, duplicate-owner, ownerless, missing-path, invalid allowlist, and historical-category masking fail. The architecture plan's operative direct `db:migrate` is replaced by sole-runner guidance rather than hidden under a historical category. +- **Non-effect:** manifest v1, lock, `mosaic` application-schema ownership, TLS, activation, KBN-100/KBN-105 serial gates, and all earlier canon decisions remain unchanged. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md). + +### 1.0.0-rc.8 — KBN-101 finite authority, executable runner, and pgvector-owner remediation + +- **Finite authority closure:** KBN-101-06 classifies every current executable source/script/package bin, operator document, and deploy manifest by exact path; unclassified current hits fail. Byte-immutable historical SQL, PGlite-only routines, negative-test literals, vendored/generated artifacts, and clearly labeled historical reports are exact-path/category reviewed allowlists only. `packages/db/src/index.ts` loses its public `runMigrations` export with a direct-import/compile negative; `docs/fleet/backlog-conventions.md` and `docs/PERFORMANCE.md` lose first-use/direct-Drizzle/Gateway-startup migration instructions and carry runner/readiness route negatives. A token scan is only input to the classifier, never proof of authority. +- **Executable exclusive cards:** KBN-101-03 alone publishes `mosaic-db-migrator` from `packages/db/package.json`/`src/cli.ts`, owns `docker/db-migrator.Dockerfile`, and keeps `{runner,config.dto,manifest,identity,tls}` private, with exact `--run|--verify|--help`, env-only input, stable exits, and command tests. KBN-101-00 alone owns `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, plus bootstrap tests. KBN-101-05 alone owns `tools/db/render-postgres-secrets.ts`, renderer tests, and Compose/Portainer/Swarm/two-gateway declarations, consuming the versioned bootstrap interface. No card overlaps renderer/bootstrap/deployment ownership. +- **Extension-owner transition:** `mosaic_extension_owner` is a dedicated NOLOGIN role whose membership/credentials never reach services; the external bootstrap actor alone may `SET ROLE` during bootstrap. Fresh vector and member objects retain that owner. PostgreSQL has no supported extension-owner alteration: approved-owner existing extension relocation validates `pg_extension.extowner`, members/schema/version and uses tested `ALTER EXTENSION ... SET SCHEMA`; legacy runtime-owned extension fails closed to a controlled shadow database migration with backup, evidence, quiesce/final delta, atomic switch, and read-only rollback window. No catalog mutation, ownership adoption, or `DROP CASCADE` is permitted. Runtime/migrator/schema-owner extension ALTER/DROP/member-update denial is mandatory. +- **Non-effect:** manifest v1, lock namespace, role/search-path, relocation/TLS/activation, KBN-100/KBN-105 serial gates, and all retained canon decisions are strengthened, not weakened. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md). + +### 1.0.0-rc.7 — KBN-101 complete current-path, relocation, and two-gateway remediation + +- **Finite current-path closure:** static inventory and the `DATABASE_URL`-only-before-connect/DDL denial matrix now explicitly include Gateway's former temporary-table pgvector test (runner-prepared persistent read/query-only fixture), `docker/init-db.sql` retirement, `migrate-tier.ts` runner/bootstrap-only guidance, and the active two-gateway harness. The harness is migrated, not retired: `postgres-a/b → mosaic-db-migrator-a/b → gateway-a/b`, each with isolated URL/CA material, verified readiness, SANs, and positive/negative TLS evidence. +- **Executable relocation:** KBN-101-03 exclusively owns `schema.ts`, Drizzle snapshots/journal/generated relocation and exact tests. All future application declarations use exported `pgSchema('mosaic')`; immutable historical SQL runs only in trusted legacy `public`. `vector` is fixed in non-writable `mosaic_extensions`, with exact catalog relocatability/version eligibility, explicit type/operator qualification, catalog-class ordering, unknown-object fail-closed behavior, clean/current-public/partial/reverse rollback tests, and an N-1 release order. +- **Bound deployment ownership:** `mosaicstack/stack` KBN-101-00/05 owns current Compose, Portainer, two-gateway, bootstrap renderer/templates, UID/GID declarations, and rendered validation. Gateway is fixed to `10001:10001`; PostgreSQL UID/GID is image-inspected and frozen only after digest pinning. Exact secret paths, atomic renderer behavior, Compose/Swarm targets/modes, Gateway/PostgreSQL leaf separation, and two-pair TLS failure evidence are required. Mosaic deployment control plane/Jason is the named activation authority; environment IaC/Vault supplies versioned input only. +- **Correct traceability:** REQ-03 maps to role/schema/search-path, REQ-04 to TLS, REQ-05 to post-KBN-100 immutability, REQ-06 to rollout/rollback, and REQ-07 to the KBN-101 → KBN-100 → KBN-101 → KBN-105 sequence. No prior manifest/lock/role/DAG/activation decision is weakened. + +### 1.0.0-rc.6 — KBN-101 closed DDL/TLS/ledger activation remediation + +- **Choice:** `mosaic-db-migrator` is the sole application/CI/test PostgreSQL DDL control plane. Every legacy entrypoint is routed or denied, rejects `DATABASE_URL`-only before connection/DDL, and `db:push` is unavailable outside an allowlisted disposable developer target. The runner holds one `max:1` session with fixed `pg_try_advisory_lock(1297044289,1262636593)` across preflight through release. +- **Exact ledger:** manifest v1 canonically serializes journal logical index/tag and SHA-256 of exact shipped migration bytes. It maps each observed ledger hash to one tuple; physical insertion order is non-normative, while missing/unknown/duplicate/ambiguous/corrupt/stale states fail closed. Shipped `0009` bytes remain unchanged; a missing/effects-absent `0009` runs normally, an applied-late hash maps normally, and partial/full effects with missing hash require backup restoration or separately reviewed repair—not manual adoption. +- **TLS/search path:** operator/IaC owns CA and server leaf lifecycle, exact compose/Swarm secret mounts, server TLS activation, service-DNS SANs, verified-TLS readiness, transition, CA overlap rotation, and rollback. Runtime/migrator use `verify-full`; PGlite is not PostgreSQL TLS evidence. Application sessions use only `pg_catalog,mosaic`; no URL/config-derived identifier reaches SQL. +- **Safe release:** cards 00–07 land prepared but inactive; owner-runtime deployments remain N-1. Mosaic control plane/Jason alone authorizes one atomic TLS/roles → runner → readiness → runtime activation or rollback. No runtime-operator compatibility switch, bypass, plaintext interval, or force-on-red exists; all temporary support is removed before KBN-101-08. +- **Non-effect:** role graph (**except as amended by rc.20 — see below**), immutable certification after KBN-100, KBN-105 gate, rc.5’s preserved rc.4 SI-001 invariants, and all KCR-001–016 decisions remain unchanged. Exact detail is normative in [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md). + +### 1.0.0-rc.5 — KBN-101 role/connection split + +- **Choice:** PostgreSQL `standalone` and `federated` runtime uses `DATABASE_URL` only as a non-owner `mosaic_runtime` login; an explicit migration phase uses `DATABASE_MIGRATION_URL` only as `mosaic_migrator`, which `SET ROLE`s to non-login `mosaic_schema_owner` for DDL. Local PGlite remains an explicit embedded exception. +- **No fallback / no startup DDL:** missing migration URL fails the migration phase; it never falls back to runtime URL/default/config. Gateway replicas do not run migrations. An advisory-locked migration phase verifies the exact ordered Drizzle ledger fingerprint before replicas may become ready. +- **Privilege model:** non-login `mosaic_platform_database_owner` is outside application paths; `mosaic_schema_owner` owns only application/ledger schemas. `mosaic_runtime` has only `mosaic_runtime_capability`, owns no object/schema, cannot assume owner/migrator, has no TEMPORARY privilege, has only read access to the Drizzle ledger, and must fail startup if effective identity, unsafe attributes, authenticated TLS, search path, schema version, grants, or immutable relation privileges differ from the frozen contract. `task_events`, `artifacts`, `task_checkpoints`, `task_checkpoint_artifacts`, and `approval_decision_artifacts` grant runtime only INSERT/SELECT; KBN-100 retains RESTRICT/no-cascade semantics. +- **Non-effect:** rc.4 SI-001 candidate-key/FK order and all KCR-001–016 tenancy, SOT, proposal-audit, approval, fence, recovery, no-cascade, endpoint, and wire invariants are unchanged. This amendment neither creates roles/secrets nor changes production deployment (**except as amended by rc.20 — see below**). +- **Gate:** KBN-101’s role/schema-boundary foundation certificate, Vault/redaction/rotation, N-1/rollback, and independent security GO are mandatory before KBN-100. After KBN-100 creates the immutable relations, KBN-101 real deployed-role immutable-operation certification plus Ultron GO is mandatory before KBN-105; synthetic test-role success alone is insufficient. Exact implementation detail is normative in [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md). + +### 1.0.0-rc.4 — KBN010-SI-001 (preserved) + +- **Choice:** add the explicitly named, non-partial unique candidate key `missions_workspace_id_uidx` on `missions(workspace_id, id)` and retain `missions_workspace_project_id_uidx` on `(workspace_id, project_id, id)`. +- **Rationale:** mission `id` remains globally unique, while the composite candidate key makes the frozen tenant-safe generic mission relations valid. `artifacts` and `approval_decisions` are polymorphic exactly-one-target records and do not consistently carry `project_id`; widening both children would unnecessarily broaden v1 and its target semantics. +- **Exact effect:** `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` continue to reference the exact ordered columns `missions(workspace_id, id)` with RESTRICT deletion, now backed by a matching candidate key. +- **Non-effect:** no SOT, tenancy, project-congruence, proposal-audit, approval, fencing, immutability, no-cascade, API, or wire-version invariant changes. The `SuccessEnvelopeV1.contractVersion` remains `1.0.0`. +- **Historical evidence boundary:** `KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` intentionally remains the immutable rc.3 blocker verdict that detected SI-001; this rc.4 record and the #753 scratchpad append are the authorized disposition. Rewriting the gate verdict is outside this amendment's exclusive scope. +- **Gate:** this amendment resolves the DDL defect identified by KBN010-SI-001 but does not itself lift KBN-100; independent schema/SecReview remains required. + +## 1. Authority + +Concrete contracts are the four `contracts/*.v1.ts` files. PostgreSQL/current-main Drizzle is the sole writable SOT. In PostgreSQL standalone/federated deployments, KBN-101 rc.13 DDL/ledger/TLS/role/attestation/generation separation is a precondition to schema implementation and certification. Public health, Valkey, files, exports, providers, browser state, and outage notes cannot authorize/reconstruct writes. Mechanical Coordinator is non-LLM with no scope/gate/certification/merge authority. Certifier is final independent gate with no merge authority. No feature lane starts until this canon merges and the KBN-010/KBN-105 prerequisites are satisfied. + +## 2. Health proof and exact failures + +`KanbanHealthResponseV1` is a discriminated union: + +| State | read | write | Capability | +| -------------------- | ----: | ----: | --------------------------------------------------- | +| `healthy` | true | true | reads; public state still cannot authorize mutation | +| `read-only-degraded` | true | false | reads only | +| `write-unavailable` | false | false | diagnostics only | + +Every response has `checkedAt`, `validUntil`, `policyRevision`; contradictory booleans fail validation. + +For a mutation, Gateway opens the PostgreSQL transaction, executes the live write probe on that transaction/connection, mints the internal branded `PostgresWriteHealthProofV1`, and revalidates time/policy/transaction identity immediately before mutation. Public REST/MCP/CLI DTOs never accept health/proof fields. Valkey/caller assertions cannot mint proof. Pure Coordinator takes `KanbanEvaluationContextV1`; persistence takes `InternalKanbanMutationContextV1` or probes internally. + +| Case | HTTP | Frozen result | Retry | +| ---------------------------- | --------------------------: | ------------------------------------------------------------------- | -------------------- | +| degraded write | 503 | `KANBAN_WRITE_HEALTH_UNPROVEN`, `read-only-degraded`, `not_applied` | false | +| write unavailable | 503 | `KANBAN_WRITE_UNAVAILABLE`, `write-unavailable`, `not_applied` | false | +| version conflict | 409 | `AGGREGATE_VERSION_CONFLICT`, actual version, `not_applied` | false | +| timeout/unreachable | timeout/502/504 | `retryable_transport_error`, `unknown` | same idempotency key | +| stale fence/session/approval | coordinator rejection union | `not_applied` | false | + +Required negatives: contradictory state, expired/policy-mismatched/wrong-transaction proof, Valkey-only health, forged healthy, and exhaustive non-cross-mapping of 503 vs 502/504/timeout vs 409. + +## 3. Canonical schema invariants + +Complete declaration: `contracts/kanban-schema.v1.ts`. + +- Tables: tenant/identity (`workspaces`, members, teams/members, agents/sessions); planning (`projects`, `milestones`, current-milestone join, `missions`, mission-milestones, `tasks`, normalized tags, dependencies); orchestration (`task_assignments`, durable execution state, leases, checkpoints/evidence); governance (`change_proposals`, immutable artifacts/evidence, events, approvals, outbox, external links). +- Task statuses: `backlog | ready | in_progress | blocked | in_review | done | cancelled`. +- Assignment states everywhere: `awaiting_approval | policy_pre_authorized | approved | rejected | leased | released | expired | superseded`. +- Specialist roles everywhere: `planning | enhance | coder | review | security-review | pr-monitor | certifier`. +- Owner uses exactly-one user/team; assignment principal exactly-one user/team/agent; users require active membership; agent/session and all evidence are workspace-bound. +- Task→mission/milestone/parent, mission→milestone, and project→current-milestone are project-congruent composite relations. +- Mission `id` remains globally unique. The additional non-partial `missions_workspace_id_uidx` candidate key on `(workspace_id, id)` exists only to support the frozen workspace-safe polymorphic artifact and approval-decision mission relations; the project-congruent `(workspace_id, project_id, id)` key remains authoritative wherever `project_id` is present. +- Dependency identity is workspace+predecessor+successor independent of type. +- Approval evidence and checkpoint evidence are workspace-scoped joins to immutable artifacts, never JSON ID arrays. +- Proposal audit links are composite relations: `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` reference `task_events(workspace_id, id)` with RESTRICT deletion. +- Assignment is persisted with task/version, exact target/session, expiry/state/policy/proposer/reason. Approval relates to assignment. Lease acquisition accepts IDs, then reloads/locks and validates every relation. +- `tasks.fencing_counter` is bigint; locked atomic increment/RETURNING creates a decimal-string lease token. Lease/checkpoint composites bind exact workspace+task+assignment/session+fence. +- `task_execution_states` durably records retry/quarantine/exhaustion. +- Tags are normalized; legacy `tasks.tags` remains through N-1. Archive is explicit actor/reason/time and does not change lifecycle. +- Canonical parents use RESTRICT. Events/checkpoints/artifacts/evidence are INSERT/SELECT-only for application roles. Normal flow archives/cancels; purge is audited break-glass retention work. + +## 4. Outage proposal contract + +`change_proposals` stores workspace, active-member proposer, source-note digest, target/version, typed command/payload, idempotency, lifecycle, decision actor/reason/time, proposal version, and submit/accepted event IDs. Both event IDs are workspace-aware composite foreign keys to `task_events(workspace_id, id)`; a bare UUID is never sufficient. + +Submission preallocates the proposal ID. One transaction inserts `change_proposal.submitted` with the proposal workspace, `aggregate_type='change_proposal'`, `aggregate_id=`, `previous_version=NULL`, and `new_version=1`, then inserts the proposal referencing that event. Missing, foreign-workspace, wrong-type, or unrelated-proposal events abort the transaction. + +Submit/list/get/accept/reject are explicit Gateway commands. Pending/rejected proposals are inert: no scheduling, dependency/gate satisfaction, or direct target mutation. Acceptance locks proposal+target, obtains fresh transaction-local proof, verifies pending/expected version, invokes the normal command handler, and atomically stores the emitted normal-command event ID. That event must share the proposal workspace, match `target_aggregate_type` and `target_aggregate_id`, use `causation_id=submitted_audit_event_id`, and carry `payload.changeProposalId=`. Missing, foreign-workspace, unrelated-target, unrelated-proposal, or unrelated-command events abort acceptance. + +## 5. Concrete current-main N-1 migration delta + +**Inspected:** `origin/main:packages/db/src/schema.ts` at `e72388b2cbfe400842fe940fa6cabf984ed43711` (2026-07-13). It has global teams/no workspace keys, legacy project/mission/task statuses, nullable task project/mission, `tasks.assignee/tags/due_date`, mission JSON/config, duplicated `mission_tasks.status`, legacy agent fields, and separate fleet `backlog` claims. + +Legacy columns remain declared in unified `schema.ts` for expand + full N-1/rollback window. Generation must not infer early drops. + +### 5.1 Ordered phases + +1. **Pre-expand:** N-1 patch stops `mission_tasks.status` as write source; inventory writers; backup/checksum. +2. **Expand:** add enums/tables and nullable-first columns; retain legacy declarations/uniques; emit no v1-only status. +3. **Backfill:** bootstrap workspace; bounded idempotent cursor/checksum batches; quarantine ambiguous rows. +4. **Validate:** no null tenant, cross-project link, ambiguous owner; status/tag/date/config retention; then constraints/NOT NULL. +5. **Compatibility:** N-1 reads legacy; same-DB transaction mirrors only unavoidable fields; never file/Valkey dual write. +6. **Switch:** stop N-1 writers; Gateway sole command boundary; enable canonical statuses. +7. **Contract release:** later release after rollback/N-1; remove compatibility/global uniques/legacy fields. + +### 5.2 Mission candidate-key and dependent-FK DDL order + +KBN-100 migration DDL must execute the SI-001 portion in this order: + +1. expand/backfill `missions.workspace_id` and `missions.project_id` while preserving the global `missions.id` primary key and the project-congruent `missions_workspace_project_id_uidx` key; +2. prove duplicate-key feasibility on the production-shape dataset: `(workspace_id, id)` has no duplicate groups and global `id` uniqueness remains intact; +3. create the non-partial unique index `missions_workspace_id_uidx` on exact ordered columns `(workspace_id, id)`; +4. only after step 3, create/alter `artifacts` and add `artifacts_workspace_mission_fk` from `(workspace_id, mission_id)` to exact `missions(workspace_id, id)` with `ON DELETE RESTRICT`; +5. only after step 3, create/alter `approval_decisions` and add `approval_decisions_workspace_mission_fk` from `(workspace_id, mission_id)` to exact `missions(workspace_id, id)` with `ON DELETE RESTRICT`; +6. validate both constraints and prove a mission ID paired with a foreign workspace is rejected for each child. + +The candidate key is intentionally redundant with globally unique `missions.id`, but PostgreSQL requires a matching unique candidate key for the exact two-column FK target. It is additive and N-1-safe. Pre-switch rollback drops the two dependent FKs/tables before dropping this candidate key, preserves the global primary key and project-congruent key, and follows the existing freeze/reconciliation rule after the first canonical mutation. + +### 5.3 New audit/proposal DDL order + +KBN-100 migration DDL may begin only after KBN-101 foundation role/schema-boundary certification. It runs in the explicit migrator/owner phase—not Gateway startup—and its generated Drizzle declaration/snapshot/journal must be mutually consistent. It must execute in this order: + +1. create `task_events` and its unique `(workspace_id, id)` key; +2. create `change_proposals` with nullable acceptance-event ID and required submission-event ID; +3. add `change_proposals_workspace_submitted_event_fk` from `(workspace_id, submitted_audit_event_id)` to `task_events(workspace_id, id)` with `ON DELETE RESTRICT`; +4. add `change_proposals_workspace_accepted_command_event_fk` from `(workspace_id, accepted_command_audit_event_id)` to the same composite key with `ON DELETE RESTRICT`; +5. install application-role immutability privileges and same-transaction semantic validation before enabling proposal commands. + +The submission transaction inserts the event first using a preallocated proposal UUID, then the proposal. Acceptance inserts the normal command event before updating the locked proposal. Neither FK is omitted or replaced by a bare UUID/index check. + +### 5.4 Field map + +| Current | Expand/backfill | N-1 compatibility | Switch/contract | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------- | +| global `teams`, `team_members` | add workspace nullable; bootstrap; validate active owners | retain global slug/FKs | workspace composites; global unique contracts later | +| `projects.status` | add `canonical_status`; map active/paused/completed/archived | mirror representable values; no `planning` | canonical authority; legacy contracts later | +| project `owner_id/team_id/owner_type` | add exact accountable user/team; deterministic map or quarantine | preserve old reads and compare drift | canonical exact-one; remove legacy after parity | +| current milestone | create milestones then join table (no circular DDL) | absent to N-1 | join is authority | +| nullable `missions.project_id` | derive workspace/project; null/orphan exception, never guess | keep nullable legacy read | canonical required; validate/set NOT NULL later | +| mission relational candidate keys | retain global `id` PK and project-congruent key; add non-partial `(workspace_id,id)` key before artifact/approval FKs | additive key is ignored safely by N-1 readers/writers | retain both composite keys; generic mission children use exact workspace+ID target | +| mission `description` | add objective; preserve description; reviewed nonblank mapping | N-1 description | objective authority; retain until signed review | +| `missions.status` | add canonical; planning→draft, active/paused/completed/failed same | no new-only statuses emitted | canonical authority | +| mission `milestones` JSON | normalize with source digest; preserve malformed/original | N-1 reads JSON; no reverse sync | normalized authority; JSON removed after checksum sign-off | +| mission config/metadata/phase/user | retain all; map known typed policy only | all remain declared | remove only by signed consumer inventory | +| nullable `tasks.project_id` | derive explicit/mission project; orphan quarantine | retain nullable read/write during compatibility | canonical required; NOT NULL later | +| `tasks.mission_id` | add project-congruent composite | old relation readable | composite authority | +| `tasks.status` | canonical: not-started→backlog, in-progress→in_progress, others same | no ready/in_review emission | canonical authority | +| `tasks.assignee` | deterministic active user/team/agent assignment; raw value preserved if ambiguous | mirror text only if unambiguous | canonical owner/assignment; remove after no-loss sign-off | +| `tasks.tags` JSON | normalize trim/case/dedupe with original digest | transactionally mirror normalized rows | normalized authority; JSON later removed | +| `tasks.due_date` | copy exactly to `due_at` | mirror | due_at authority; legacy later | +| task common fields | preserve metadata byte-for-byte; add criteria/rank/retry/archive/version/fence | old reads valid | new fields canonical | +| `mission_tasks.status` | keep; prohibit as write source; linked status ignored; unlinked becomes task or reject | read-only compatibility value | membership uses task mission; status dropped after no readers | +| mission-task notes/PR/user | map to metadata/artifact/event/link/attribution; preserve | read-only | remove after parity | +| `agents.status` | add workspace/lifecycle/runtime/roles; status remains presence | retain all legacy fields | lifecycle/roles authority; status may remain telemetry | +| agent project/owner/prompt/tools/skills/config | preserve; validate tenant; derive typed capabilities without loss | N-1 reads | removal only by separate inventory | +| fleet `backlog` | map to designated-project tasks; edges; claimed rows quarantine | freeze claims before switch; read-only compare | task/lease authority; retire after stabilization | + +### 5.5 Required migration tests + +Empty DB; exact production-shape snapshot; crash/resume; rollback before switch; N-1 startup/read/write; workspace/member negatives; status-shadow/no premature new status; `mission_tasks.status` write prohibition; tags/assignee/date/mission JSON/config/description/agent checksum; project congruence/current-milestone order; backlog freeze/no dispatch; and proof legacy declarations persist until contract release. + +SI-001 adds frozen future executable evidence: empty and production-shape migrations create `missions_workspace_id_uidx` before either dependent FK; duplicate-key feasibility preflight returns no `(workspace_id,id)` duplicate groups without weakening global `id` uniqueness; N-1 startup/read/write behavior is unchanged; pre-switch rollback removes dependents before the candidate key; both exact FK column lists reconcile to the candidate key; and foreign-workspace mission references fail for both artifacts and approval decisions. TDD is not applicable to this design-only amendment; KBN-100 must implement these negative migration tests before runtime schema release. + +Proposal-specific negatives must attempt: missing submission event, foreign-workspace submission event, foreign-workspace acceptance event, same-workspace event for another proposal, event for another target aggregate, and unrelated normal-command event. Every attempt must fail atomically with no accepted proposal and no target mutation. + +## 6. Ownership and Coordinator split + +coder2 solely owns `packages/db/src/schema.ts`, `packages/db/drizzle/**`, journal/metadata, and migration tests. No other lane generates migrations. Expand is additive; no drop/rename/narrow; constraints validate before NOT NULL; compatibility is same-DB only; contract is later. + +KBN-200/coder4 owns pure `MechanicalCoordinatorDecisionEngineV1`: complete immutable snapshots in, deterministic eligibility/proposal/retry decisions out; no ID loading, SQL, Gateway, Valkey, proof, persistence, restart I/O, or LLM. + +KBN-210/coder3 owns `MechanicalCoordinatorServicePortV1`: ID loading, locks, fresh proof, assignment/approval persistence, atomic fencing, lease/checkpoint/outbox, Valkey wakes, durable retry/quarantine, and `recoverFromPostgres`. Cycle: load snapshots → pure decision → persist assignment → authoritative approval/policy → acquire by IDs/locks → increment fence → lease → ack/heartbeat/checkpoint → submit to review or durable retry/quarantine. No completion/certification/merge method exists. + +## 7. Exact Gateway/DTO freeze for KBN-105 + +### 7.1 Common wire rules + +Base is `/api/v1/workspaces/:workspaceId`. Mutations require header `Idempotency-Key` (1–128 chars). Existing-aggregate mutations also require `If-Match-Version` (positive integer); create and privileged assignment-cycle requests are the only exceptions, while proposal submission carries `expectedTargetVersion` in its body. Body workspace fields are forbidden. Tenant denial follows one 404/403 policy without foreign existence detail. + +```ts +interface SuccessEnvelopeV1 { + contractVersion: '1.0.0'; + data: T; + aggregateRevision: string; + correlationId: string; +} +interface ListEnvelopeV1 extends SuccessEnvelopeV1 { + page: { cursor: string | null; nextCursor: string | null; limit: number }; +} +``` + +Errors are the exact health/transport/version unions in §2 plus validation/auth/not-found. Public DTOs never expose/accept internal write proof. + +### 7.2 Exact route registry + +| Method/path | Request body/query | Success data | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | +| `GET /kanban-health` | none | `KanbanHealthResponseV1` | +| `GET /projects` | `status,ownerUserId,ownerTeamId,cursor,limit` | project list | +| `POST /projects` | `name,key,description,status,priority,ownerUserId XOR ownerTeamId,metadata` | project | +| `GET /projects/:projectId` | none | project | +| `PATCH /projects/:projectId` | editable create fields + expected header | project | +| `POST /projects/:projectId/archive` | `reason` | project | +| `GET /tasks` | `projectId,missionId,milestoneId,status,priority,ownerUserId,ownerTeamId,specialistRole,tag,dueState,archived,cursor,limit` | task summary list | +| `POST /tasks` | `projectId,missionId?,milestoneId?,parentTaskId?,title,description?,acceptanceCriteria[],status,priority,rank,ownerUserId XOR ownerTeamId,specialistRole?,dueAt?,notBeforeAt?,estimateMinutes?,retryPolicy?,tagIds[],metadata` | task detail | +| `GET /tasks/:taskId` | none | task detail including readiness/dependencies/assignment/lease/events | +| `PATCH /tasks/:taskId` | editable non-transition fields | task detail | +| `POST /tasks/:taskId/transition` | `toStatus,reason?` | task detail | +| `POST /tasks/:taskId/move` | `toStatus?,beforeTaskId?,afterTaskId?` | task detail with persisted rank | +| `POST /tasks/:taskId/archive` | `reason` | task detail | +| `PUT /tasks/:taskId/tags` | `tagIds[]` | task detail | +| `POST /tasks/:taskId/dependencies` | `predecessorTaskId,type` | dependency | +| `DELETE /tasks/:taskId/dependencies/:predecessorTaskId` | no body | deleted dependency ID | +| `GET /tasks/:taskId/events` | `cursor,limit` | event list | +| `GET /tags` | `query,cursor,limit` | tag list | +| `POST /tags` | `name,color?` | tag | +| `GET /change-proposals` | `state,targetType,targetId,cursor,limit` | proposal list | +| `POST /change-proposals` | `sourceNoteDigest,targetType,targetId,expectedTargetVersion,commandType,commandPayload` | inert proposal | +| `GET /change-proposals/:proposalId` | none | proposal | +| `POST /change-proposals/:proposalId/accept` | `reason` | proposal + normal command result | +| `POST /change-proposals/:proposalId/reject` | `reason` | proposal | +| `GET /coordinator/eligibility` | `projectId?,missionId?,cursor,limit` | `EligibilityDecisionV1[]` | +| `POST /coordinator/assignment-cycles` | `limit` | assignment proposals; privileged internal | +| `POST /coordinator/assignments/:assignmentId/approve` | `decision,reason,policyRevision,artifactIds[]` | approval decision | +| `POST /coordinator/leases/acquire` | `taskId,assignmentId,approvalDecisionId,targetSessionId,leaseTtlSeconds` | lease with decimal-string fence | +| `POST /coordinator/leases/:leaseId/ack` | `taskId,sessionId,fencingToken` | lease | +| `POST /coordinator/leases/:leaseId/heartbeat` | `taskId,sessionId,fencingToken,extendSeconds` | lease | +| `POST /coordinator/leases/:leaseId/checkpoints` | `taskId,sessionId,fencingToken,sequence,resumableSummary,artifactIds[],contextUsagePercent` | checkpoint | +| `POST /coordinator/leases/:leaseId/submit-review` | `taskId,sessionId,fencingToken,artifactIds[],summary` | task in `in_review` | + +All Coordinator mutations except human approval are service-identity-only. Generic task PATCH cannot perform claim/heartbeat/checkpoint/review/certification/completion shortcuts. Completion after certification uses a separately gated lifecycle command owned by the Portfolio/Sub-Orchestrator flow, not the Coordinator. + +### 7.3 DTO invariants + +Task summary/detail use exact schema vocabularies, owner union, `version: number`, `fencingCounter: string`, explicit `archivedAt/by/reason`, normalized tags, computed readiness, and separate assignment/lease. Assignment DTO includes one persisted ID, task/version, exact principal/agent/session, role, state, expiry, policy, proposer/reason. Lease/checkpoint DTOs serialize every fence as decimal string. Proposal DTO exposes no hidden write authority. + +### 7.4 MCP ownership and mapping + +coder3 exclusively owns: + +- `apps/gateway/src/mcp/mcp.dto.ts` +- `mcp.controller.ts` +- `mcp.service.ts` +- `mcp.module.ts` +- `mcp.tokens.ts` +- `mcp.service.spec.ts` + +MCP tools are thin maps: `mosaic_projects_{list,get,create,update,archive}`, `mosaic_tasks_{list,get,create,update,transition,move,archive,set_tags,add_dependency,remove_dependency}`, and `mosaic_change_proposals_{list,get,submit,accept,reject}` to the exact routes above. coder4 owns CLI/projection clients only and must not edit Gateway MCP files. + +KBN-105 publishes route+DTO fixture digest before KBN-110/120/130. Every web/CLI/MCP call must match this registry and the generated client. + +## 8. Recovery contract and bounded delivery slice + +Runtime must invoke normative `validateRecoveryPostureV1`; JSON Schema alone is insufficient. It rejects unknown fields, PITR/WAL mismatch, RPO better than mechanism, unsafe storage, and weakened High-assurance. High-assurance is RPO 15m/RTO 4h, WAL ≤5m, PITR ≥35d, base ≤24h, restore test ≤30d, break-glass ≤90d, encrypted separate-failure-domain storage. + +KBN-115/coder2 owns `packages/config/src/recovery-posture.ts`, tests, and recovery runbook. It wires parser/refinement, override audit, mechanism assertions, restore test, and break-glass evidence. Any deployment manifest is separately enumerated and Mos-serialized. Recovery config has no SOT/gate/Coordinator authority fields. + +## 9. Integration, security, and hold + +Required release evidence includes KBN-101 foundation role/schema-boundary and post-KBN-100 real immutable-operation deployed-role certificates (not synthetic roles), empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority. + +### 9.1 SI-001 amendment gate and #757 boundary + +- KBN-100 must provide the §5.2 candidate-key ordering, duplicate-feasibility, exact-FK reconciliation, empty/prod/N-1/rollback, and two-child foreign-workspace evidence before SI-001 can be certified closed. +- All prior KCR-001–016 decisions and fixed SOT/tenant/authority, proposal-audit, approval, task-fencing, immutability, and no-cascade invariants remain unchanged. +- Read-only PR #757 cross-check: its logical-agent connector lease/CAS fencing uses separate runtime tables/contracts and `lease_epoch`; rc.4 changes only the frozen `missions` candidate key. There is no shared table, index, FK, identity, fence, or authority semantic to consume or reconcile, and #757 remains owned by its existing lane. + +The build hold remains active until independent re-review reports GO for KCR-001–016 and the rc.4 SI-001 amendment. Mos alone releases waves and serializes integration roots. diff --git a/docs/native-kanban-sot/TASKS.md b/docs/native-kanban-sot/TASKS.md new file mode 100644 index 00000000..9c2c2dae --- /dev/null +++ b/docs/native-kanban-sot/TASKS.md @@ -0,0 +1,278 @@ +# Native Kanban/SOT P0–P3 — Dependency-Ordered Build Slices + +**Status:** CANON INDEPENDENTLY APPROVED; PUBLICATION IN PROGRESS +**Tracking:** [Mosaic Stack issue #751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751) +**Execution:** USC web1 only; collision-free GPT coder2/3/4/5 lanes +**Contract:** `SHARED-CONTRACT.md` + four `contracts/*.v1.ts` files +**Implementation hold:** no feature slice starts until the canon PR is merged to `main` with terminal-green CI; after merge, each slice remains held until every declared KBN prerequisite is complete. + +> This publication file is not a runtime task authority. After cutover, repository `TASKS.md` is generated read-only and never imported. + +## Execution invariants + +- PostgreSQL is the sole writable SOT; current-main Drizzle is the persistence foundation. +- Mutations require fresh internal PostgreSQL transaction-local write proof and fail closed otherwise. +- Public health DTOs, Valkey, files, browser state, providers, and outage notes cannot authorize writes. +- Outage notes return only through attributable `change_proposals`; proposal acceptance executes the normal command. +- Mechanical Coordinator is non-LLM and cannot invent scope, waive gates, certify, or merge. +- Certifier is final independent gate with no merge authority. +- Workspace is the hard tenant. Project hierarchy is project-congruent. Assignment, approval, lease, fence, checkpoint, and evidence are relationally bound. +- Recovery tiers change recovery posture only. + +## 1. Collision-free ownership + +| USC lane | Exclusive ownership | Must not edit | +| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| **coder2 — schema/recovery** | `packages/db/src/schema.ts`; `packages/db/drizzle/**`; DB tests; `packages/config/src/recovery-posture.ts`; `packages/config/src/recovery-posture.spec.ts`; `docs/runbooks/kanban-postgres-recovery.md` | Gateway, Brain repositories, Coordinator, web, CLI/importer | +| **coder3 — domain/Gateway/MCP server** | Kanban repositories under `packages/brain/src/`; Gateway workspace/project/mission/milestone/task/kanban/health/coord modules; **exact MCP files:** `apps/gateway/src/mcp/mcp.dto.ts`, `mcp.controller.ts`, `mcp.service.ts`, `mcp.module.ts`, `mcp.tokens.ts`, `mcp.service.spec.ts`; Gateway root wiring/tests | DB schema/migrations, `packages/coord`, web, CLI/importer | +| **coder4 — CLI → pure Coordinator → migration tooling** | In this one fixed lane order: KBN-120 (`packages/mosaic` CLI/projection) → KBN-200 (`packages/coord/src/mechanical/**`) → KBN-300/320 (`scripts/kanban-migration/**`) | DB, Gateway/MCP server, web | +| **coder5 — web** | `apps/web/src/app/(dashboard)/{tasks,projects}/**`; `apps/web/src/components/{tasks,projects}/**`; Kanban web API/types; later Coordinator/migration-review routes | DB, Gateway, Coordinator, CLI/importer | +| **Mos — publication/integration** | Contract amendments, exact endpoint registry publication, serialized root exports/manifests/lockfiles, integration gates | Active lane feature files | + +Shared roots, package exports/manifests, lockfiles, and generated artifacts are integration-serialized. Contract changes stop affected lanes and require Mos approval. + +## 2. Parallelization legend + +- **SERIAL:** prerequisite must be complete and reviewed. +- **PARALLEL-GROUP:** disjoint files and exact frozen contract permit concurrent work. +- **LANE-SERIAL:** one lane's stated order cannot change. +- **INTEGRATION-SERIAL:** component heads green first; semantic findings return to owner. + +## 3. Corrected dependency graph + +```text +KBN-000 canon remediation + -> KBN-010 threat/auth/constraint-impact gate (MUST COMPLETE) + -> KBN-101 foundation role/schema-boundary certificate (SERIAL) + -> KBN-100 schema + concrete N-1 migration implementation + ├─ KBN-101 post-KBN-100 deployed-role immutable-operation certificate (SERIAL) + │ -> KBN-105 exact endpoint/DTO/error/registry freeze (SERIAL) + │ ├─ KBN-110 domain + Gateway + MCP server implementation + │ ├─ KBN-120 CLI/projection implementation [coder4 first] + │ └─ KBN-130 web MVP implementation + └─ KBN-115 recovery parser/mechanism slice [coder2 lane-serial] +KBN-110 + KBN-120 + KBN-130 + KBN-115 + -> KBN-140 P1 integration/SIT + -> KBN-200 pure decision engine [coder4 after KBN-120] + -> KBN-210 persistence/service adapter + approval/lease binding + -> KBN-220 Coordinator operations UI + -> KBN-230 P2 concurrency/fault/gate integration +KBN-230 + -> KBN-300 importer dry-run/apply/verify [coder4 after KBN-200] + ├─ KBN-310 migration reviewer UI + └─ KBN-320 cutover/rollback tooling [coder4 after KBN-300] +KBN-310 + KBN-320 + -> KBN-330 rehearsal/reconciliation + -> KBN-340 owner-gated cutover/stabilization +``` + +No consumer implementation begins before KBN-105. No schema work begins before KBN-010 completes and the KBN-101 foundation role/schema-boundary certificate passes; the real immutable-operation certificate follows KBN-100 and blocks KBN-105. The coder4 order is always KBN-120 → KBN-200 → KBN-300 → KBN-320. + +## 4. P0 — Canon, threat gate, schema, and exact API freeze + +### KBN-000 — Remediate and publish canon + +- **Status:** COMPLETE — PR #752 squash-merged as `49e8a54`; issue #751 closed; post-merge pipeline #1798 terminal success. +- **Owner:** Mosaic publication control plane. +- **Mode:** SERIAL; completed. +- **IN:** Resolve KCR-001–016 in requirements, schema, health, Coordinator, recovery, migration map, and slices; independent re-review. +- **OUT:** Feature implementation. +- **Depends on:** none. +- **Contract surfaces:** all canon. +- **Evidence:** strict TS; Prettier; per-finding traceability; independent author≠reviewer GO; Ultron GO; terminal-green CI. + +### KBN-010 — Threat, authorization, and constraint-impact gate + +- **Status:** IN PROGRESS — issue [#753](https://git.mosaicstack.dev/mosaicstack/stack/issues/753). +- **Owner:** `kbn-coder3`; independent `secrev`. +- **Mode:** SERIAL prerequisite of KBN-100. +- **Exclusive files:** `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` and task scratchpad only. +- **IN:** Cross-workspace owners/principals/evidence; active membership; stale/forged health; approval forgery; fence monotonicity; audit retention; proposal target/audit-event forgery; service tokens; DB/Valkey outage. +- **OUT:** Runtime/schema edits. +- **Depends on:** KBN-000 independent re-review GO. +- **Contract surfaces:** schema constraints, health proof, exact errors, command-family authorization. +- **Evidence:** signed constraint-impact matrix; no unresolved schema-impact finding; SecReview pass. + +### KBN-101 — PostgreSQL runtime/migration role split and deployed-role certification + +- **Status:** IN PROGRESS — issue [#771](https://git.mosaicstack.dev/mosaicstack/stack/issues/771); rc.16 closes HIGH-1 current generic storage-wrapper authority: README/user-guide remove `storage migrate --run` guidance and false runner delegation; current source is direct-Drizzle, legacy N-1, uncertified, non-operative, and forbidden pending -02/-03/-06/-08 activation. The -06 fixture fails both exact former forms before inventory/status masking and source-consistency rejects current direct-Drizzle wrapper as runner delegation. It awaits independent exact-head re-review; implementation remains held. +- **Owner:** Mos integration control plane; independently reviewed by security/Ultron. +- **Mode:** SERIAL foundation certificate blocks KBN-100; its post-KBN-100 real immutable-operation certificate blocks KBN-105. +- **IN:** Exact `DATABASE_URL` non-owner runtime versus `DATABASE_MIGRATION_URL` owner/migrator connection contract; sole published `mosaic-db-migrator --run|--verify` PostgreSQL DDL path and all legacy/future entrypoint closure; active migrate-tier destination only after runner prepare/verify through exact `--target-url-file /run/secrets/mosaic-migrate-target-url`, paired authenticated provider-version file, and signed `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`; runner-only signing key/public-key isolation; canonical Vault KV-v2 target URL/version, generation-pinned renderer, importer CA/public-key/attestation plus privileged sealed producer-to-importer handoff, safe-fd/consumer-isolation/no-log-oracle, TLS/server/database/role/manifest/schema binding, expiry/replay/provider-rotation/TOCTOU/no-DML controls, and dedicated non-DDL importer; finite exact-path scanner/allowlist/active-route review plus unsuppressible automatic-startup/init/Compose-before-runner semantic negatives and every-path before-connect denial matrix; `DATABASE_TLS_CA_CERT_PATH` plus operator/IaC CA/server-key/cert lifecycle, exact service-DNS SANs, Vault/compose/Swarm mount modes, TLS server/bootstrap/rotation/rollback; PGlite exception; fixed two-int advisory lock; manifest-v1 logical-index/tag/exact-byte-SHA-256 ledger reconciliation including safe `0009`; fixed `mosaic` schema and exact `pg_catalog,mosaic` pooled session path; platform/schema/`NOLOGIN SUPERUSER` extension-owner/migrator/importer/runtime roles; approved-owner versus legacy-owner shadow pgvector transition; ownership, zero membership/no runtime secret, TEMP/ledger-read/default privilege and immutable grant proof; N-1 inactive prepared cards then atomic activation/rollback authority; Vault/redaction/observability/operator runbooks; one-card/one-PR implementation DAG. +- **OUT:** Production mutation in this planning card; KBN-100 tables/data backfill; application API behavior; KBN-105 route/DTO freeze. +- **Depends on:** KBN-010 completed. +- **Contract surfaces:** [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md); `SHARED-CONTRACT.md` rc.15 amendment. +- **Evidence:** foundation: exact `--help|--run|--verify`/exit/argv/import-negative plus DTO entrypoint negatives for every finite classified current DDL/static-bypass path (including `DATABASE_URL`-only, runner fixture, retired init, sanitized current operator guidance, both harness pairs, and `db:push` refusal); active migrate-tier paired URL/version/attestation files, signing/public-key isolation, canonical Vault KV-v2 authenticated version, generation-pinned renderer, importer CA, safe fd/TOCTOU/consumer-isolation/no-log-oracle, atomic JCS/Ed25519, digest/TLS/server/database/role/manifest/schema binding, expiry/replay/provider rotation/revocation, zero-connection versus zero-DML, prepared-target/importer/no-DDL negatives; clean/pre-0009/skipped/applied-late/duplicate/unknown/missing/corrupt/stale/backup plus public-to-`mosaic`/partial/reverse runner proof; fixed-lock contention/crash/readiness/unrelated-key tests; runtime cannot invoke migrations/DDL/TEMP; actual pgvector 0.8.2 control metadata, fresh/approved-owner existing/legacy-owner shadow/partial-resume-rollback/N-1 pgvector evidence with `rolcanlogin=false`, `rolsuper=true`, zero members, external-superuser `SET ROLE`/`RESET ROLE` audit, `pg_extension.extowner`, owner-bearing member/schema/version and runtime/migrator/schema-owner/importer/all-service-role `SET ROLE`/ALTER/DROP/member-update denial; disposable standalone, federated/Swarm, and two-gateway verified-TLS positives plus both-pair CA/SAN/downgrade/key mode/UID-GID/URL-secret consumer-isolation and legacy-drain/`hostssl` zero-plaintext negatives; exclusive bootstrap/renderer/manifest ownership test; catalog relocation/vector-query/operator/Drizzle-only-`mosaic`, role/grant/search-path/pool-reset/identifier checks; N-1/atomic TLS-only rollback/no-force-on-red rehearsal; named Vault/bootstrap-control-plane/CA-overlap/redaction/operator evidence; independent author≠reviewer security GO. Post-KBN-100: real deployed non-owner INSERT/SELECT and UPDATE/DELETE denial for immutable event/artifact/evidence relations plus Ultron GO. + +### KBN-100 — Unified Drizzle schema and concrete N-1 migration + +- **Owner:** **coder2**. +- **Mode:** SERIAL. +- **Exclusive files:** `packages/db/src/schema.ts`, `packages/db/drizzle/**`, DB tests. +- **IN:** All frozen tables/joins/enums; workspace/project-congruent constraints; owners/principals; tags/archive; change proposals with both workspace-aware task-event composite FKs and frozen event-before-proposal DDL order; assignment approvals; durable execution/quarantine; monotonic bigint fence; exact checkpoint/evidence joins; RESTRICT/immutability; concrete current-main expand/backfill/switch/contract map. +- **OUT:** Repositories, Gateway, Coordinator behavior, UI, importer. +- **Depends on:** **KBN-010 completed and KBN-101 foundation role/schema-boundary certificate PASS**. KBN-100 is blocked until both are terminal; it rebases on KBN-101 main, restores generated Drizzle declaration/snapshot/journal consistency, and confines procedural immutable-table grant/trigger/backfill work to its schema ownership. Its new relations are then subject to KBN-101 post-KBN-100 deployed-role certification. +- **Contract surfaces:** `kanban-schema.v1.ts`; SHARED-CONTRACT current-main delta map. +- **Evidence:** reviewed SQL; empty/prod-shape/partial-resume/rollback tests; N-1 app safety; legacy columns remain declared; workspace/project mismatch negatives; proposal event-FK missing/foreign-workspace tests; one active lease; monotonic fence; parent-delete RESTRICT; immutability privileges; SecReview. + +### KBN-105 — Exact Gateway/MCP endpoint, DTO, and error freeze + +- **Owner:** Mos + coder3 contract author; independent endpoint-alignment reviewer. +- **Mode:** SERIAL after KBN-100; prerequisite for KBN-110/120/130. +- **Exclusive files:** canonical endpoint-registry/DTO contract docs; no implementation. +- **IN:** Exact routes and methods from SHARED-CONTRACT §8; request/success/error fields; status codes; pagination/filter/revision envelopes; idempotency/expected-version headers/fields; proposal commands; health proof exclusion from public DTOs; MCP tool-to-route map. +- **OUT:** Controller/service/client implementation. +- **Depends on:** KBN-100 and KBN-101 post-KBN-100 deployed-role immutable-operation certification PASS. +- **Contract surfaces:** health/error unions; schema IDs/statuses; Gateway DTO freeze. +- **Evidence:** every FE/CLI/MCP call maps 1:1 to a route; 503/502-504/409 non-cross-map fixtures; contract digest published. + +### KBN-115 — Recovery posture parser, mechanisms, and evidence + +- **Owner:** **coder2**, lane-serial after KBN-100. +- **Mode:** PARALLEL with KBN-110/120/130 after KBN-105. +- **Exclusive files:** `packages/config/src/recovery-posture.ts`, `.spec.ts`, `docs/runbooks/kanban-postgres-recovery.md`; deployment-specific backup manifest changes are a separately enumerated Mos integration patch. +- **IN:** Wire normative `validateRecoveryPostureV1`; override audit; backup/WAL/PITR mechanism assertions; off-cluster encryption/failure-domain checks; restore and break-glass evidence procedure. +- **OUT:** SOT/gate/Coordinator policy knobs; DB business schema. +- **Depends on:** KBN-100, KBN-105. +- **Contract surfaces:** `recovery-posture.v1.ts` only. +- **Evidence:** impossible-combination tests; High-assurance weakening tests; selected-tier mechanism verification; restore and break-glass evidence; SecReview. + +## 5. P1 — Thin native MVP + +### KBN-110 — Workspace-safe domain, Gateway, MCP server, and proposal commands + +- **Owner:** **coder3**. +- **Mode:** PARALLEL-GROUP P1-A after KBN-105. +- **Exclusive files:** ownership map, including all exact MCP server files listed there. +- **IN:** Workspace-safe repositories; project/task/dependency/tag/archive CRUD; transitions; exact owners; assignment/approval/link/artifact queries; submit/query/accept/reject change proposals; health endpoint; internal write-proof mint/revalidation; event/outbox atomicity; frozen DTOs/routes. +- **OUT:** Scheduling algorithm, web, CLI, DB schema. +- **Depends on:** KBN-100, KBN-105. +- **Contract surfaces:** all four TypeScript contracts and exact registry. +- **Evidence:** DTO/service/controller/integration tests; active-membership and no-oracle negatives; proposal cannot mutate directly; submission event is the new proposal's exact `change_proposal.submitted` event; acceptance links the executed normal command for the locked proposal and same workspace/target; missing, foreign-workspace, unrelated-proposal/target/command event negatives; exact failure mapping; endpoint registry; SecReview. + +### KBN-120 — CLI, MCP client mapping, and generated projection + +- **Owner:** **coder4**; first coder4 slice. +- **Mode:** PARALLEL-GROUP P1-A after KBN-105. +- **Exclusive files:** `packages/mosaic/src/commands/{kanban,tasks,projects}.ts`; `packages/mosaic/src/projections/**`; tests. **No `apps/gateway/src/mcp/**` edits.\*\* +- **IN:** Frozen query/mutation routes; proposal commands; compact context; generated `TASKS.md`; deliberate denial/transport/conflict handling. +- **OUT:** Gateway/MCP server, file importer, raw SQL/Valkey, Coordinator. +- **Depends on:** KBN-105; runtime integration later requires KBN-110. +- **Evidence:** contract fixtures; same revision; no import parser; same idempotency key on transport retry; 503 never auto-retried. + +### KBN-130 — Writable Kanban/List and minimal Projects UI + +- **Owner:** **coder5**. +- **Mode:** PARALLEL-GROUP P1-A after KBN-105. +- **Exclusive files:** web ownership map. +- **IN:** Workspace context; projects; tasks; tags; explicit archive; detail; accessible move/reorder; filters; dependency/readiness; owner/assignment/lease; audit; proposal visibility; conflict/loading/error/reconnect. +- **OUT:** Gateway/schema, Coordinator operations UI, migration UI. +- **Depends on:** KBN-105; runtime integration later requires KBN-110. +- **Evidence:** frozen contract mocks; real-Gateway journeys; keyboard/non-drag; tags/archive semantics; no-oracle tenant negatives; 503/transport/409 distinct UI. + +### KBN-140 — P1 integration and situational gate + +- **Owner:** Mos integration; independent reviewer/SecReview/Certifier. +- **Mode:** INTEGRATION-SERIAL. +- **IN:** KBN-110/120/130/115; unavoidable root exports only. +- **OUT:** P2 behavior. +- **Depends on:** KBN-110, KBN-120, KBN-130, KBN-115. +- **Evidence:** clean migration; web/CLI/MCP/projection revision parity; forged/expired health negatives; change-proposal event-chain success plus missing/foreign/unrelated-event negatives; tag/archive; tenant negatives; endpoint registry; author-independent review; Certifier pass. + +## 6. P2 — Mechanical Coordinator + +### KBN-200 — Pure deterministic decision engine + +- **Owner:** **coder4**; second coder4 slice, strictly after KBN-120. +- **Mode:** SERIAL in coder4 lane. +- **Exclusive files:** `packages/coord/src/mechanical/**` and pure tests. +- **IN:** `MechanicalCoordinatorDecisionEngineV1`; complete immutable snapshots; eligibility/explanation; fairness/order; capability matching; expiry/retry/quarantine decisions. +- **OUT:** ID loading, PostgreSQL, Drizzle, Gateway, Valkey, health-proof minting, persistence, `recoverFromPostgres`, LLM calls. +- **Depends on:** KBN-140 (or Mos may release after KBN-120 + frozen types if no P1 semantic risk remains). +- **Evidence:** deterministic/property tests; snapshot completeness; no I/O/model imports; no authority methods. + +### KBN-210 — Coordinator persistence/service adapter and approval-bound leases + +- **Owner:** **coder3**. +- **Mode:** SERIAL after KBN-200. +- **Exclusive files:** Gateway `coord` and repositories. +- **IN:** `MechanicalCoordinatorServicePortV1`; snapshot loading; proposal persistence; manual/versioned policy approval; acquire by IDs; reload+lock task/assignment/approval/session; fresh txn-local write proof; atomic task fence increment; lease/ack/heartbeat/checkpoint/submit; durable retry/quarantine; outbox/Valkey wake; restart recovery. +- **OUT:** Pure algorithm, UI, DB schema. +- **Depends on:** KBN-110, KBN-200. +- **Evidence:** forged/stale approval rejection; target/session/version/expiry/policy checks; concurrent monotonic fences; same-workspace mismatch negatives; bigint precision; stale worker rejection; DB/Valkey faults; SecReview. + +### KBN-220 — Coordinator operations UI + +- **Owner:** **coder5**. +- **Mode:** after KBN-210 exact DTO freeze. +- **IN:** Roster; eligibility; persisted assignment state; approvals/overrides; exact lease/fence; durable retry/quarantine; role/gate/Certifier visibility. +- **OUT:** Scheduling decisions, schema, merge control for Certifier. +- **Depends on:** KBN-210. +- **Evidence:** authorized journeys; reason required; stale refresh; no Certifier merge; endpoint alignment/accessibility. + +### KBN-230 — P2 concurrency/fault/gate integration + +- **Owner:** Mos integration; independent reviewer/SecReview/Certifier. +- **Mode:** INTEGRATION-SERIAL. +- **Depends on:** KBN-200, KBN-210, KBN-220. +- **Evidence:** one lease; monotonic fences; exact relational mismatches rejected; expired proof; forged healthy; approval binding; restart; durable quarantine; outbox recovery; author≠reviewer; Certifier final/no merge. + +## 7. P3 — Shadow migration and cutover + +### KBN-300 — One-way importer dry-run/apply/verify + +- **Owner:** **coder4**; third coder4 slice. +- **Mode:** after KBN-230. +- **Exclusive files:** `scripts/kanban-migration/import/**`. +- **IN:** Immutable jarvis-brain/Vikunja snapshots; deterministic mapping; source digest/lineage; Gateway writes; rejects; no dispatch. +- **OUT:** Bidirectional sync, direct DB/file canonical writes, unrelated brain data. +- **Depends on:** KBN-230. +- **Evidence:** idempotency; counts/fields; malformed/foreign rejects; no dispatch; SecReview. + +### KBN-310 — Shadow reviewer UI + +- **Owner:** **coder5**. +- **Mode:** PARALLEL-GROUP P3-A after KBN-300 report freeze. +- **IN:** Read-only counts/diffs/rejects/lineage/sign-off. +- **OUT:** Apply/cutover mutations. +- **Depends on:** KBN-300. +- **Evidence:** read-only and tenant tests; pagination/accessibility. + +### KBN-320 — Cutover/rollback tooling + +- **Owner:** **coder4**; fourth coder4 slice, after KBN-300. +- **Mode:** PARALLEL-GROUP P3-A with KBN-310. +- **Exclusive files:** `scripts/kanban-migration/cutover/**`. +- **IN:** Freeze assertion; backup/checksum; final delta; client switch; legacy writer/credential shutdown; rollback delta; stabilization. +- **OUT:** Destructive deletion, reverse sync, ungated production execution. +- **Depends on:** KBN-300. +- **Evidence:** fail-safe rehearsal; no dual writer; rollback authority; SecReview. + +### KBN-330 — Migration rehearsal/reconciliation + +- **Owner:** Mos + coder4 support + independent data reviewer. +- **Mode:** INTEGRATION-SERIAL. +- **Depends on:** KBN-310, KBN-320. +- **Evidence:** signed exceptions; selected-tier restore; backlog hold; no legacy changes; Certifier readiness. + +### KBN-340 — Final cutover/stabilization + +- **Owner:** Mos/control plane; owner-gated operation. +- **Mode:** SERIAL. +- **Depends on:** KBN-330 PASS and Jason authorization. +- **Evidence:** no legacy writer; scoped Gateway identities; no accidental dispatch; terminal green health/CI; Certifier evidence; owner retirement approval. + +## 8. Consistent USC wave schedule + +| Wave | coder2 | coder3 | coder4 | coder5 | +| ---- | ----------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------ | ------------------------------ | +| 0 | Wait | **KBN-010** | Wait | Wait | +| 0.5 | Wait | **KBN-101 foundation** Mos-controlled role/connection contract and certificate | Wait | Wait | +| 1 | **KBN-100** after KBN-101 foundation PASS | Review bounded schema/grant implementation | Wait | Wait | +| 1.5 | Certification support | **KBN-101 post-KBN-100 deployed-role immutable-operation certificate**, then KBN-105 | Wait | Wait | +| 2 | **KBN-115** after KBN-100 | **KBN-105** exact freeze, then KBN-110 | **KBN-120** only after KBN-105 | **KBN-130** only after KBN-105 | +| 3 | Review support | Finish KBN-110 | **KBN-200 after KBN-120** | Finish KBN-130 | +| 4 | — | **KBN-210 after KBN-200** | Review/support | **KBN-220 after KBN-210 DTOs** | +| 5 | — | P2 remediation | **KBN-300 then KBN-320** | **KBN-310** | + +Mos alone releases slices and lifts the build hold after independent re-review GO. diff --git a/docs/native-kanban-sot/contracts/health-state.v1.ts b/docs/native-kanban-sot/contracts/health-state.v1.ts new file mode 100644 index 00000000..2deaa0f7 --- /dev/null +++ b/docs/native-kanban-sot/contracts/health-state.v1.ts @@ -0,0 +1,206 @@ +/** + * Mosaic Native Kanban — frozen health/error contract v1. + * Publication contract only; no runtime implementation is included here. + * + * PostgreSQL is the sole writable SOT. Public health DTOs are observations, + * never write authority. Only an internal transaction-local proof produced by + * the PostgreSQL adapter may authorize a mutation. + */ + +export const KANBAN_CONTRACT_VERSION = '1.0.0' as const; + +export const kanbanHealthStates = ['healthy', 'read-only-degraded', 'write-unavailable'] as const; +export type KanbanHealthState = (typeof kanbanHealthStates)[number]; + +interface KanbanHealthBaseV1 { + contractVersion: typeof KANBAN_CONTRACT_VERSION; + checkedAt: string; + /** Observation expires at this RFC 3339 instant; it still never authorizes writes. */ + validUntil: string; + policyRevision: string; + reasons: string[]; +} + +export interface HealthyKanbanHealthResponseV1 extends KanbanHealthBaseV1 { + state: 'healthy'; + readHealthProven: true; + writeHealthProven: true; +} + +export interface ReadOnlyDegradedKanbanHealthResponseV1 extends KanbanHealthBaseV1 { + state: 'read-only-degraded'; + readHealthProven: true; + writeHealthProven: false; +} + +export interface WriteUnavailableKanbanHealthResponseV1 extends KanbanHealthBaseV1 { + state: 'write-unavailable'; + readHealthProven: false; + writeHealthProven: false; +} + +/** Public, discriminated observation. Contradictory combinations are unrepresentable. */ +export type KanbanHealthResponseV1 = + | HealthyKanbanHealthResponseV1 + | ReadOnlyDegradedKanbanHealthResponseV1 + | WriteUnavailableKanbanHealthResponseV1; + +/** Pure evaluation context. It cannot authorize a mutation. */ +export interface KanbanEvaluationContextV1 { + contractVersion: typeof KANBAN_CONTRACT_VERSION; + workspaceId: string; + correlationId: string; + now: string; + policyRevision: string; + observedHealth: KanbanHealthResponseV1; +} + +/** + * Non-exported brand: public DTO deserialization cannot construct this type. + * The PostgreSQL adapter mints it only after a fresh write probe inside the same + * transaction and validates checkedAt <= now < validUntil and policy revision. + */ +declare const postgresWriteHealthProofBrand: unique symbol; +export interface PostgresWriteHealthProofV1 { + readonly [postgresWriteHealthProofBrand]: true; + readonly source: 'postgres-transaction-local-write-probe'; + readonly transactionId: string; + readonly checkedAt: string; + readonly validUntil: string; + readonly policyRevision: string; +} + +/** Internal mutation context; MUST NOT appear in REST/MCP/CLI request DTOs. */ +export interface InternalKanbanMutationContextV1 { + contractVersion: typeof KANBAN_CONTRACT_VERSION; + workspaceId: string; + correlationId: string; + causationId?: string; + idempotencyKey: string; + now: string; + expectedPolicyRevision: string; + writeProof: PostgresWriteHealthProofV1; +} + +interface MutationFailureBaseV1 { + contractVersion: typeof KANBAN_CONTRACT_VERSION; + retryable: false; + requestOutcome: 'not_applied'; + idempotencyKey: string; + correlationId: string; + message: string; +} + +/** KCR-016: code/state pairing is exact and cannot cross-map. */ +export interface ReadOnlyWriteHealthDenialV1 extends MutationFailureBaseV1 { + kind: 'deliberate_fail_closed_denial'; + code: 'KANBAN_WRITE_HEALTH_UNPROVEN'; + healthState: 'read-only-degraded'; + checkedAt: string; +} + +export interface WriteUnavailableDenialV1 extends MutationFailureBaseV1 { + kind: 'deliberate_fail_closed_denial'; + code: 'KANBAN_WRITE_UNAVAILABLE'; + healthState: 'write-unavailable'; + checkedAt: string; +} + +export type DeliberateWriteDenialV1 = ReadOnlyWriteHealthDenialV1 | WriteUnavailableDenialV1; + +export const transportErrorCodes = [ + 'GATEWAY_UNREACHABLE', + 'GATEWAY_TIMEOUT', + 'UPSTREAM_BAD_GATEWAY', +] as const; +export type TransportErrorCode = (typeof transportErrorCodes)[number]; + +/** Client-normalized transport uncertainty; never an authoritative 503 body. */ +export interface RetryableTransportErrorV1 { + contractVersion: typeof KANBAN_CONTRACT_VERSION; + kind: 'retryable_transport_error'; + code: TransportErrorCode; + retryable: true; + requestOutcome: 'unknown'; + /** Retry MUST reuse this exact key. */ + idempotencyKey: string; + correlationId: string; + message: string; +} + +export interface VersionConflictV1 { + contractVersion: typeof KANBAN_CONTRACT_VERSION; + kind: 'version_conflict'; + code: 'AGGREGATE_VERSION_CONFLICT'; + retryable: false; + requestOutcome: 'not_applied'; + aggregateType: 'project' | 'mission' | 'milestone' | 'task' | 'change_proposal'; + aggregateId: string; + expectedVersion: number; + actualVersion: number; + idempotencyKey: string; + correlationId: string; + message: string; +} + +export type KanbanMutationFailureV1 = + | DeliberateWriteDenialV1 + | RetryableTransportErrorV1 + | VersionConflictV1; + +export const kanbanHealthCapabilities: Readonly< + Record +> = { + healthy: { canonicalReads: true, mutations: true }, + 'read-only-degraded': { canonicalReads: true, mutations: false }, + 'write-unavailable': { canonicalReads: false, mutations: false }, +}; + +/** Exact HTTP/error normalization freeze; 503, transport, and 409 cannot cross-map. */ +export const kanbanFailureHttpMapV1 = { + KANBAN_WRITE_HEALTH_UNPROVEN: { + httpStatus: 503, + kind: 'deliberate_fail_closed_denial', + requestOutcome: 'not_applied', + retryable: false, + }, + KANBAN_WRITE_UNAVAILABLE: { + httpStatus: 503, + kind: 'deliberate_fail_closed_denial', + requestOutcome: 'not_applied', + retryable: false, + }, + AGGREGATE_VERSION_CONFLICT: { + httpStatus: 409, + kind: 'version_conflict', + requestOutcome: 'not_applied', + retryable: false, + }, + GATEWAY_UNREACHABLE: { + httpStatus: 502, + kind: 'retryable_transport_error', + requestOutcome: 'unknown', + retryable: true, + }, + GATEWAY_TIMEOUT: { + httpStatus: 504, + kind: 'retryable_transport_error', + requestOutcome: 'unknown', + retryable: true, + }, + UPSTREAM_BAD_GATEWAY: { + httpStatus: 502, + kind: 'retryable_transport_error', + requestOutcome: 'unknown', + retryable: true, + }, +} as const; + +/** + * Required negative contract tests: + * - contradictory state/proof booleans fail type/schema validation; + * - expired internal proof and policy mismatch deny before mutation; + * - Valkey-only liveness cannot mint PostgresWriteHealthProofV1; + * - public/caller-forged `healthy` cannot enter InternalKanbanMutationContextV1; + * - authoritative 503, transport 502/504/timeout, and 409 mappings are exhaustive. + */ diff --git a/docs/native-kanban-sot/contracts/kanban-schema.v1.ts b/docs/native-kanban-sot/contracts/kanban-schema.v1.ts new file mode 100644 index 00000000..5d0c3aad --- /dev/null +++ b/docs/native-kanban-sot/contracts/kanban-schema.v1.ts @@ -0,0 +1,1295 @@ +/** + * Mosaic Native Kanban — frozen Drizzle schema contract v1. + * + * This is the canonical target/compatibility declaration for integration into + * the ONE current-main packages/db/src/schema.ts. It MUST NOT be imported as a + * competing schema module. KBN-100 applies the field-by-field expand/backfill/ + * switch/contract map in SHARED-CONTRACT.md; legacy fields marked below remain + * declared throughout the expand and N-1 window. + * + * Existing Better Auth users.id is the identity parent. User references require + * active workspace membership checks in the same authoritative transaction. + */ + +import { sql } from 'drizzle-orm'; +import { + bigint, + boolean, + check, + foreignKey, + index, + integer, + jsonb, + numeric, + pgEnum, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, +} from 'drizzle-orm/pg-core'; + +// ─── Frozen vocabularies ───────────────────────────────────────────────────── + +export const workspaceLifecycleEnum = pgEnum('workspace_lifecycle', [ + 'active', + 'suspended', + 'archived', +]); +export const workspaceMemberRoleEnum = pgEnum('workspace_member_role', [ + 'owner', + 'admin', + 'member', + 'auditor', + 'service', +]); +export const teamMemberRoleEnum = pgEnum('team_member_role', ['manager', 'member']); +export const agentLifecycleEnum = pgEnum('agent_lifecycle', ['enabled', 'disabled']); +export const agentSessionStateEnum = pgEnum('agent_session_state', [ + 'starting', + 'available', + 'busy', + 'degraded', + 'offline', + 'ended', +]); +export const projectStatusEnum = pgEnum('project_status_v1', [ + 'planning', + 'active', + 'paused', + 'completed', + 'archived', +]); +export const missionStatusEnum = pgEnum('mission_status_v1', [ + 'draft', + 'awaiting_approval', + 'active', + 'paused', + 'certifying', + 'completed', + 'failed', + 'cancelled', +]); +export const milestoneStatusEnum = pgEnum('milestone_status_v1', [ + 'planned', + 'active', + 'at_risk', + 'completed', + 'cancelled', +]); +export const taskStatusEnum = pgEnum('task_status_v1', [ + 'backlog', + 'ready', + 'in_progress', + 'blocked', + 'in_review', + 'done', + 'cancelled', +]); +export const priorityEnum = pgEnum('work_priority_v1', ['critical', 'high', 'medium', 'low']); +export const specialistRoleEnum = pgEnum('specialist_role_v1', [ + 'planning', + 'enhance', + 'coder', + 'review', + 'security-review', + 'pr-monitor', + 'certifier', +]); +export const dependencyTypeEnum = pgEnum('task_dependency_type', [ + 'blocks', + 'review_gate', + 'certification_gate', +]); +/** Must match assignmentStates in mechanical-coordinator.v1.ts exactly. */ +export const assignmentStateEnum = pgEnum('task_assignment_state_v1', [ + 'awaiting_approval', + 'policy_pre_authorized', + 'approved', + 'rejected', + 'leased', + 'released', + 'expired', + 'superseded', +]); +export const leaseStateEnum = pgEnum('task_lease_state', [ + 'pending_ack', + 'active', + 'released', + 'expired', + 'revoked', +]); +export const actorKindEnum = pgEnum('actor_kind_v1', [ + 'user', + 'agent', + 'session', + 'service', + 'policy', + 'system', +]); +export const approvalDecisionEnum = pgEnum('approval_decision_v1', [ + 'requested', + 'approved', + 'rejected', + 'escalated', +]); +export const executionDispositionEnum = pgEnum('task_execution_disposition_v1', [ + 'available', + 'retry_delayed', + 'quarantined', + 'exhausted', +]); +export const changeProposalStateEnum = pgEnum('change_proposal_state_v1', [ + 'pending', + 'accepted', + 'rejected', +]); +export const outboxStateEnum = pgEnum('outbox_state_v1', [ + 'pending', + 'publishing', + 'published', + 'failed', +]); + +// ─── Tenant and identity ───────────────────────────────────────────────────── + +export const workspacesV1 = pgTable( + 'workspaces', + { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + slug: text('slug').notNull(), + settings: jsonb('settings').notNull().$type>().default({}), + lifecycle: workspaceLifecycleEnum('lifecycle').notNull().default('active'), + ownerId: text('owner_id').notNull(), // FK to existing users.id at integration + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('workspaces_slug_uidx').on(t.slug), + check('workspaces_version_positive_chk', sql`${t.version} > 0`), + ], +); + +export const workspaceMembersV1 = pgTable( + 'workspace_members', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + userId: text('user_id').notNull(), // FK to existing users.id at integration + role: workspaceMemberRoleEnum('role').notNull().default('member'), + active: boolean('active').notNull().default(true), + joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(), + revokedAt: timestamp('revoked_at', { withTimezone: true }), + }, + (t) => [ + uniqueIndex('workspace_members_workspace_user_uidx').on(t.workspaceId, t.userId), + index('workspace_members_user_active_idx').on(t.userId, t.active), + ], +); + +export const teamsV1 = pgTable( + 'teams', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + name: text('name').notNull(), + slug: text('slug').notNull(), + ownerId: text('owner_id').notNull(), // legacy/current users.id field retained + managerId: text('manager_id').notNull(), // legacy/current users.id field retained + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('teams_workspace_slug_uidx').on(t.workspaceId, t.slug), + uniqueIndex('teams_workspace_id_uidx').on(t.workspaceId, t.id), + ], +); + +export const teamMembersV1 = pgTable( + 'team_members', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + teamId: uuid('team_id').notNull(), + userId: text('user_id').notNull(), + role: teamMemberRoleEnum('role').notNull().default('member'), + invitedBy: text('invited_by'), + joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'team_members_workspace_team_fk', + columns: [t.workspaceId, t.teamId], + foreignColumns: [teamsV1.workspaceId, teamsV1.id], + }).onDelete('restrict'), + uniqueIndex('team_members_workspace_team_user_uidx').on(t.workspaceId, t.teamId, t.userId), + ], +); + +export const agentsV1 = pgTable( + 'agents', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + name: text('name').notNull(), + provider: text('provider').notNull(), // legacy retained + model: text('model').notNull(), // legacy retained + legacyStatus: text('status', { + enum: ['idle', 'active', 'error', 'offline'], + }) + .notNull() + .default('idle'), + projectId: uuid('project_id'), // legacy retained through N-1 + ownerId: text('owner_id'), // legacy retained; active membership required + systemPrompt: text('system_prompt'), // legacy retained + allowedTools: jsonb('allowed_tools').$type(), // legacy retained + skills: jsonb('skills').$type(), // legacy retained + isSystem: boolean('is_system').notNull().default(false), // legacy retained + config: jsonb('config'), // legacy retained + runtime: text('runtime').notNull(), + roles: jsonb('roles').notNull().$type().default([]), + capabilities: jsonb('capabilities').notNull().$type().default([]), + lifecycle: agentLifecycleEnum('lifecycle').notNull().default('enabled'), + metadata: jsonb('metadata').notNull().$type>().default({}), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('agents_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('agents_workspace_name_uidx').on(t.workspaceId, t.name), + index('agents_workspace_lifecycle_idx').on(t.workspaceId, t.lifecycle), + check('agents_version_positive_chk', sql`${t.version} > 0`), + ], +); + +export const agentSessionsV1 = pgTable( + 'agent_sessions', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + agentId: uuid('agent_id').notNull(), + harnessSessionKey: text('harness_session_key').notNull(), + host: text('host').notNull(), + state: agentSessionStateEnum('state').notNull().default('starting'), + declaredRoles: specialistRoleEnum('declared_primary_role'), + roleSet: jsonb('role_set').notNull().$type().default([]), + capabilities: jsonb('capabilities').notNull().$type().default([]), + capacity: integer('capacity').notNull().default(1), + contextUsagePercent: integer('context_usage_percent'), + startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(), + lastHeartbeatAt: timestamp('last_heartbeat_at', { withTimezone: true }), + endedAt: timestamp('ended_at', { withTimezone: true }), + metadata: jsonb('metadata').notNull().$type>().default({}), + }, + (t) => [ + foreignKey({ + name: 'agent_sessions_workspace_agent_fk', + columns: [t.workspaceId, t.agentId], + foreignColumns: [agentsV1.workspaceId, agentsV1.id], + }).onDelete('restrict'), + uniqueIndex('agent_sessions_workspace_harness_key_uidx').on(t.workspaceId, t.harnessSessionKey), + uniqueIndex('agent_sessions_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('agent_sessions_workspace_agent_id_uidx').on(t.workspaceId, t.agentId, t.id), + index('agent_sessions_workspace_state_heartbeat_idx').on( + t.workspaceId, + t.state, + t.lastHeartbeatAt, + ), + check('agent_sessions_capacity_positive_chk', sql`${t.capacity} > 0`), + check( + 'agent_sessions_context_percent_chk', + sql`${t.contextUsagePercent} is null or (${t.contextUsagePercent} >= 0 and ${t.contextUsagePercent} <= 100)`, + ), + ], +); + +// ─── Planning hierarchy ────────────────────────────────────────────────────── + +export const projectsV1 = pgTable( + 'projects', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + name: text('name').notNull(), + description: text('description'), + /** Legacy status remains declared/readable through the N-1 window. */ + legacyStatus: text('status', { + enum: ['active', 'paused', 'completed', 'archived'], + }) + .notNull() + .default('active'), + canonicalStatus: projectStatusEnum('canonical_status').notNull().default('planning'), + /** Legacy ownership fields retained until contract release. */ + legacyOwnerId: text('owner_id'), + legacyTeamId: uuid('team_id'), + legacyOwnerType: text('owner_type', { enum: ['user', 'team'] }) + .notNull() + .default('user'), + accountableUserId: text('accountable_user_id'), + accountableTeamId: uuid('accountable_team_id'), + priority: priorityEnum('priority').notNull().default('medium'), + repositoryUrl: text('repository_url'), + repositoryProvider: text('repository_provider'), + defaultBranch: text('default_branch'), + domain: text('domain'), + startDate: timestamp('start_date', { withTimezone: true }), + targetDate: timestamp('target_date', { withTimezone: true }), + blockerSummary: text('blocker_summary'), + progressPolicy: jsonb('progress_policy').notNull().$type>().default({}), + metadata: jsonb('metadata').notNull().$type>().default({}), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'projects_workspace_accountable_user_fk', + columns: [t.workspaceId, t.accountableUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + foreignKey({ + name: 'projects_workspace_accountable_team_fk', + columns: [t.workspaceId, t.accountableTeamId], + foreignColumns: [teamsV1.workspaceId, teamsV1.id], + }).onDelete('restrict'), + uniqueIndex('projects_workspace_id_uidx').on(t.workspaceId, t.id), + index('projects_workspace_status_idx').on(t.workspaceId, t.canonicalStatus), + check( + 'projects_exactly_one_accountable_owner_chk', + sql`num_nonnulls(${t.accountableUserId}, ${t.accountableTeamId}) = 1`, + ), + check('projects_version_positive_chk', sql`${t.version} > 0`), + ], +); + +export const milestonesV1 = pgTable( + 'milestones', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + projectId: uuid('project_id').notNull(), + name: text('name').notNull(), + description: text('description'), + status: milestoneStatusEnum('status').notNull().default('planned'), + sequence: integer('sequence').notNull(), + targetDate: timestamp('target_date', { withTimezone: true }), + completedAt: timestamp('completed_at', { withTimezone: true }), + providerMilestoneRef: text('provider_milestone_ref'), + acceptanceSummary: text('acceptance_summary'), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'milestones_workspace_project_fk', + columns: [t.workspaceId, t.projectId], + foreignColumns: [projectsV1.workspaceId, projectsV1.id], + }).onDelete('restrict'), + uniqueIndex('milestones_workspace_project_id_uidx').on(t.workspaceId, t.projectId, t.id), + uniqueIndex('milestones_project_sequence_uidx').on(t.workspaceId, t.projectId, t.sequence), + index('milestones_workspace_project_status_idx').on(t.workspaceId, t.projectId, t.status), + check('milestones_sequence_positive_chk', sql`${t.sequence} > 0`), + check('milestones_version_positive_chk', sql`${t.version} > 0`), + ], +); + +/** Avoids an unsafe circular projects.current_milestone FK during expand. */ +export const projectCurrentMilestonesV1 = pgTable( + 'project_current_milestones', + { + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + projectId: uuid('project_id').notNull(), + milestoneId: uuid('milestone_id').notNull(), + setByUserId: text('set_by_user_id').notNull(), + setAt: timestamp('set_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + primaryKey({ name: 'project_current_milestones_pk', columns: [t.workspaceId, t.projectId] }), + foreignKey({ + name: 'project_current_milestones_project_fk', + columns: [t.workspaceId, t.projectId], + foreignColumns: [projectsV1.workspaceId, projectsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'project_current_milestones_milestone_fk', + columns: [t.workspaceId, t.projectId, t.milestoneId], + foreignColumns: [milestonesV1.workspaceId, milestonesV1.projectId, milestonesV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'project_current_milestones_set_by_user_fk', + columns: [t.workspaceId, t.setByUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + ], +); + +export const missionsV1 = pgTable( + 'missions', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + projectId: uuid('project_id').notNull(), + name: text('name').notNull(), + /** Legacy fields remain through N-1 and are mapped, never dropped on expand. */ + legacyDescription: text('description'), + legacyStatus: text('status', { + enum: ['planning', 'active', 'paused', 'completed', 'failed'], + }) + .notNull() + .default('planning'), + legacyUserId: text('user_id'), + legacyMilestones: jsonb('milestones').$type[]>(), + legacyConfig: jsonb('config'), + objective: text('objective').notNull(), + canonicalStatus: missionStatusEnum('canonical_status').notNull().default('draft'), + phase: text('phase'), + prdArtifactUri: text('prd_artifact_uri'), + prdRevision: text('prd_revision'), + portfolioOrchestratorId: text('portfolio_orchestrator_id'), + projectSubOrchestratorId: text('project_sub_orchestrator_id'), + approvalPolicy: jsonb('approval_policy').notNull().$type>().default({}), + startedAt: timestamp('started_at', { withTimezone: true }), + completedAt: timestamp('completed_at', { withTimezone: true }), + metadata: jsonb('metadata').notNull().$type>().default({}), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'missions_workspace_project_fk', + columns: [t.workspaceId, t.projectId], + foreignColumns: [projectsV1.workspaceId, projectsV1.id], + }).onDelete('restrict'), + uniqueIndex('missions_workspace_project_id_uidx').on(t.workspaceId, t.projectId, t.id), + uniqueIndex('missions_workspace_id_uidx').on(t.workspaceId, t.id), + index('missions_workspace_project_status_idx').on( + t.workspaceId, + t.projectId, + t.canonicalStatus, + ), + check('missions_version_positive_chk', sql`${t.version} > 0`), + ], +); + +export const missionMilestonesV1 = pgTable( + 'mission_milestones', + { + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + projectId: uuid('project_id').notNull(), + missionId: uuid('mission_id').notNull(), + milestoneId: uuid('milestone_id').notNull(), + ordering: integer('ordering').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + primaryKey({ + name: 'mission_milestones_pk', + columns: [t.workspaceId, t.projectId, t.missionId, t.milestoneId], + }), + foreignKey({ + name: 'mission_milestones_project_mission_fk', + columns: [t.workspaceId, t.projectId, t.missionId], + foreignColumns: [missionsV1.workspaceId, missionsV1.projectId, missionsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'mission_milestones_project_milestone_fk', + columns: [t.workspaceId, t.projectId, t.milestoneId], + foreignColumns: [milestonesV1.workspaceId, milestonesV1.projectId, milestonesV1.id], + }).onDelete('restrict'), + uniqueIndex('mission_milestones_order_uidx').on( + t.workspaceId, + t.projectId, + t.missionId, + t.ordering, + ), + check('mission_milestones_order_positive_chk', sql`${t.ordering} > 0`), + ], +); + +// ─── Tasks, tags, dependencies ─────────────────────────────────────────────── + +export const tasksV1 = pgTable( + 'tasks', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + projectId: uuid('project_id').notNull(), + missionId: uuid('mission_id'), + milestoneId: uuid('milestone_id'), + parentTaskId: uuid('parent_task_id'), + title: text('title').notNull(), + description: text('description'), + /** Legacy/current-main fields retained throughout expand/N-1. */ + legacyStatus: text('status', { + enum: ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'], + }) + .notNull() + .default('not-started'), + legacyAssignee: text('assignee'), + legacyTags: jsonb('tags').$type(), + legacyDueDate: timestamp('due_date', { withTimezone: true }), + acceptanceCriteria: jsonb('acceptance_criteria') + .notNull() + .$type | string[]>() + .default([]), + canonicalStatus: taskStatusEnum('canonical_status').notNull().default('backlog'), + priority: priorityEnum('priority').notNull().default('medium'), + boardRank: numeric('board_rank', { precision: 30, scale: 15 }).notNull().default('1000'), + accountableUserId: text('accountable_user_id'), + accountableTeamId: uuid('accountable_team_id'), + assignedSpecialistRole: specialistRoleEnum('assigned_specialist_role'), + dueAt: timestamp('due_at', { withTimezone: true }), + notBeforeAt: timestamp('not_before_at', { withTimezone: true }), + estimateMinutes: integer('estimate_minutes'), + progressPercent: integer('progress_percent').notNull().default(0), + blocker: text('blocker'), + retryPolicy: jsonb('retry_policy').notNull().$type>().default({}), + /** Atomically incremented under this task row lock for every new lease. */ + fencingCounter: bigint('fencing_counter', { mode: 'bigint' }) + .notNull() + .default(sql`0`), + archivedAt: timestamp('archived_at', { withTimezone: true }), + archivedByUserId: text('archived_by_user_id'), + archiveReason: text('archive_reason'), + metadata: jsonb('metadata').notNull().$type>().default({}), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + completedAt: timestamp('completed_at', { withTimezone: true }), + }, + (t) => [ + foreignKey({ + name: 'tasks_workspace_project_fk', + columns: [t.workspaceId, t.projectId], + foreignColumns: [projectsV1.workspaceId, projectsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'tasks_project_mission_fk', + columns: [t.workspaceId, t.projectId, t.missionId], + foreignColumns: [missionsV1.workspaceId, missionsV1.projectId, missionsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'tasks_project_milestone_fk', + columns: [t.workspaceId, t.projectId, t.milestoneId], + foreignColumns: [milestonesV1.workspaceId, milestonesV1.projectId, milestonesV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'tasks_project_parent_fk', + columns: [t.workspaceId, t.projectId, t.parentTaskId], + foreignColumns: [t.workspaceId, t.projectId, t.id], + }).onDelete('restrict'), + foreignKey({ + name: 'tasks_workspace_accountable_user_fk', + columns: [t.workspaceId, t.accountableUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + foreignKey({ + name: 'tasks_workspace_archived_by_user_fk', + columns: [t.workspaceId, t.archivedByUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + foreignKey({ + name: 'tasks_workspace_accountable_team_fk', + columns: [t.workspaceId, t.accountableTeamId], + foreignColumns: [teamsV1.workspaceId, teamsV1.id], + }).onDelete('restrict'), + uniqueIndex('tasks_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('tasks_workspace_project_id_uidx').on(t.workspaceId, t.projectId, t.id), + index('tasks_workspace_project_status_rank_idx').on( + t.workspaceId, + t.projectId, + t.canonicalStatus, + t.boardRank, + ), + index('tasks_workspace_due_idx').on(t.workspaceId, t.dueAt), + check( + 'tasks_exactly_one_accountable_owner_chk', + sql`num_nonnulls(${t.accountableUserId}, ${t.accountableTeamId}) = 1`, + ), + check('tasks_version_positive_chk', sql`${t.version} > 0`), + check('tasks_fencing_counter_nonnegative_chk', sql`${t.fencingCounter} >= 0`), + check( + 'tasks_progress_percent_chk', + sql`${t.progressPercent} >= 0 and ${t.progressPercent} <= 100`, + ), + check( + 'tasks_archive_fields_chk', + sql`(${t.archivedAt} is null and ${t.archivedByUserId} is null and ${t.archiveReason} is null) or (${t.archivedAt} is not null and ${t.archivedByUserId} is not null and ${t.archiveReason} is not null)`, + ), + ], +); + +export const tagsV1 = pgTable( + 'tags', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + name: text('name').notNull(), + normalizedName: text('normalized_name').notNull(), + color: text('color'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('tags_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('tags_workspace_normalized_name_uidx').on(t.workspaceId, t.normalizedName), + ], +); + +export const taskTagsV1 = pgTable( + 'task_tags', + { + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id').notNull(), + tagId: uuid('tag_id').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + primaryKey({ name: 'task_tags_pk', columns: [t.workspaceId, t.taskId, t.tagId] }), + foreignKey({ + name: 'task_tags_workspace_task_fk', + columns: [t.workspaceId, t.taskId], + foreignColumns: [tasksV1.workspaceId, tasksV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'task_tags_workspace_tag_fk', + columns: [t.workspaceId, t.tagId], + foreignColumns: [tagsV1.workspaceId, tagsV1.id], + }).onDelete('restrict'), + ], +); + +export const taskDependenciesV1 = pgTable( + 'task_dependencies', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + predecessorTaskId: uuid('predecessor_task_id').notNull(), + successorTaskId: uuid('successor_task_id').notNull(), + dependencyType: dependencyTypeEnum('dependency_type').notNull().default('blocks'), + completionCondition: jsonb('completion_condition').$type>(), + createdByActorKind: actorKindEnum('created_by_actor_kind').notNull(), + createdByActorId: text('created_by_actor_id').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'task_dependencies_predecessor_fk', + columns: [t.workspaceId, t.predecessorTaskId], + foreignColumns: [tasksV1.workspaceId, tasksV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'task_dependencies_successor_fk', + columns: [t.workspaceId, t.successorTaskId], + foreignColumns: [tasksV1.workspaceId, tasksV1.id], + }).onDelete('restrict'), + /** One directed pair only; dependency type is an attribute, not a second edge. */ + uniqueIndex('task_dependencies_directed_edge_uidx').on( + t.workspaceId, + t.predecessorTaskId, + t.successorTaskId, + ), + index('task_dependencies_successor_idx').on(t.workspaceId, t.successorTaskId), + check( + 'task_dependencies_no_self_edge_chk', + sql`${t.predecessorTaskId} <> ${t.successorTaskId}`, + ), + ], +); + +// ─── Links, immutable artifacts, audit events, outage proposals ────────────── + +export const externalLinksV1 = pgTable( + 'external_links', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + entityType: text('entity_type', { + enum: ['project', 'mission', 'milestone', 'task'], + }).notNull(), + entityId: uuid('entity_id').notNull(), + provider: text('provider').notNull(), + linkType: text('link_type', { + enum: ['issue', 'pr', 'ci', 'document', 'release', 'deployment'], + }).notNull(), + externalId: text('external_id').notNull(), + url: text('url').notNull(), + repository: text('repository'), + syncMetadata: jsonb('sync_metadata').notNull().$type>().default({}), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('external_links_entity_provider_type_external_uidx').on( + t.workspaceId, + t.entityType, + t.entityId, + t.provider, + t.linkType, + t.externalId, + ), + index('external_links_entity_idx').on(t.workspaceId, t.entityType, t.entityId), + ], +); + +export const artifactsV1 = pgTable( + 'artifacts', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id'), + missionId: uuid('mission_id'), + type: text('type').notNull(), + uri: text('uri').notNull(), + immutableRevision: text('immutable_revision').notNull(), + digest: text('digest').notNull(), + producerActorKind: actorKindEnum('producer_actor_kind').notNull(), + producerActorId: text('producer_actor_id').notNull(), + evidenceClassification: text('evidence_classification').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'artifacts_workspace_task_fk', + columns: [t.workspaceId, t.taskId], + foreignColumns: [tasksV1.workspaceId, tasksV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'artifacts_workspace_mission_fk', + columns: [t.workspaceId, t.missionId], + foreignColumns: [missionsV1.workspaceId, missionsV1.id], + }).onDelete('restrict'), + uniqueIndex('artifacts_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('artifacts_workspace_digest_uidx').on(t.workspaceId, t.digest), + check('artifacts_exactly_one_owner_chk', sql`num_nonnulls(${t.taskId}, ${t.missionId}) = 1`), + ], +); + +export const taskEventsV1 = pgTable( + 'task_events', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + aggregateType: text('aggregate_type', { + enum: [ + 'workspace', + 'project', + 'mission', + 'milestone', + 'task', + 'assignment', + 'lease', + 'change_proposal', + ], + }).notNull(), + aggregateId: uuid('aggregate_id').notNull(), + eventType: text('event_type').notNull(), + actorKind: actorKindEnum('actor_kind').notNull(), + actorId: text('actor_id').notNull(), + correlationId: uuid('correlation_id').notNull(), + causationId: uuid('causation_id'), + idempotencyKey: text('idempotency_key').notNull(), + previousVersion: integer('previous_version'), + newVersion: integer('new_version'), + payload: jsonb('payload').notNull().$type>().default({}), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('task_events_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('task_events_workspace_idempotency_uidx').on(t.workspaceId, t.idempotencyKey), + index('task_events_aggregate_created_idx').on( + t.workspaceId, + t.aggregateType, + t.aggregateId, + t.createdAt, + ), + ], +); + +export const changeProposalsV1 = pgTable( + 'change_proposals', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + proposerUserId: text('proposer_user_id').notNull(), + sourceNoteDigest: text('source_note_digest').notNull(), + targetAggregateType: text('target_aggregate_type', { + enum: ['project', 'mission', 'milestone', 'task'], + }).notNull(), + targetAggregateId: uuid('target_aggregate_id').notNull(), + expectedAggregateVersion: integer('expected_aggregate_version').notNull(), + proposedCommand: text('proposed_command').notNull(), + proposedPayload: jsonb('proposed_payload').notNull().$type>(), + state: changeProposalStateEnum('state').notNull().default('pending'), + idempotencyKey: text('idempotency_key').notNull(), + submittedAuditEventId: uuid('submitted_audit_event_id').notNull(), + decisionActorUserId: text('decision_actor_user_id'), + decisionReason: text('decision_reason'), + decidedAt: timestamp('decided_at', { withTimezone: true }), + acceptedCommandAuditEventId: uuid('accepted_command_audit_event_id'), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'change_proposals_workspace_proposer_user_fk', + columns: [t.workspaceId, t.proposerUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + foreignKey({ + name: 'change_proposals_workspace_decision_actor_user_fk', + columns: [t.workspaceId, t.decisionActorUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + foreignKey({ + name: 'change_proposals_workspace_submitted_event_fk', + columns: [t.workspaceId, t.submittedAuditEventId], + foreignColumns: [taskEventsV1.workspaceId, taskEventsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'change_proposals_workspace_accepted_command_event_fk', + columns: [t.workspaceId, t.acceptedCommandAuditEventId], + foreignColumns: [taskEventsV1.workspaceId, taskEventsV1.id], + }).onDelete('restrict'), + uniqueIndex('change_proposals_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('change_proposals_workspace_idempotency_uidx').on(t.workspaceId, t.idempotencyKey), + index('change_proposals_workspace_target_state_idx').on( + t.workspaceId, + t.targetAggregateType, + t.targetAggregateId, + t.state, + ), + check('change_proposals_expected_version_positive_chk', sql`${t.expectedAggregateVersion} > 0`), + check('change_proposals_version_positive_chk', sql`${t.version} > 0`), + check( + 'change_proposals_decision_fields_chk', + sql`(${t.state} = 'pending' and ${t.decisionActorUserId} is null and ${t.decisionReason} is null and ${t.decidedAt} is null and ${t.acceptedCommandAuditEventId} is null) or (${t.state} = 'rejected' and ${t.decisionActorUserId} is not null and ${t.decisionReason} is not null and ${t.decidedAt} is not null and ${t.acceptedCommandAuditEventId} is null) or (${t.state} = 'accepted' and ${t.decisionActorUserId} is not null and ${t.decisionReason} is not null and ${t.decidedAt} is not null and ${t.acceptedCommandAuditEventId} is not null)`, + ), + ], +); + +// ─── Assignments, execution, fencing, checkpoints ──────────────────────────── + +export const taskAssignmentsV1 = pgTable( + 'task_assignments', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id').notNull(), + taskVersion: integer('task_version').notNull(), + targetUserId: text('target_user_id'), + targetTeamId: uuid('target_team_id'), + targetAgentId: uuid('target_agent_id'), + targetSessionId: uuid('target_session_id'), + specialistRole: specialistRoleEnum('specialist_role').notNull(), + state: assignmentStateEnum('state').notNull().default('awaiting_approval'), + policyRevision: text('policy_revision').notNull(), + proposedByUserId: text('proposed_by_user_id'), + proposedByAgentId: uuid('proposed_by_agent_id'), + reason: text('reason').notNull(), + proposedAt: timestamp('proposed_at', { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + approvedAt: timestamp('approved_at', { withTimezone: true }), + endedAt: timestamp('ended_at', { withTimezone: true }), + }, + (t) => [ + foreignKey({ + name: 'task_assignments_workspace_task_fk', + columns: [t.workspaceId, t.taskId], + foreignColumns: [tasksV1.workspaceId, tasksV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'task_assignments_workspace_user_fk', + columns: [t.workspaceId, t.targetUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + foreignKey({ + name: 'task_assignments_workspace_proposer_user_fk', + columns: [t.workspaceId, t.proposedByUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + foreignKey({ + name: 'task_assignments_workspace_proposer_agent_fk', + columns: [t.workspaceId, t.proposedByAgentId], + foreignColumns: [agentsV1.workspaceId, agentsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'task_assignments_workspace_team_fk', + columns: [t.workspaceId, t.targetTeamId], + foreignColumns: [teamsV1.workspaceId, teamsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'task_assignments_workspace_agent_fk', + columns: [t.workspaceId, t.targetAgentId], + foreignColumns: [agentsV1.workspaceId, agentsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'task_assignments_workspace_agent_session_fk', + columns: [t.workspaceId, t.targetAgentId, t.targetSessionId], + foreignColumns: [agentSessionsV1.workspaceId, agentSessionsV1.agentId, agentSessionsV1.id], + }).onDelete('restrict'), + uniqueIndex('task_assignments_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('task_assignments_exact_target_uidx').on( + t.workspaceId, + t.taskId, + t.id, + t.targetAgentId, + t.targetSessionId, + ), + index('task_assignments_workspace_task_state_idx').on(t.workspaceId, t.taskId, t.state), + check( + 'task_assignments_exactly_one_principal_chk', + sql`num_nonnulls(${t.targetUserId}, ${t.targetTeamId}, ${t.targetAgentId}) = 1`, + ), + check( + 'task_assignments_exactly_one_proposer_chk', + sql`num_nonnulls(${t.proposedByUserId}, ${t.proposedByAgentId}) = 1`, + ), + check( + 'task_assignments_agent_session_pair_chk', + sql`(${t.targetAgentId} is null and ${t.targetSessionId} is null) or (${t.targetAgentId} is not null and ${t.targetSessionId} is not null)`, + ), + check('task_assignments_task_version_positive_chk', sql`${t.taskVersion} > 0`), + ], +); + +export const taskExecutionStatesV1 = pgTable( + 'task_execution_states', + { + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id').notNull(), + disposition: executionDispositionEnum('disposition').notNull().default('available'), + attemptCount: integer('attempt_count').notNull().default(0), + maxAttempts: integer('max_attempts').notNull(), + nextEligibleAt: timestamp('next_eligible_at', { withTimezone: true }), + terminalReason: text('terminal_reason'), + updatedByActorKind: actorKindEnum('updated_by_actor_kind').notNull(), + updatedByActorId: text('updated_by_actor_id').notNull(), + policyRevision: text('policy_revision').notNull(), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + primaryKey({ name: 'task_execution_states_pk', columns: [t.workspaceId, t.taskId] }), + foreignKey({ + name: 'task_execution_states_workspace_task_fk', + columns: [t.workspaceId, t.taskId], + foreignColumns: [tasksV1.workspaceId, tasksV1.id], + }).onDelete('restrict'), + index('task_execution_states_disposition_next_idx').on( + t.workspaceId, + t.disposition, + t.nextEligibleAt, + ), + check('task_execution_states_attempt_nonnegative_chk', sql`${t.attemptCount} >= 0`), + check('task_execution_states_max_positive_chk', sql`${t.maxAttempts} > 0`), + check('task_execution_states_attempt_bound_chk', sql`${t.attemptCount} <= ${t.maxAttempts}`), + check('task_execution_states_version_positive_chk', sql`${t.version} > 0`), + ], +); + +export const taskLeasesV1 = pgTable( + 'task_leases', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id').notNull(), + assignmentId: uuid('assignment_id').notNull(), + agentId: uuid('agent_id').notNull(), + agentSessionId: uuid('agent_session_id').notNull(), + state: leaseStateEnum('state').notNull().default('pending_ack'), + acquiredAt: timestamp('acquired_at', { withTimezone: true }).notNull().defaultNow(), + acknowledgedAt: timestamp('acknowledged_at', { withTimezone: true }), + acknowledgeBy: timestamp('acknowledge_by', { withTimezone: true }).notNull(), + lastHeartbeatAt: timestamp('last_heartbeat_at', { withTimezone: true }), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + /** Exact value atomically returned from tasks.fencing_counter. */ + fencingToken: bigint('fencing_token', { mode: 'bigint' }).notNull(), + attemptNumber: integer('attempt_number').notNull(), + releaseReason: text('release_reason'), + releasedAt: timestamp('released_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'task_leases_exact_assignment_target_fk', + columns: [t.workspaceId, t.taskId, t.assignmentId, t.agentId, t.agentSessionId], + foreignColumns: [ + taskAssignmentsV1.workspaceId, + taskAssignmentsV1.taskId, + taskAssignmentsV1.id, + taskAssignmentsV1.targetAgentId, + taskAssignmentsV1.targetSessionId, + ], + }).onDelete('restrict'), + uniqueIndex('task_leases_active_task_uidx') + .on(t.workspaceId, t.taskId) + .where(sql`${t.state} in ('pending_ack', 'active')`), + uniqueIndex('task_leases_exact_fence_uidx').on(t.workspaceId, t.taskId, t.id, t.fencingToken), + uniqueIndex('task_leases_task_fence_uidx').on(t.workspaceId, t.taskId, t.fencingToken), + index('task_leases_workspace_state_expiry_idx').on(t.workspaceId, t.state, t.expiresAt), + check('task_leases_fence_positive_chk', sql`${t.fencingToken} > 0`), + check('task_leases_attempt_positive_chk', sql`${t.attemptNumber} > 0`), + ], +); + +export const taskCheckpointsV1 = pgTable( + 'task_checkpoints', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id').notNull(), + leaseId: uuid('lease_id').notNull(), + fencingToken: bigint('fencing_token', { mode: 'bigint' }).notNull(), + sequence: integer('sequence').notNull(), + resumableSummary: text('resumable_summary').notNull(), + contextUsagePercent: integer('context_usage_percent'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'task_checkpoints_exact_lease_fence_fk', + columns: [t.workspaceId, t.taskId, t.leaseId, t.fencingToken], + foreignColumns: [ + taskLeasesV1.workspaceId, + taskLeasesV1.taskId, + taskLeasesV1.id, + taskLeasesV1.fencingToken, + ], + }).onDelete('restrict'), + uniqueIndex('task_checkpoints_workspace_task_id_uidx').on(t.workspaceId, t.taskId, t.id), + uniqueIndex('task_checkpoints_lease_sequence_uidx').on(t.workspaceId, t.leaseId, t.sequence), + index('task_checkpoints_task_created_idx').on(t.workspaceId, t.taskId, t.createdAt), + check('task_checkpoints_sequence_positive_chk', sql`${t.sequence} > 0`), + check('task_checkpoints_fence_positive_chk', sql`${t.fencingToken} > 0`), + check( + 'task_checkpoints_context_percent_chk', + sql`${t.contextUsagePercent} is null or (${t.contextUsagePercent} >= 0 and ${t.contextUsagePercent} <= 100)`, + ), + ], +); + +export const checkpointArtifactsV1 = pgTable( + 'task_checkpoint_artifacts', + { + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id').notNull(), + checkpointId: uuid('checkpoint_id').notNull(), + artifactId: uuid('artifact_id').notNull(), + }, + (t) => [ + primaryKey({ + name: 'task_checkpoint_artifacts_pk', + columns: [t.workspaceId, t.checkpointId, t.artifactId], + }), + foreignKey({ + name: 'task_checkpoint_artifacts_checkpoint_fk', + columns: [t.workspaceId, t.taskId, t.checkpointId], + foreignColumns: [ + taskCheckpointsV1.workspaceId, + taskCheckpointsV1.taskId, + taskCheckpointsV1.id, + ], + }).onDelete('restrict'), + foreignKey({ + name: 'task_checkpoint_artifacts_artifact_fk', + columns: [t.workspaceId, t.artifactId], + foreignColumns: [artifactsV1.workspaceId, artifactsV1.id], + }).onDelete('restrict'), + ], +); + +// ─── Approvals, evidence, outbox ───────────────────────────────────────────── + +export const approvalDecisionsV1 = pgTable( + 'approval_decisions', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id'), + missionId: uuid('mission_id'), + assignmentId: uuid('assignment_id'), + gateType: text('gate_type').notNull(), + requestedFromRole: specialistRoleEnum('requested_from_role'), + decision: approvalDecisionEnum('decision').notNull().default('requested'), + conditions: jsonb('conditions').notNull().$type>().default({}), + actorKind: actorKindEnum('actor_kind'), + actorId: text('actor_id'), + policyRevision: text('policy_revision').notNull(), + reason: text('reason'), + requestedAt: timestamp('requested_at', { withTimezone: true }).notNull().defaultNow(), + decidedAt: timestamp('decided_at', { withTimezone: true }), + }, + (t) => [ + foreignKey({ + name: 'approval_decisions_workspace_task_fk', + columns: [t.workspaceId, t.taskId], + foreignColumns: [tasksV1.workspaceId, tasksV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'approval_decisions_workspace_mission_fk', + columns: [t.workspaceId, t.missionId], + foreignColumns: [missionsV1.workspaceId, missionsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'approval_decisions_workspace_assignment_fk', + columns: [t.workspaceId, t.assignmentId], + foreignColumns: [taskAssignmentsV1.workspaceId, taskAssignmentsV1.id], + }).onDelete('restrict'), + uniqueIndex('approval_decisions_workspace_id_uidx').on(t.workspaceId, t.id), + index('approval_decisions_assignment_idx').on(t.workspaceId, t.assignmentId, t.decision), + check( + 'approval_decisions_one_target_chk', + sql`num_nonnulls(${t.taskId}, ${t.missionId}, ${t.assignmentId}) = 1`, + ), + ], +); + +export const approvalDecisionArtifactsV1 = pgTable( + 'approval_decision_artifacts', + { + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + approvalDecisionId: uuid('approval_decision_id').notNull(), + artifactId: uuid('artifact_id').notNull(), + }, + (t) => [ + primaryKey({ + name: 'approval_decision_artifacts_pk', + columns: [t.workspaceId, t.approvalDecisionId, t.artifactId], + }), + foreignKey({ + name: 'approval_decision_artifacts_decision_fk', + columns: [t.workspaceId, t.approvalDecisionId], + foreignColumns: [approvalDecisionsV1.workspaceId, approvalDecisionsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'approval_decision_artifacts_artifact_fk', + columns: [t.workspaceId, t.artifactId], + foreignColumns: [artifactsV1.workspaceId, artifactsV1.id], + }).onDelete('restrict'), + ], +); + +export const outboxEventsV1 = pgTable( + 'outbox_events', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + aggregateType: text('aggregate_type').notNull(), + aggregateId: uuid('aggregate_id').notNull(), + aggregateRevision: integer('aggregate_revision').notNull(), + eventType: text('event_type').notNull(), + payload: jsonb('payload').notNull().$type>(), + state: outboxStateEnum('state').notNull().default('pending'), + attempts: integer('attempts').notNull().default(0), + nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }), + publishedAt: timestamp('published_at', { withTimezone: true }), + lastError: text('last_error'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('outbox_events_aggregate_revision_type_uidx').on( + t.workspaceId, + t.aggregateType, + t.aggregateId, + t.aggregateRevision, + t.eventType, + ), + index('outbox_events_state_next_attempt_idx').on(t.state, t.nextAttemptAt), + check('outbox_events_revision_positive_chk', sql`${t.aggregateRevision} > 0`), + check('outbox_events_attempts_nonnegative_chk', sql`${t.attempts} >= 0`), + ], +); + +/** + * Mandatory transaction/privilege rules frozen with this schema: + * + * 1. Every user principal/owner/proposer/decision actor must have ACTIVE + * workspace_members membership; denials expose no foreign-ID existence. + * 2. roleSet/roles JSON values are validated against specialist_role_v1 until + * normalized role joins are introduced; the primary/assigned role is enum. + * 3. Dependency cycles are rejected under a serialized recursive check. + * 4. task_events is declared before change_proposals so migration DDL creates + * task_events_workspace_id_uidx before both workspace-aware proposal event + * FKs. Submission preallocates the proposal ID and, in one transaction, + * inserts `change_proposal.submitted` with aggregate_type=change_proposal, + * aggregate_id=proposal.id, previous_version=NULL, new_version=1, then the + * proposal referencing that event. Missing or foreign-workspace events fail. + * 5. change_proposals never mutate targets directly. Acceptance locks proposal + * and target, proves fresh PG write health, checks expected version, invokes + * the NORMAL typed command, and in that same transaction binds its emitted + * event. The event workspace/aggregate type/aggregate ID must equal the + * proposal workspace/target, causation_id must equal submittedAuditEventId, + * and payload.changeProposalId must equal proposal.id; unrelated events fail. + * 6. Lease acquisition locks task+assignment+approval+session, increments + * tasks.fencing_counter atomically, and uses RETURNING bigint as the token. + * 7. task_events, task_checkpoints, checkpoint/artifact evidence, approval + * evidence, and immutable artifacts grant application roles INSERT/SELECT + * only. Canonical parents are archived, not hard-deleted; all parent FKs use + * RESTRICT. Purge requires an audited retention/break-glass procedure. + * 8. Polymorphic external_links and change_proposals targets are validated in a + * workspace-scoped transaction before insert; no existence oracle. + * 9. Legacy fields remain in the unified Drizzle declaration throughout expand + * and N-1. New Gateway commands never accept mission_tasks.status as a write + * source; its concrete retirement is specified in SHARED-CONTRACT.md. + */ diff --git a/docs/native-kanban-sot/contracts/mechanical-coordinator.v1.ts b/docs/native-kanban-sot/contracts/mechanical-coordinator.v1.ts new file mode 100644 index 00000000..b75f4ce8 --- /dev/null +++ b/docs/native-kanban-sot/contracts/mechanical-coordinator.v1.ts @@ -0,0 +1,419 @@ +/** + * Mosaic Native Kanban — frozen Mechanical Coordinator contracts v1. + * + * The pure decision engine and persistence/orchestration service are separate. + * Neither surface can create scope, edit acceptance, waive gates, certify, + * merge, release a deployment, or close a provider issue. + */ + +import type { + DeliberateWriteDenialV1, + InternalKanbanMutationContextV1, + KanbanEvaluationContextV1, + KanbanMutationFailureV1, + RetryableTransportErrorV1, + VersionConflictV1, +} from './health-state.v1.js'; + +export const COORDINATOR_CONTRACT_VERSION = '1.0.0' as const; +export type Uuid = string; +export type IsoTimestamp = string; +/** PostgreSQL bigint-safe decimal string; never a JavaScript number. */ +export type FencingTokenV1 = string; + +export const specialistRoles = [ + 'planning', + 'enhance', + 'coder', + 'review', + 'security-review', + 'pr-monitor', + 'certifier', +] as const; +export type SpecialistRole = (typeof specialistRoles)[number]; + +/** One vocabulary shared with task_assignment_state_v1 in the Drizzle schema. */ +export const assignmentStates = [ + 'awaiting_approval', + 'policy_pre_authorized', + 'approved', + 'rejected', + 'leased', + 'released', + 'expired', + 'superseded', +] as const; +export type AssignmentStateV1 = (typeof assignmentStates)[number]; + +export const readinessStates = [ + 'dependency-gated', + 'schedule-gated', + 'policy-gated', + 'lease-available', + 'leased', + 'retry-delayed', + 'exhausted', + 'quarantined', +] as const; +export type ReadinessState = (typeof readinessStates)[number]; + +export interface RetryStateSnapshotV1 { + disposition: 'available' | 'retry_delayed' | 'quarantined' | 'exhausted'; + attemptCount: number; + maxAttempts: number; + nextEligibleAt: IsoTimestamp | null; + idempotent: boolean; + terminalReason: string | null; + version: number; +} + +export interface TaskEligibilitySnapshotV1 { + workspaceId: Uuid; + taskId: Uuid; + taskVersion: number; + projectId: Uuid; + projectActive: boolean; + missionId: Uuid | null; + missionActive: boolean; + status: 'ready'; + priority: 'critical' | 'high' | 'medium' | 'low'; + boardRank: string; + dueAt: IsoTimestamp | null; + notBeforeAt: IsoTimestamp | null; + createdAt: IsoTimestamp; + requiredRole: SpecialistRole; + requiredCapabilities: readonly string[]; + blockingDependencies: readonly { + taskId: Uuid; + done: boolean; + completionConditionSatisfied: boolean; + }[]; + releaseApproval: { + decisionId: Uuid; + approved: boolean; + policyRevision: string; + } | null; + activeLeaseId: Uuid | null; + retry: RetryStateSnapshotV1; +} + +export interface AgentSessionSnapshotV1 { + workspaceId: Uuid; + agentId: Uuid; + sessionId: Uuid; + state: 'available' | 'busy'; + roles: readonly SpecialistRole[]; + capabilities: readonly string[]; + capacity: number; + activeLeaseCount: number; + heartbeatAt: IsoTimestamp; +} + +export interface EligibilityExplanationV1 { + taskId: Uuid; + eligible: boolean; + readiness: ReadinessState; + reasons: readonly { + gate: + | 'status' + | 'project' + | 'mission' + | 'dependency' + | 'schedule' + | 'retry' + | 'approval' + | 'lease' + | 'capability' + | 'capacity' + | 'health'; + satisfied: boolean; + code: string; + detail: string; + }[]; + policyRevision: string; + evaluatedAt: IsoTimestamp; +} + +export interface AssignmentProposalDecisionV1 { + workspaceId: Uuid; + taskId: Uuid; + taskVersion: number; + targetAgentId: Uuid; + targetSessionId: Uuid; + specialistRole: SpecialistRole; + initialState: 'awaiting_approval' | 'policy_pre_authorized'; + policyRevision: string; + explanation: EligibilityExplanationV1; + expiresAt: IsoTimestamp; +} + +export interface AssignmentCycleSnapshotV1 { + context: KanbanEvaluationContextV1; + tasks: readonly TaskEligibilitySnapshotV1[]; + sessions: readonly AgentSessionSnapshotV1[]; + workspaceFairness: Readonly>; + limit: number; +} + +export interface AssignmentCycleDecisionV1 { + evaluatedTaskCount: number; + proposals: readonly AssignmentProposalDecisionV1[]; + explanations: readonly EligibilityExplanationV1[]; +} + +export interface LeaseExpirySnapshotV1 { + workspaceId: Uuid; + taskId: Uuid; + taskVersion: number; + leaseId: Uuid; + assignmentId: Uuid; + sessionId: Uuid; + fencingToken: FencingTokenV1; + state: 'pending_ack' | 'active'; + acknowledgeBy: IsoTimestamp; + expiresAt: IsoTimestamp; + lastHeartbeatAt: IsoTimestamp | null; + retry: RetryStateSnapshotV1; +} + +export interface LeaseExpiryDecisionV1 { + leaseId: Uuid; + action: 'retain' | 'release' | 'retry' | 'quarantine' | 'exhaust'; + reason: string; + nextEligibleAt: IsoTimestamp | null; +} + +/** Pure package owned by KBN-200. It receives complete immutable snapshots. */ +export interface MechanicalCoordinatorDecisionEngineV1 { + evaluateAssignmentCycle(snapshot: AssignmentCycleSnapshotV1): AssignmentCycleDecisionV1; + explainEligibility( + context: KanbanEvaluationContextV1, + task: TaskEligibilitySnapshotV1, + sessions: readonly AgentSessionSnapshotV1[], + ): EligibilityExplanationV1; + decideLeaseExpiry( + context: KanbanEvaluationContextV1, + lease: LeaseExpirySnapshotV1, + ): LeaseExpiryDecisionV1; +} + +export interface PersistedAssignmentV1 { + assignmentId: Uuid; + workspaceId: Uuid; + taskId: Uuid; + taskVersion: number; + targetAgentId: Uuid; + targetSessionId: Uuid; + specialistRole: SpecialistRole; + state: AssignmentStateV1; + policyRevision: string; + proposedBy: { kind: 'user' | 'agent'; id: Uuid }; + reason: string; + createdAt: IsoTimestamp; + expiresAt: IsoTimestamp; +} + +export interface TaskLeaseV1 { + leaseId: Uuid; + workspaceId: Uuid; + taskId: Uuid; + taskVersion: number; + assignmentId: Uuid; + agentId: Uuid; + sessionId: Uuid; + state: 'pending_ack' | 'active'; + fencingToken: FencingTokenV1; + attempt: number; + acquiredAt: IsoTimestamp; + acknowledgeBy: IsoTimestamp; + lastHeartbeatAt: IsoTimestamp | null; + expiresAt: IsoTimestamp; +} + +interface ServiceCommandBaseV1 { + context: InternalKanbanMutationContextV1; + taskId: Uuid; + expectedTaskVersion: number; +} + +export interface AcquireApprovedLeaseCommandV1 extends ServiceCommandBaseV1 { + assignmentId: Uuid; + approvalDecisionId: Uuid; + targetSessionId: Uuid; + leaseTtlSeconds: number; +} + +export interface LeaseCommandV1 extends ServiceCommandBaseV1 { + leaseId: Uuid; + sessionId: Uuid; + fencingToken: FencingTokenV1; +} + +export interface HeartbeatLeaseCommandV1 extends LeaseCommandV1 { + extendSeconds: number; +} + +export interface CheckpointCommandV1 extends LeaseCommandV1 { + sequence: number; + resumableSummary: string; + artifactIds: readonly Uuid[]; + contextUsagePercent: number; +} + +export interface SubmitForReviewCommandV1 extends LeaseCommandV1 { + artifactIds: readonly Uuid[]; + summary: string; +} + +export interface ReleaseLeaseCommandV1 extends LeaseCommandV1 { + reason: + | 'worker_requested' + | 'ack_timeout' + | 'heartbeat_timeout' + | 'task_submitted' + | 'policy_revoked' + | 'shutdown'; +} + +export interface AssignmentCycleCommandV1 { + context: InternalKanbanMutationContextV1; + limit: number; +} + +export interface ExpirySweepCommandV1 { + context: InternalKanbanMutationContextV1; + limit: number; +} + +export interface RecoverCoordinatorCommandV1 { + context: InternalKanbanMutationContextV1; +} + +interface CoordinatorRejectionBaseV1 { + kind: 'coordinator_rejection'; + retryable: false; + requestOutcome: 'not_applied'; + correlationId: Uuid; + idempotencyKey: string; + message: string; +} + +export type CoordinatorPolicyRejectionV1 = + | (CoordinatorRejectionBaseV1 & { code: 'WORKSPACE_MISMATCH' }) + | (CoordinatorRejectionBaseV1 & { + code: 'TASK_NOT_ELIGIBLE'; + explanation: EligibilityExplanationV1; + }) + | (CoordinatorRejectionBaseV1 & { code: 'APPROVAL_REQUIRED' }) + | (CoordinatorRejectionBaseV1 & { code: 'APPROVAL_STALE' }) + | (CoordinatorRejectionBaseV1 & { code: 'ASSIGNMENT_STALE' }) + | (CoordinatorRejectionBaseV1 & { code: 'ASSIGNMENT_TARGET_MISMATCH' }) + | (CoordinatorRejectionBaseV1 & { code: 'POLICY_REVISION_MISMATCH' }) + | (CoordinatorRejectionBaseV1 & { code: 'ARTIFACT_WORKSPACE_MISMATCH' }) + | (CoordinatorRejectionBaseV1 & { code: 'LEASE_ALREADY_ACTIVE' }) + | (CoordinatorRejectionBaseV1 & { code: 'LEASE_NOT_FOUND' }) + | (CoordinatorRejectionBaseV1 & { code: 'LEASE_NOT_ACTIVE' }) + | (CoordinatorRejectionBaseV1 & { + code: 'ACK_DEADLINE_EXPIRED'; + expiredAt: IsoTimestamp; + }) + | (CoordinatorRejectionBaseV1 & { + code: 'FENCING_TOKEN_STALE'; + currentFencingToken: FencingTokenV1; + }) + | (CoordinatorRejectionBaseV1 & { code: 'SESSION_MISMATCH' }) + | (CoordinatorRejectionBaseV1 & { + code: 'HEARTBEAT_EXPIRED'; + expiredAt: IsoTimestamp; + }) + | (CoordinatorRejectionBaseV1 & { + code: 'CHECKPOINT_SEQUENCE_CONFLICT'; + currentSequence: number; + }) + | (CoordinatorRejectionBaseV1 & { code: 'RETRY_EXHAUSTED' }) + | (CoordinatorRejectionBaseV1 & { + code: 'NON_IDEMPOTENT_RETRY_REQUIRES_ORCHESTRATOR'; + }); + +/** Explicit mapping to the Gateway mutation failure union; no arbitrary booleans. */ +export type CoordinatorFailureV1 = + | DeliberateWriteDenialV1 + | VersionConflictV1 + | RetryableTransportErrorV1 + | CoordinatorPolicyRejectionV1; + +export interface CoordinatorSuccessV1 { + ok: true; + value: T; + correlationId: Uuid; +} +export interface CoordinatorFailureResultV1 { + ok: false; + failure: CoordinatorFailureV1; +} +export type CoordinatorResultV1 = CoordinatorSuccessV1 | CoordinatorFailureResultV1; + +export interface ExpirySweepResultV1 { + examined: number; + released: readonly Uuid[]; + retryScheduled: readonly Uuid[]; + quarantined: readonly Uuid[]; + exhausted: readonly Uuid[]; +} + +export interface RestartRecoveryResultV1 { + activeLeaseIds: readonly Uuid[]; + expiredLeaseIds: readonly Uuid[]; + pendingAssignmentIds: readonly Uuid[]; + pendingOutboxEventIds: readonly Uuid[]; +} + +/** Persistence/Gateway adapter owned by KBN-210. */ +export interface MechanicalCoordinatorServicePortV1 { + /** Loads immutable snapshots, invokes pure engine, and persists proposals atomically. */ + runAssignmentCycle( + command: AssignmentCycleCommandV1, + ): Promise>; + + /** Query path loads by ID; public health observation cannot authorize mutation. */ + getEligibilityExplanation( + context: KanbanEvaluationContextV1, + taskId: Uuid, + ): Promise>; + + /** + * Accepts IDs only. Implementation reloads and locks assignment + approval + + * task + target session in PostgreSQL, then verifies workspace, task version, + * target agent/session, state, expiry, policy revision, and current approval. + */ + acquireApprovedLease( + command: AcquireApprovedLeaseCommandV1, + ): Promise>; + + acknowledgeLease(command: LeaseCommandV1): Promise>; + heartbeatLease(command: HeartbeatLeaseCommandV1): Promise>; + appendCheckpoint( + command: CheckpointCommandV1, + ): Promise>; + submitForReview( + command: SubmitForReviewCommandV1, + ): Promise>; + releaseLease(command: ReleaseLeaseCommandV1): Promise>; + expireAndRecover( + command: ExpirySweepCommandV1, + ): Promise>; + recoverFromPostgres( + command: RecoverCoordinatorCommandV1, + ): Promise>; +} + +/** Compile-time mapping guarantee: Coordinator Gateway failures are Kanban failures or exact policy rejections. */ +export function isKanbanMutationFailureV1( + failure: CoordinatorFailureV1, +): failure is KanbanMutationFailureV1 { + return ( + failure.kind === 'deliberate_fail_closed_denial' || + failure.kind === 'retryable_transport_error' || + failure.kind === 'version_conflict' + ); +} diff --git a/docs/native-kanban-sot/contracts/recovery-posture.v1.ts b/docs/native-kanban-sot/contracts/recovery-posture.v1.ts new file mode 100644 index 00000000..3604519a --- /dev/null +++ b/docs/native-kanban-sot/contracts/recovery-posture.v1.ts @@ -0,0 +1,369 @@ +/** + * Mosaic Native Kanban — frozen recovery-posture contract v1. + * Recovery posture is configurable; SOT, write-health, Coordinator authority, + * and gate semantics are not fields and cannot be overridden. + */ + +export const RECOVERY_POSTURE_CONTRACT_VERSION = '1.0.0' as const; +export const recoveryTiers = ['lite', 'standard', 'high-assurance'] as const; +export type RecoveryTier = (typeof recoveryTiers)[number]; + +export interface OffClusterStorageV1 { + required: true; + encrypted: true; + separateFailureDomain: true; + minimumCopies: number; + storageClass: 'encrypted-object-storage' | 'encrypted-backup-target'; +} + +export interface RecoveryPostureV1 { + contractVersion: typeof RECOVERY_POSTURE_CONTRACT_VERSION; + tier: RecoveryTier; + targetRpoMinutes: number; + targetRtoMinutes: number; + baseBackupIntervalHours: number; + /** null means WAL archival/PITR is disabled. */ + walArchiveIntervalMinutes: number | null; + /** 0 means PITR is disabled. */ + pitrRetentionDays: number; + restoreTestIntervalDays: number; + breakGlassDrillIntervalDays: number; + offClusterStorage: OffClusterStorageV1; +} + +export const recoveryPostureDefaults: Readonly> = { + lite: { + contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION, + tier: 'lite', + targetRpoMinutes: 24 * 60, + targetRtoMinutes: 24 * 60, + baseBackupIntervalHours: 24, + walArchiveIntervalMinutes: null, + pitrRetentionDays: 0, + restoreTestIntervalDays: 90, + breakGlassDrillIntervalDays: 365, + offClusterStorage: { + required: true, + encrypted: true, + separateFailureDomain: true, + minimumCopies: 1, + storageClass: 'encrypted-backup-target', + }, + }, + standard: { + contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION, + tier: 'standard', + targetRpoMinutes: 60, + targetRtoMinutes: 8 * 60, + baseBackupIntervalHours: 24, + walArchiveIntervalMinutes: 15, + pitrRetentionDays: 14, + restoreTestIntervalDays: 90, + breakGlassDrillIntervalDays: 180, + offClusterStorage: { + required: true, + encrypted: true, + separateFailureDomain: true, + minimumCopies: 1, + storageClass: 'encrypted-object-storage', + }, + }, + 'high-assurance': { + contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION, + tier: 'high-assurance', + targetRpoMinutes: 15, + targetRtoMinutes: 4 * 60, + baseBackupIntervalHours: 24, + walArchiveIntervalMinutes: 5, + pitrRetentionDays: 35, + restoreTestIntervalDays: 30, + breakGlassDrillIntervalDays: 90, + offClusterStorage: { + required: true, + encrypted: true, + separateFailureDomain: true, + minimumCopies: 1, + storageClass: 'encrypted-object-storage', + }, + }, +}; + +/** Shape schema. Normative cross-field semantics are enforced by validateRecoveryPostureV1. */ +export const recoveryPostureJsonSchemaV1 = { + $id: 'https://mosaicstack.dev/contracts/recovery-posture.v1.schema.json', + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + additionalProperties: false, + required: [ + 'contractVersion', + 'tier', + 'targetRpoMinutes', + 'targetRtoMinutes', + 'baseBackupIntervalHours', + 'walArchiveIntervalMinutes', + 'pitrRetentionDays', + 'restoreTestIntervalDays', + 'breakGlassDrillIntervalDays', + 'offClusterStorage', + ], + properties: { + contractVersion: { const: RECOVERY_POSTURE_CONTRACT_VERSION }, + tier: { enum: recoveryTiers }, + targetRpoMinutes: { type: 'integer', minimum: 1 }, + targetRtoMinutes: { type: 'integer', minimum: 1 }, + baseBackupIntervalHours: { type: 'integer', minimum: 1 }, + walArchiveIntervalMinutes: { + anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }], + }, + pitrRetentionDays: { type: 'integer', minimum: 0 }, + restoreTestIntervalDays: { type: 'integer', minimum: 1 }, + breakGlassDrillIntervalDays: { type: 'integer', minimum: 1 }, + offClusterStorage: { + type: 'object', + additionalProperties: false, + required: ['required', 'encrypted', 'separateFailureDomain', 'minimumCopies', 'storageClass'], + properties: { + required: { const: true }, + encrypted: { const: true }, + separateFailureDomain: { const: true }, + minimumCopies: { type: 'integer', minimum: 1 }, + storageClass: { + enum: ['encrypted-object-storage', 'encrypted-backup-target'], + }, + }, + }, + }, +} as const; + +export const recoveryValidationCodes = [ + 'INVALID_SHAPE', + 'UNKNOWN_FIELD', + 'PITR_REQUIRES_WAL', + 'WAL_REQUIRES_PITR', + 'RPO_BETTER_THAN_MECHANISM', + 'OFF_CLUSTER_REQUIRED', + 'HIGH_ASSURANCE_WEAKENED', +] as const; +export type RecoveryValidationCode = (typeof recoveryValidationCodes)[number]; + +export interface RecoveryValidationIssueV1 { + code: RecoveryValidationCode; + path: string; + message: string; +} +export type RecoveryValidationResultV1 = + | { ok: true; value: RecoveryPostureV1 } + | { ok: false; issues: RecoveryValidationIssueV1[] }; + +const topLevelFields = new Set([ + 'contractVersion', + 'tier', + 'targetRpoMinutes', + 'targetRtoMinutes', + 'baseBackupIntervalHours', + 'walArchiveIntervalMinutes', + 'pitrRetentionDays', + 'restoreTestIntervalDays', + 'breakGlassDrillIntervalDays', + 'offClusterStorage', +]); +const storageFields = new Set([ + 'required', + 'encrypted', + 'separateFailureDomain', + 'minimumCopies', + 'storageClass', +]); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} +function isPositiveInteger(value: unknown): value is number { + return Number.isInteger(value) && Number(value) > 0; +} +function isNonnegativeInteger(value: unknown): value is number { + return Number.isInteger(value) && Number(value) >= 0; +} + +/** + * Normative parser/refinement. Deployment code MUST call this function (or a + * byte-for-byte behaviorally equivalent generated validator), not JSON Schema + * shape validation alone. + */ +export function validateRecoveryPostureV1(input: unknown): RecoveryValidationResultV1 { + const issues: RecoveryValidationIssueV1[] = []; + if (!isRecord(input)) { + return { + ok: false, + issues: [{ code: 'INVALID_SHAPE', path: '$', message: 'posture must be an object' }], + }; + } + + for (const key of Object.keys(input)) { + if (!topLevelFields.has(key)) { + issues.push({ code: 'UNKNOWN_FIELD', path: `$.${key}`, message: 'unknown field' }); + } + } + + const tier = input['tier']; + const storage = input['offClusterStorage']; + const integerFields = [ + 'targetRpoMinutes', + 'targetRtoMinutes', + 'baseBackupIntervalHours', + 'restoreTestIntervalDays', + 'breakGlassDrillIntervalDays', + ] as const; + + if (input['contractVersion'] !== RECOVERY_POSTURE_CONTRACT_VERSION) { + issues.push({ + code: 'INVALID_SHAPE', + path: '$.contractVersion', + message: `must equal ${RECOVERY_POSTURE_CONTRACT_VERSION}`, + }); + } + if (!recoveryTiers.includes(tier as RecoveryTier)) { + issues.push({ code: 'INVALID_SHAPE', path: '$.tier', message: 'unknown recovery tier' }); + } + for (const field of integerFields) { + if (!isPositiveInteger(input[field])) { + issues.push({ + code: 'INVALID_SHAPE', + path: `$.${field}`, + message: 'must be a positive integer', + }); + } + } + if (!isNonnegativeInteger(input['pitrRetentionDays'])) { + issues.push({ + code: 'INVALID_SHAPE', + path: '$.pitrRetentionDays', + message: 'must be a nonnegative integer', + }); + } + if ( + input['walArchiveIntervalMinutes'] !== null && + !isPositiveInteger(input['walArchiveIntervalMinutes']) + ) { + issues.push({ + code: 'INVALID_SHAPE', + path: '$.walArchiveIntervalMinutes', + message: 'must be null or a positive integer', + }); + } + + if (!isRecord(storage)) { + issues.push({ + code: 'INVALID_SHAPE', + path: '$.offClusterStorage', + message: 'must be an object', + }); + } else { + for (const key of Object.keys(storage)) { + if (!storageFields.has(key)) { + issues.push({ + code: 'UNKNOWN_FIELD', + path: `$.offClusterStorage.${key}`, + message: 'unknown field', + }); + } + } + if ( + storage['required'] !== true || + storage['encrypted'] !== true || + storage['separateFailureDomain'] !== true + ) { + issues.push({ + code: 'OFF_CLUSTER_REQUIRED', + path: '$.offClusterStorage', + message: 'storage must be required, encrypted, and in a separate failure domain', + }); + } + if (!isPositiveInteger(storage['minimumCopies'])) { + issues.push({ + code: 'INVALID_SHAPE', + path: '$.offClusterStorage.minimumCopies', + message: 'must be a positive integer', + }); + } + if ( + storage['storageClass'] !== 'encrypted-object-storage' && + storage['storageClass'] !== 'encrypted-backup-target' + ) { + issues.push({ + code: 'INVALID_SHAPE', + path: '$.offClusterStorage.storageClass', + message: 'unsupported storage class', + }); + } + } + + const wal = input['walArchiveIntervalMinutes']; + const pitr = input['pitrRetentionDays']; + if (pitr !== 0 && wal === null) { + issues.push({ + code: 'PITR_REQUIRES_WAL', + path: '$.pitrRetentionDays', + message: 'PITR retention requires WAL archival', + }); + } + if (wal !== null && pitr === 0) { + issues.push({ + code: 'WAL_REQUIRES_PITR', + path: '$.walArchiveIntervalMinutes', + message: 'WAL archival requires positive PITR retention', + }); + } + + if ( + isPositiveInteger(input['targetRpoMinutes']) && + isPositiveInteger(input['baseBackupIntervalHours']) && + (wal === null || isPositiveInteger(wal)) + ) { + const mechanismMinutes = wal === null ? input['baseBackupIntervalHours'] * 60 : wal; + if (mechanismMinutes > input['targetRpoMinutes']) { + issues.push({ + code: 'RPO_BETTER_THAN_MECHANISM', + path: '$.targetRpoMinutes', + message: `configured mechanism can only support ${mechanismMinutes} minutes`, + }); + } + } + + if (tier === 'high-assurance') { + const weakened = + !isPositiveInteger(input['targetRpoMinutes']) || + input['targetRpoMinutes'] > 15 || + !isPositiveInteger(input['targetRtoMinutes']) || + input['targetRtoMinutes'] > 4 * 60 || + !isPositiveInteger(input['baseBackupIntervalHours']) || + input['baseBackupIntervalHours'] > 24 || + !isPositiveInteger(wal) || + wal > 5 || + !isNonnegativeInteger(pitr) || + pitr < 35 || + !isPositiveInteger(input['restoreTestIntervalDays']) || + input['restoreTestIntervalDays'] > 30 || + !isPositiveInteger(input['breakGlassDrillIntervalDays']) || + input['breakGlassDrillIntervalDays'] > 90; + if (weakened) { + issues.push({ + code: 'HIGH_ASSURANCE_WEAKENED', + path: '$', + message: 'high-assurance posture may be strengthened but not weakened', + }); + } + } + + if (issues.length > 0) return { ok: false, issues }; + return { ok: true, value: input as unknown as RecoveryPostureV1 }; +} + +export interface RecoveryPostureOverrideAuditV1 { + actorId: string; + reason: string; + effectiveAt: string; + policyRevision: string; + previous: RecoveryPostureV1; + next: RecoveryPostureV1; +} diff --git a/docs/native-kanban-sot/tsconfig.json b/docs/native-kanban-sot/tsconfig.json new file mode 100644 index 00000000..8ab269f4 --- /dev/null +++ b/docs/native-kanban-sot/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "incremental": false, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "baseUrl": ".", + "paths": { + "drizzle-orm": ["../../packages/db/node_modules/drizzle-orm/index.d.ts"], + "drizzle-orm/pg-core": ["../../packages/db/node_modules/drizzle-orm/pg-core/index.d.ts"] + } + }, + "include": ["contracts/*.ts"] +} diff --git a/docs/openapi-tess.yaml b/docs/openapi-tess.yaml new file mode 100644 index 00000000..10ddeb0c --- /dev/null +++ b/docs/openapi-tess.yaml @@ -0,0 +1,389 @@ +openapi: 3.1.0 +info: { title: Mosaic Tess Gateway, version: 1.0.0 } +security: [{ sessionAuth: [] }] +paths: + /api/interaction/{agentName}/sessions: + { + get: + { + summary: List authorized runtime sessions, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/provider' }, + { $ref: '#/components/parameters/correlation' }, + ], + responses: { '200': { description: Sessions } }, + }, + } + /api/interaction/{agentName}/transitional-capabilities: + { + get: + { + summary: Get transitional capability matrix, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/provider' }, + { $ref: '#/components/parameters/correlation' }, + ], + responses: { '200': { description: Matrix } }, + }, + } + /api/interaction/{agentName}/tree: + { + get: + { + summary: Get authorized session tree, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/provider' }, + { $ref: '#/components/parameters/correlation' }, + ], + responses: { '200': { description: Tree } }, + }, + } + /api/interaction/{agentName}/sessions/{sessionId}/enroll: + { + post: + { + summary: Enroll a durable session, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/sessionId' }, + { $ref: '#/components/parameters/correlation' }, + ], + requestBody: { $ref: '#/components/requestBodies/Enroll' }, + responses: { '200': { description: Enrolled } }, + }, + } + /api/interaction/{agentName}/sessions/{sessionId}/attach: + { + post: + { + summary: Attach to a runtime session, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/sessionId' }, + { $ref: '#/components/parameters/correlation' }, + ], + requestBody: { $ref: '#/components/requestBodies/Attach' }, + responses: { '200': { description: Attachment } }, + }, + } + /api/interaction/{agentName}/sessions/{sessionId}/send: + { + post: + { + summary: Queue a durable provider send, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/sessionId' }, + { $ref: '#/components/parameters/correlation' }, + ], + requestBody: { $ref: '#/components/requestBodies/Send' }, + responses: { '200': { description: Queued } }, + }, + } + /api/interaction/{agentName}/sessions/{sessionId}/stop: + { + post: + { + summary: Stop a session with approval, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/sessionId' }, + { $ref: '#/components/parameters/correlation' }, + ], + requestBody: { $ref: '#/components/requestBodies/Stop' }, + responses: { '200': { description: Stopped }, '403': { description: Approval denied } }, + }, + } + /api/interaction/{agentName}/sessions/{sessionId}/recover: + { + post: + { + summary: Recover interrupted durable work, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/sessionId' }, + { $ref: '#/components/parameters/correlation' }, + ], + responses: { '200': { description: Recovered } }, + }, + } + /api/coord/mos/handoff: + { + post: + { + summary: Submit Mos handoff, + parameters: [{ $ref: '#/components/parameters/correlation' }], + requestBody: { $ref: '#/components/requestBodies/Handoff' }, + responses: { '200': { description: Receipt } }, + }, + } + /api/coord/mos/{handoffId}/observe: + { + get: + { + summary: Observe Mos handoff, + parameters: + [ + { $ref: '#/components/parameters/handoffId' }, + { $ref: '#/components/parameters/correlation' }, + ], + responses: { '200': { description: Observation } }, + }, + } + /api/coord/mos/{handoffId}/result: + { + get: + { + summary: Get Mos handoff result, + parameters: + [ + { $ref: '#/components/parameters/handoffId' }, + { $ref: '#/components/parameters/correlation' }, + ], + responses: { '200': { description: Result } }, + }, + } + /api/interaction/{agentName}/sessions/{sessionId}/stream: + { + get: + { + summary: Stream runtime events, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/sessionId' }, + { $ref: '#/components/parameters/correlation' }, + ], + responses: + { + '200': + { + description: Event stream, + content: { text/event-stream: { schema: { type: string } } }, + }, + }, + }, + } + /api/memory/preferences: + { + get: { summary: List preferences, responses: { '200': { description: Preferences } } }, + post: + { + summary: Upsert preference, + requestBody: { $ref: '#/components/requestBodies/Preference' }, + responses: { '200': { description: Preference } }, + }, + } + /api/memory/preferences/{key}: + { + get: + { + summary: Get preference, + parameters: [{ $ref: '#/components/parameters/key' }], + responses: { '200': { description: Preference } }, + }, + delete: + { + summary: Delete preference, + parameters: [{ $ref: '#/components/parameters/key' }], + responses: { '204': { description: Deleted } }, + }, + } + /api/memory/insights: + { + get: { summary: List insights, responses: { '200': { description: Insights } } }, + post: + { + summary: Create insight, + requestBody: { $ref: '#/components/requestBodies/Insight' }, + responses: { '200': { description: Insight } }, + }, + } + /api/memory/insights/{id}: + { + get: + { + summary: Get insight, + parameters: [{ $ref: '#/components/parameters/id' }], + responses: { '200': { description: Insight } }, + }, + delete: + { + summary: Delete insight, + parameters: [{ $ref: '#/components/parameters/id' }], + responses: { '204': { description: Deleted } }, + }, + } + /api/memory/search: + { + post: + { + summary: Search memory, + requestBody: { $ref: '#/components/requestBodies/Search' }, + responses: { '200': { description: Search results } }, + }, + } +components: + securitySchemes: { sessionAuth: { type: http, scheme: bearer } } + parameters: + agentName: { name: agentName, in: path, required: true, schema: { type: string } } + sessionId: { name: sessionId, in: path, required: true, schema: { type: string } } + provider: { name: provider, in: query, required: true, schema: { type: string } } + correlation: { name: X-Correlation-Id, in: header, required: true, schema: { type: string } } + key: { name: key, in: path, required: true, schema: { type: string } } + id: { name: id, in: path, required: true, schema: { type: string } } + requestBodies: + Enroll: + { + required: true, + content: + { + application/json: + { + schema: + { + type: object, + required: [providerId, runtimeSessionId], + properties: + { providerId: { type: string }, runtimeSessionId: { type: string } }, + }, + }, + }, + } + Handoff: + { + required: true, + content: + { + application/json: + { + schema: + { + type: object, + required: [idempotencyKey, summary], + properties: + { + idempotencyKey: { type: string }, + summary: { type: string }, + context: { type: string }, + missionId: { type: string }, + }, + }, + }, + }, + } + Attach: + { + content: + { + application/json: { schema: { type: object, properties: { mode: { enum: [read] } } } }, + }, + } + Send: + { + required: true, + content: + { + application/json: + { + schema: + { + type: object, + required: [content, idempotencyKey], + properties: { content: { type: string }, idempotencyKey: { type: string } }, + }, + }, + }, + } + Stop: + { + required: true, + content: + { + application/json: + { + schema: + { + type: object, + required: [approvalRef], + properties: { approvalRef: { type: string } }, + }, + }, + }, + } + Preference: + { + required: true, + content: + { + application/json: + { + schema: + { + type: object, + required: [key, value], + properties: + { + key: { type: string }, + value: {}, + category: { type: string }, + source: { type: string }, + }, + }, + }, + }, + } + Insight: + { + required: true, + content: + { + application/json: + { + schema: + { + type: object, + required: [content], + properties: + { + content: { type: string }, + source: { type: string }, + category: { type: string }, + metadata: { type: object }, + }, + }, + }, + }, + } + Search: + { + required: true, + content: + { + application/json: + { + schema: + { + type: object, + required: [query], + properties: + { + query: { type: string }, + limit: { type: integer }, + maxDistance: { type: number }, + }, + }, + }, + }, + } diff --git a/docs/plans/2026-03-15-agent-platform-architecture.md b/docs/plans/2026-03-15-agent-platform-architecture.md index 889ce539..898b865f 100644 --- a/docs/plans/2026-03-15-agent-platform-architecture.md +++ b/docs/plans/2026-03-15-agent-platform-architecture.md @@ -1460,12 +1460,11 @@ Add to `packages/db/src/schema.ts` in the `preferences` table definition: mutable: boolean('mutable').notNull().default(true), ``` -Generate and apply: +### Held future procedure -```bash -pnpm --filter @mosaicstack/db db:generate # generates migration SQL -pnpm --filter @mosaicstack/db db:migrate # applies to PG -``` +This historical architecture plan grants **no current command authority**. PostgreSQL execution is non-operative until **KBN-101-00, KBN-101-03, and KBN-101-05** land; do not invoke a PostgreSQL runner from this checkout. After those cards land, the approved future procedure is exactly: external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. Offline migration artifact generation belongs to its owning implementation card and does not activate PostgreSQL execution. + +> **KBN-101 supersession:** `pnpm --filter @mosaicstack/db db:migrate` is superseded and MUST NOT be used. The future runner receives only deployment-injected migration credentials; it accepts no URL, SQL, schema, or role argv. Platform enforcement keys (seeded with `mutable = false` by gateway `PreferencesService.onModuleInit()`): diff --git a/docs/plans/2026-03-15-wave2-tui-layout-navigation.md b/docs/plans/2026-03-15-wave2-tui-layout-navigation.md index 791e63bb..e67a1d1e 100644 --- a/docs/plans/2026-03-15-wave2-tui-layout-navigation.md +++ b/docs/plans/2026-03-15-wave2-tui-layout-navigation.md @@ -946,13 +946,11 @@ pnpm --filter @mosaicstack/types typecheck Expected: All PASS -**Step 2: Manual smoke test** +**Step 2: Manual smoke test (held)** -```bash -cd /home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements -docker compose up -d -pnpm --filter @mosaicstack/cli exec tsx src/cli.ts tui -``` +This historical TUI smoke test is unavailable until KBN-101-02 supplies a fail-closed Gateway local +startup route. Do not start current Compose PostgreSQL or infer a local Gateway from PGlite support. +A future reviewed test must use the correct Mosaic CLI package and an independently verified Gateway. Verify: diff --git a/docs/remediation/BOARD-LEDGER.md b/docs/remediation/BOARD-LEDGER.md new file mode 100644 index 00000000..7c9e1560 --- /dev/null +++ b/docs/remediation/BOARD-LEDGER.md @@ -0,0 +1,25 @@ + + +### **D-1 / P-ACTIVATION + hygiene — committed `.npmrc` hard-pins `store-dir=/root/.local/share/pnpm/store`.** + +Correct for the CI container (runs as root), fatal for EVERY non-root local checkout: `EACCES` on `/root/.local/share/pnpm/store/v10/server/server.json`. A committed config that only works on one runtime is exactly the activation-skew class. Fix candidate: make store-dir env-overridable, not hardcoded. + + + +### **D-3 / P-FLEET-001 — the seats running this mission are UNMANAGED.** `mos-remediation`, `rev-974`, + +`planner-opus`, `planner-sol` appear in NO roster (`~/.config/mosaic/fleet/roster.yaml`, `agents/`). Planners run on socket `default`; the roster declares `mosaic-fleet`. This is the exact "one roster-owned socket/host + quarantine unmanaged + stale GC" failure P-FLEET-001 indicts — observed on the remediation mission's own fleet. Prerequisite for INBOX identity-addressing. + +### **D-2 / hygiene — husky `prepare` fails `EPERM` copying into root-owned `.husky/_/`.** Repo working + +tree has root-owned dirs (`.husky/`, repo root) under a non-root agent. Worked around with the intended `HUSKY=0` escape hatch (does NOT disable the existing pre-commit/pre-push hooks). + + + +### **D-5 / P-QUEUE-001 + P-CONFORMANCE-001 — KEYSTONE: an inert gate that erased its own evidence.** + +Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ the CI format gate did not block. An unrelated later PR (#872) then reformatted that file via its own `lint-staged`, so `main` went green again and nobody learned the gate had failed to fire. Verified blob-level under the repo's own config. **Detection must be per-merge-commit against that commit's own tree** — a "is main green today" check reports all-clear on this exact defect. Binding on RM-02/RM-55. Full chain in `TASKS.md` §1a. NOT quiet-patched, by Mos's ruling: patching the symptom destroys the signal. + +### **D-4 / P-LIFECYCLE + hygiene — a dispatched agent silently IGNORED an in-message context reset.** + +planner-sol was at 64.3%/372k; the brief asked it to reset first; it began work on dirty context anyway. Only an out-of-band `/new` driven by the orchestrator guaranteed clean state. Confirms the postmortem thesis: **instructions are not enforcement.** Reset must be a mechanical pre-dispatch step, not a request. diff --git a/docs/remediation/BOARD.md b/docs/remediation/BOARD.md new file mode 100644 index 00000000..88eb9003 --- /dev/null +++ b/docs/remediation/BOARD.md @@ -0,0 +1,93 @@ +# mos-remediation — LIVE BOARD (keep < 8 KB) + +**Phase:** EXECUTING — P0 open. RM-01 MERGED; RM-02 (keystone gate registry) is next. +**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`). + +## Head + +- Mission charter + 15 decisions + 4-build plan: PERSISTED (`docs/remediation/MISSION.md`). +- HOLD lifted for this workstream (Jason 2026-07-31). Nothing implemented yet — planning first. +- Orchestrator seat `mos-remediation` is LIVE and owns the mission. Residency attestation: PASS. +- **TASK-0 DONE** — checkout repaired, all three gates green HONESTLY (no `--no-verify`), branch pushed. +- **TASK-1 DONE** — both planners delivered independently on clean context; reconciled into `TASKS.md` + (58 tasks across P0–P5, 7 convergences, 7 adjudicated disagreements, 3 escalated decisions). +- **NEXT ACTION IS NOT MINE:** DECISION-1/2/3 (`TASKS.md` §5) must be ruled before P0 dispatch. + RM-01 is dispatchable immediately regardless — it depends on nothing and blocks everything. + +## In-flight + +| Task | Owner | State | +| ----------------------------------- | --------------- | ------------------------------------------------------------------------- | +| RM-01 reproducible checkout | — | **MERGED** `f58b3699` (PR #1027) — rev-974 APPROVE + CI #2172 8/8 green | +| RM-02 gate registry ★keystone | unassigned | **READY** — depends only on RM-01; not held by RM-03 | +| RM-03 queue guard (3 defects) | — | HOLD — #1023 SUPERSEDED-PENDING-JASON | +| RM-59 close D-19 residual risk | — | BLOCKED by RM-12/RM-21/RM-25 (spine + executor) — tracked edge, not prose | +| `remediation/state` snapshot → main | mos-remediation | opening at this mission seam | + +## Fleet seats + +- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE +- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — DELIVERED, idle +- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — DELIVERED, idle +- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call +- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason + +## Gate status + +- Delivery gates active: author≠reviewer, diff-blind pre-registered checks, CI-green, merged-PR completion. +- Freeze: LIFTED for this workstream only. +- Git identity: `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what + gate-16 protects; rev-974 reviews, mos-dt-0 never self-reviews). Dedicated identity TRACKED, Mos provisions. +- Capability check (D-11b): before dispatching seat X to provider Y, verify + `~/.config/mosaic/secrets/gitea-tokens/gitea--.token` exists. Token-file set = authoritative + capability registry. Mos owns provisioning; escalate missing pairs to him. +- Seat identity (D-11a): token identity AND `git config user.name`/`user.email` must BOTH be set and + agree. Exporting `MOSAIC_GIT_IDENTITY` alone does NOT fix commit authorship. +- Standing worker-brief doctrine (accreted, mandatory in every brief): don't weaken a RED test to make + it pass; if a check is unrunnable as written SAY SO, never silently substitute; `agent-send -f` never + `-m`; heavy artifacts off shared `/tmp`. +- Remote control: native `/remote-control` NOT wired in this runtime. Path is **Mos-relay** + (Jason ↔ mos-claude via Discord ↔ mos-remediation via agent-send). Not a blocker. + +## Sequencing (from MISSION.md) + +1. Spine + choke-point service (MACP wiring @ mosaic_orchestrator.py::run_single_task) + PG/Redis + ⚠ **CONTESTED — see DECISION-1.** Both planners independently reject this wire-in point: that + controller is `"enabled": false` and references a dispatcher that does not exist here. Charter text + left UNCHANGED pending Mos/Jason ruling; do not treat it as settled. +2. Rotation daemon (finish Mission Control Plane, reuse packages/coord) +3. Comms service (envelope→service→PG/Redis→adapters) +4. Hygiene + conformance harness + Cross-cutting retirements: flat-file tracking, 3 MACP islands, silent MOSAIC BYPASS. + +## Dogfood evidence — live failure classes, not hypotheticals + +> Newest first. Oldest entries roll to `BOARD-LEDGER.md` via `board-roll.sh` when this file +> exceeds its 8 KB cap. Keystone detail is duplicated in `TASKS.md` §1a, so rolling loses nothing. + + + +### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.** + +PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence. + +### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.** + +~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents. + +### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.** + +Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class. + + + +## Decisions log + +- 2026-07-31 — Mission set up by Mos post-postmortem (15/15 decided). Dogfood posture active. +- 2026-07-31 — Mos: stale `.mosaic/orchestrator/mission.json` is RESIDUE of the disabled Python + orchestrator rail that this plan RETIRES. Do NOT invest in it; do NOT build on that rail. The 0/0 + milestone banner is cosmetic. (Supersedes any plan to repair it.) +- 2026-07-31 — Mos: planners must be dispatched with GUARANTEED clean context, not requested-clean. + Prior default-socket planner sessions predate this mission; dirty context is the indicted hygiene. +- 2026-07-31 — mos-remediation: worker briefs forbid all git ops and restrict each worker to a single + named output file, so two planners can share one checkout without a branch race (M2-era incident doctrine). diff --git a/docs/remediation/DECOMP-OPUS.md b/docs/remediation/DECOMP-OPUS.md new file mode 100644 index 00000000..d35acb79 --- /dev/null +++ b/docs/remediation/DECOMP-OPUS.md @@ -0,0 +1,1110 @@ +# DECOMP-OPUS — Adversarial Task Decomposition (ROBUSTNESS SIDE) + +**Author:** `planner-opus` (web1), robustness/pessimist lane. +**Inputs read:** `MISSION.md`, `MACP-WIRING-SCOUT.md`, `BOARD.md`, plus first-hand inspection of +`/src/mosaic-stack` (`packages/{macp,coord,db,queue,forge,mosaic}`, `apps/`, +`packages/mosaic/framework/tools/orchestrator-matrix/controller/mosaic_orchestrator.py`), +`~/.config/mosaic/tools/{git,tmux,lease-broker}/`, and +`jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md`. +**Not read (by charter):** `DECOMP-SOL.md`. +**Output:** 38 tasks, 8 dissents. + +--- + +## 0. Operating premise of this decomposition + +Three assumptions drive every ordering choice below. All three are load-bearing; if you reject one, +the DAG changes. + +**A1 — Every gate is inert until proven otherwise.** Gate-6 (`ci-queue-wait.sh`) passed green +fleet-wide while classifying `unknown` → `exit 0`, and its fix recursed the same defect. I verified +the defect is still live at `~/.config/mosaic/tools/git/ci-queue-wait.sh:282-288`: + +``` +terminal-success|terminal-failure|unknown) exit 0 # "unknown" == pass +*) echo "unrecognized state ... proceeding conservatively"; exit 0 +``` + +`no-status` also exits 0 unless `--require-status` is passed, and nothing passes it +(`pr-merge.sh:98`). A gate whose failure path is unreachable is worse than no gate: it manufactures +evidence. **Therefore: no task in this plan may introduce a gate without a registered +must-exit-nonzero negative control, and the registry check itself is a gate (R-002).** + +**A2 — Fail-open is the resting state of this system.** Confirmed instances, all live today: + +| Site | Failure mode | +| ----------------------------------------- | --------------------------------------------------------------- | +| `ci-queue-wait.sh:282,286` | unknown/unrecognized CI state → proceed | +| `send-message.sh:111` | indeterminate submission → `✓ sent`, exit 0 | +| `packages/forge/src/cli.ts:13-31` | stub `TaskExecutor` reports completion with **empty gate list** | +| `lease-broker/mutator-gate.py:140` | broker socket unset → `return 0` (all tool calls allowed) | +| `lease-broker/revoke-lease.py:57` | broker socket unset → `return 0` (lease never revoked) | +| `lease-broker/receipt-observer-client.py` | entire client replaced by a `sys.exit(0)` stub | + +Four of six are _silent_. Every task below names its fail-closed point explicitly (§4). + +**A3 — The substrate is real, but "wire it in" understates the work.** `packages/db` already has +`missions`, `tasks`, `mission_tasks`, `agents`, `interaction_{sessions,inbox,outbox,checkpoints,handoffs}` +with idempotency-key unique indexes and a compaction-epoch column. `packages/queue` has a +`QueueAdapter` with bullmq + local adapters. That is genuinely more than the charter credits. +But see §6 — roughly a third of this plan is greenfield regardless of the slogan. + +--- + +## 1. Phase map (ordered; hard barriers between phases marked ⛔) + +``` +P0 Make gates provable + stop the fleet re-bricking R-001..R-005 + ⛔ barrier: no gate-introducing task may merge before R-002 +P1 Durable spine (PG) R-010..R-013 + ⛔ barrier: no migration may merge before R-010 +P2 Single choke-point executor (MACP wire-in) R-020..R-030 + ⛔ barrier: R-025 (no-second-path) must land in the same milestone as R-020 +P3 Rotation lifecycle (Mission Control Plane, finished) R-040..R-045 + ⛔ barrier: R-045 (delete /compact guidance) may not merge before R-042 is live +P4 Comms service R-050..R-055 + ⛔ barrier: R-060 (one socket per host) must precede R-052 identity addressing +P5 Retirements, hygiene, conformance R-060..R-065 +``` + +**Deliberate divergence from `MISSION.md` sequencing:** the charter starts at "spine + choke-point +(builds 1+2)". I insert P0 ahead of it. Rationale in Dissent D1. + +--- + +## 2. Master task table + +`tier` = suggested runtime. `codex` = mechanical, spec is unambiguous. `sonnet` = normal feature work. +`opus` = security/integrity/design-ambiguity or cross-cutting invariant. + +| id | title | build | depends_on | est tok | tier | +| ----- | ---------------------------------------------------------------------------------------- | ----- | -------------------------- | ------- | ------ | +| R-001 | Reproducible checkout + pre-push gate that fails on code, not env | 5 | — | 60k | codex | +| R-002 | Gate registry + negative-control CI check (anti-inert-gate harness) | 5 | R-001 | 120k | opus | +| R-003 | Queue-guard fail-closed rework (`unknown`/`no-status`/malformed) | 1 | R-002 | 100k | sonnet | +| R-004 | Break-glass replaces the three silent `MOSAIC BYPASS` fail-opens | 1 | R-005, R-002 | 120k | opus | +| R-005 | Activation/version coherence: block launch on host↔repo skew, fail SAFE | 5 | R-001 | 140k | sonnet | +| R-010 | Fix the Drizzle postgres-tier first-install migration defect | 2 | R-001 | 90k | sonnet | +| R-011 | Orchestration spine schema (macp_tasks, attempts, gate_results, ledger, claims) | 2 | R-010 | 160k | opus | +| R-012 | Spine client with fail-closed connection semantics (no silent PGlite in prod) | 2 | R-011 | 80k | sonnet | +| R-013 | Transactional outbox tables + reconciliation sweeper (PG-first) | 2 | R-012 | 140k | opus | +| R-020 | Production `TaskExecutor` backed by `@mosaicstack/macp` | 1 | R-012, R-002 | 220k | opus | +| R-021 | Gate-runner hardening: structured `GateEntry`, `fail_on`, timeout, empty-gate-set = fail | 1 | R-020 | 120k | sonnet | +| R-022 | Hash-chained append-only MACPEvent ledger in PG; lifecycle EventType extension | 1 | R-020, R-011 | 160k | opus | +| R-023 | `packages/coord` submits work through the executor (retire direct spawn) | 1 | R-020 | 140k | sonnet | +| R-024 | `mosaic yolo\|claude\|codex\|pi` launch path records a typed Task + events | 1 | R-020, R-022 | 160k | sonnet | +| R-025 | No-second-path gate: terminal status writable only by the executor | 1 | R-020, R-022 | 140k | opus | +| R-026 | Delete the Forge stub executor; Forge submits through the real one | 1 | R-020 | 90k | codex | +| R-027 | Seat identity from `MOSAIC_AGENT_NAME` + mandatory tri-state write outcomes | 1 | R-020 | 150k | opus | +| R-028 | Typed state claims (source/confidence/TTL) with HMAC integrity, fail-closed | 1 | R-011, R-020 | 170k | opus | +| R-029 | Contract-hash binding; stale generation loses mutation authority mechanically | 1 | R-020, R-028 | 180k | opus | +| R-030 | Retire the Python controller + `plugins/macp` island (island count 3 → 1) | 1 | R-023, R-024, R-025, R-026 | 110k | codex | +| R-040 | Durable compaction/token sensor (per-runtime thresholds, PreCompact event) | 3 | R-022, R-029 | 130k | sonnet | +| R-041 | Typed checkpoint writer (structured claims, not transcript) + digest | 3 | R-028, R-040 | 150k | opus | +| R-042 | Rotation daemon: watch → checkpoint → kill → relaunch → rehydrate | 3 | R-041, R-023 | 240k | opus | +| R-043 | Rehydration attestation gate: refuse to act on an incomplete claim set | 3 | R-041 | 130k | opus | +| R-044 | Fault-injected rotation soak (100× incl. kill-at-worst-moment) | 3 | R-042, R-043 | 180k | sonnet | +| R-045 | Delete `/compact and continue` from the persistent-seat path (substitution) | 3 | R-042, R-044 | 60k | codex | +| R-050 | `comms/v1` envelope + protocol-version negotiation, LOUD reject | 4 | R-011 | 140k | opus | +| R-051 | Comms service: PG state machine PENDING→RECEIVED→CONSUMED→DEAD-LETTER | 4 | R-050, R-013 | 200k | opus | +| R-052 | tmux transport adapter behind the service; durable retry before cursor advance | 4 | R-051, R-060 | 160k | sonnet | +| R-053 | Per-class coalescing + supersede (stale-consumed-as-live fix) | 4 | R-051 | 130k | sonnet | +| R-054 | Redis hot path + provenance guard (Redis may never be read as authority) | 4 | R-051, R-013 | 170k | opus | +| R-055 | Retire direct tmux sends; only the service may write a pane | 4 | R-052, R-053 | 100k | codex | +| R-060 | One roster-owned socket/host, quarantine unmanaged, stale-session GC | 5 | R-005 | 150k | sonnet | +| R-061 | Auto-sync allowlist (never auto-stage unknown paths) + worktree isolation | 5 | R-002 | 110k | sonnet | +| R-062 | Flat-file orchestration tracking → DB cutover, with rollback artifact | 2/5 | R-024, R-028, R-042 | 200k | opus | +| R-063 | Conformance harness: fault injection of the six live failure classes | 5 | R-044, R-051, R-062 | 260k | opus | +| R-064 | Fleet-wide inert-gate audit against the R-002 registry | 5 | R-002 | 120k | sonnet | +| R-065 | Retirement proof: CI asserts all three retirements are complete and stay complete | 5 | R-030, R-055, R-062 | 90k | codex | + +**Totals:** 38 tasks, ~5.3M estimated worker tokens. Critical path (longest chain): +`R-001 → R-010 → R-011 → R-012 → R-020 → R-022 → R-029 → R-040 → R-041 → R-042 → R-044 → R-062 → R-063` += 13 tasks, ~2.2M tokens. Everything else parallelizes against it. + +--- + +## 3. Task detail + +Acceptance criteria are written **diff-blind**: each is a command a reviewer can pre-register and run +against the merged branch without having read the implementation. `⇒0` / `⇒≠0` denote required exit +codes. Every gate-introducing task carries at least one **must-fail** case — that is the whole point. + +### P0 — Make gates provable + +--- + +**R-001 — Reproducible checkout + pre-push gate that fails on code, not env** +_build 5 · depends: — · 60k · codex_ + +The mosaic-stack checkout on web1 cannot push: `pre-push` typecheck fails on `Cannot find module +commander/@mosaicstack/*` because deps were never installed. A gate that reds on environment is +indistinguishable from a gate that reds on defect, so the fleet learns to bypass it. This is the +mission's own TASK-0 and it blocks literally every other PR. + +- AC1: from a clean clone in the CI container, `pnpm install --frozen-lockfile && pnpm -w typecheck` ⇒0. +- AC2: `scripts/preflight.sh` (or equivalent named in the PR) run with node_modules removed ⇒≠0 with a + message containing `run pnpm install`, and ⇒0 after install. **Negative control: the missing-deps + case must be distinguishable by exit code or a machine-greppable marker from a real type error.** +- AC3: introduce `export const x: number = "s"` in any package → gate ⇒≠0. Revert → ⇒0. +- AC4: `git status --porcelain` in a fresh clone after a full build is empty (no generated residue). +- Dogfood: live finding 2026-07-31 — "pre-push gate red on env not code"; `.bak`/`.mosaic-bak` and + untracked `apps/coordinator` residue in the checkout. +- Fail-closed point: none introduced; this restores the _ability_ to fail closed. + +--- + +**R-002 — Gate registry + negative-control CI check (anti-inert-gate harness)** ★ keystone +_build 5 · depends: R-001 · 120k · opus_ + +The generalization of P-QUEUE-001. A machine-readable registry (`gates.manifest.json` or equivalent) +where every deterministic gate in the repo declares: invocation, the input classes it must +distinguish, and for **each** class the required exit code. A CI job runs every declared case and +fails if (a) any must-fail case exits 0, (b) any gate in the tools tree is absent from the registry, +or (c) a gate has zero must-fail cases registered. + +- AC1: `pnpm gate:verify` ⇒0 on main. +- AC2: mutate any registered gate's failure branch to `exit 0` → `pnpm gate:verify` ⇒≠0 naming that + gate. **This is the meta-negative-control; a reviewer must run it.** +- AC3: add a new executable under the gates path without a registry entry → ⇒≠0 with `unregistered gate`. +- AC4: registry entry with zero must-fail cases → ⇒≠0 with `no negative control`. +- AC5: the check runs in CI on every PR, not only on gate-file changes (an inert gate is usually made + inert by a change _elsewhere_, e.g. an env var going unset). +- Dogfood: gate-6 inert fleet-wide; #1019 fix (PR #1023 @f6334080) recursed the same defect — + rev-974 review id 58 finding (3): "tests don't assert exit outcomes." +- Fail-closed point: **unregistered gate ⇒ build fails.** Not "warn". +- Secretly greenfield: yes, small. Accept it — it is the cheapest insurance in the plan. + +--- + +**R-003 — Queue-guard fail-closed rework** +_build 1 · depends: R-002 · 100k · sonnet_ + +`ci-queue-wait.sh` must stop treating `unknown`, unrecognized, and `no-status` as pass. Spec is +rev-974 review id 58: (1) `unknown` must not exit 0; (2) payload via stdin/temp file, never argv +(ARG_MAX rc126 at ~150 KiB); (3) tests assert exit outcomes. + +- AC1: synthetic status payloads for each class ⇒ `pending` (loops then times out ⇒124), + `terminal-success` ⇒0, `terminal-failure` ⇒≠0, `no-status` ⇒≠0, `malformed JSON` ⇒≠0, + `unknown` ⇒≠0. Each is a registered R-002 case. +- AC2: a 150 KiB status payload ⇒ not 126, and classification unchanged. +- AC3: `--force-proceed` (if provided) requires a reason, emits a WARN to stderr, and is a registered + break-glass per R-004 — never a silent default. +- AC4: `pr-merge.sh` invokes the guard with `--require-status`; removing that flag ⇒ R-002 fails. +- Dogfood: gate-6 inert + #1019 recursion. +- Fail-closed point: **every non-green classification blocks the merge.** +- ⚠ **Authority conflict:** #1019/PR #1023 is explicitly PARKED under Mos ("delivery stack still + parked"), while Build 1 absorbs QUEUE and HOLD is lifted for this workstream. The orchestrator must + resolve ownership with Mos _before dispatch_, or two lanes will rework the same branch. Flagged, not + assumed. + +--- + +**R-004 — Break-glass replaces the three silent `MOSAIC BYPASS` fail-opens** +_build 1 · depends: R-005, R-002 · 120k · opus_ + +Convert `mutator-gate.py:140`, `revoke-lease.py:57`, and the `receipt-observer-client.py` stub from +permanent silent fail-open into LOUD + AUDITED + TEMPORARY break-glass: explicit opt-in token with an +expiry timestamp, a WARN on stderr every invocation, an audit line written on every use, and refusal +once expired. + +- AC1: with break-glass unset and the broker absent → gate ⇒≠0 (denies) with `GATE_UNAVAILABLE`. +- AC2: with a valid unexpired break-glass token → ⇒0, **and** an audit record exists containing seat, + reason, expiry, and invocation count. +- AC3: with an expired token → ⇒≠0. (Registered must-fail case.) +- AC4: `grep -rn "MOSAIC BYPASS" ~/.config/mosaic/tools/` returns nothing after deploy; a CI check + fails if the marker reappears without a matching registry entry. +- AC5: the Stop-hook client never blocks a turn — observational hooks exit 0 on transport failure + **but record a delivery-failure event**; silence is the bug, not the exit code. +- Dogfood: Pi brick (fail-closed gate whose recovery needed the gate it was blocked by) + the + 2026-07-22 fleet incident that motivated all three bypasses. +- Fail-closed point: **broker absent + no break-glass = deny.** +- ⚠ **Ordering hazard (severe):** merging R-004 before R-005 re-bricks the fleet exactly as on + 2026-07-22. The bypasses exist because the broker daemon was never _deployed_ on this host, not + because the code was wrong. Removing the fail-open before deployment coherence is real recreates the + incident. **Hard edge: R-005 → R-004.** + +--- + +**R-005 — Activation/version coherence: block launch on host↔repo skew, fail SAFE** +_build 5 · depends: R-001 · 140k · sonnet_ + +P-ACTIVATION-001. Transactional install of CLI + hooks + broker + version; launch refuses to start a +seat whose deployed framework version disagrees with the repo/roster expectation. "Fail SAFE" per +Jason's caveat = clear message + exact fix command, not a bare hard stop. + +- AC1: with matching versions, `mosaic claude` launches ⇒0. +- AC2: with a deliberately skewed deployed version, launch ⇒≠0 and stdout contains both versions and a + copy-pasteable remediation command. (Registered must-fail case.) +- AC3: interrupted install (kill mid-way) leaves the previous version fully functional — no + half-installed hook set. Verified by: install, `kill -9` at a scripted point, then run the previous + version's smoke test ⇒0. +- AC4: `mosaic doctor` reports broker deployed/undeployed **honestly** — the label must change when + the daemon is stopped. (Direct fix for P-RECOVERY-001's "honest capability labeling".) +- Dogfood: the 2026-07-22 incident root cause ("#828 lease-broker daemon + launch-time env injection + were never deployed on this host"); the messy-checkout finding. +- Fail-closed point: **version skew blocks launch.** + +### P1 — Durable spine + +--- + +**R-010 — Fix the Drizzle postgres-tier first-install migration defect** ★ hidden blocker +_build 2 · depends: R-001 · 90k · sonnet_ + +`packages/db/src/migrate.ts:30-38` carries a TODO admitting that **postgres-tier first-install fails +today**: Drizzle wraps all migrations in one transaction (breaking 0009's `ALTER TYPE ADD VALUE` → +`SET DEFAULT` sequence) and `drizzle/meta/_journal.json` orders 0009 before 0008, which the +postgres-js migrator silently skips by `created_at < folderMillis`. The PGlite path sidesteps both — +which means the spine has only ever been proven on the embedded tier. + +- AC1: against a **fresh empty** Postgres 16 container, `runMigrations()` ⇒0 and every table in + `schema.ts` exists (assert by count and by name list). +- AC2: `drizzle.__drizzle_migrations` contains one row per migration file, in file order. +- AC3: re-running `runMigrations()` on the migrated DB ⇒0 and is a no-op (row count unchanged). +- AC4: a deliberately corrupted journal ⇒≠0 (must not silently skip). Registered must-fail case. +- Dogfood: synthetic — no live incident yet, because nobody has first-installed the PG tier. That is + precisely the danger: the first real deploy is the discovery event. +- Fail-closed point: **skipped migration ⇒ error, never silent skip.** +- ⚠ **Ordering hazard:** every later task that adds a migration (R-011, R-013, R-022, R-028, R-050, + R-051) silently depends on this. Without it they pass on PGlite in CI and fail on the real deploy. + +--- + +**R-011 — Orchestration spine schema** +_build 2 · depends: R-010 · 160k · opus_ + +New tables, **not** overloads of existing ones: `macp_tasks` (typed MACP `Task`), `task_attempts`, +`gate_results`, `macp_events` (append-only, monotonic sequence, `prev_hash`/`hash` chain), +`state_claims` (typed claim with source/confidence/TTL + HMAC), `seat_identities`, +`contract_bindings`. Reuse `missions`/`mission_tasks` for mission linkage. + +- AC1: migration applies to fresh PG ⇒0 (per R-010 AC1). +- AC2: `UPDATE macp_events SET ...` or `DELETE FROM macp_events` ⇒ error (append-only enforced by + trigger/rule or role grants, not convention). Registered must-fail case. +- AC3: inserting an event whose `prev_hash` does not match the current head ⇒ error. +- AC4: a verification function reports gap/tamper on an artificially altered chain ⇒≠0. +- AC5: `state_claims` rows carry a non-null TTL; a query helper returns expired claims as _expired_, + never as valid. +- Dogfood: P-AUDIT-001's duplicate ledger (`.mosaic/orchestrator/events.ndjson` written by + `mosaic_orchestrator.py:129-133` and only _read_ by `plugins/mosaic-framework`). +- Fail-closed point: **ledger mutation attempt = hard DB error.** +- Design note (deliberate divergence): the existing `events` table (`schema.ts:264`) is a generic + `type/title/description/date` record — a calendar-shaped table. Overloading it as the audit ledger + would defeat append-only enforcement. Separate table. + +--- + +**R-012 — Spine client with fail-closed connection semantics** +_build 2 · depends: R-011 · 80k · sonnet_ + +`packages/db` exposes both `createDb` (postgres) and `createPgliteDb` (embedded). If production can +ever silently land on PGlite, two seats will happily write to two different "systems of record." + +- AC1: with `NODE_ENV=production` and no `DATABASE_URL`, client construction ⇒ throws (not a fallback). + Registered must-fail case. +- AC2: with `DATABASE_URL` set to an unreachable host, the first operation ⇒ throws within the + configured timeout; no in-memory substitute is created. +- AC3: PGlite construction in production requires an explicit `MOSAIC_ALLOW_EMBEDDED_DB=1`; absent it, + ⇒ throws. +- AC4: a health endpoint/CLI reports which tier is in use, and the string differs between tiers. +- Dogfood: synthetic (the split-brain this prevents has not happened yet _because_ the spine is not + wired — it would happen on week one of Build 2). +- Fail-closed point: **no DB ⇒ no work.** The choke point must not degrade to local files. + +--- + +**R-013 — Transactional outbox + reconciliation sweeper** +_build 2 · depends: R-012 · 140k · opus_ + +PG commit first, then enqueue. A sweeper deterministically re-enqueues rows that committed in PG but +never reached the hot queue. `interaction_outbox` already has the right shape +(`idempotency_key` unique per session, status enum, `content_digest`) — reuse it rather than adding a +fourth queue concept. + +- AC1: kill the process between PG commit and enqueue → sweeper re-enqueues within N seconds; the + consumer sees the message exactly once (idempotency key enforced). +- AC2: enqueue the same idempotency key twice → one delivery, one PG row. Registered must-fail case is + the inverse: a duplicate delivery ⇒ test fails. +- AC3: with Redis unavailable, writes still commit to PG and are marked `pending`; nothing is lost and + nothing reports delivered. +- AC4: sweeper is idempotent — running it twice concurrently produces no duplicates (advisory lock). +- Dogfood: MACP scout notification BOUNCE ("tmux target not found") — never RECEIVED, no retry, found + only by manual liveness check. +- Fail-closed point: **an un-acked message is never marked delivered.** + +### P2 — The single choke point + +--- + +**R-020 — Production `TaskExecutor` backed by `@mosaicstack/macp`** ★ keystone +_build 1 · depends: R-012, R-002 · 220k · opus_ + +One Node executor: validate a typed `Task` (schemas exist at `packages/macp/src/schemas`), call +`resolveCredentials`, run the process, call `runGates`, emit `MACPEvent`s, persist a `TaskResult`. +Exposed as a programmatic API **and** a CLI. Note the scout's observation that there is no exported +programmatic `submit` — that is this task. + +- AC1: submitting a Task that fails schema validation ⇒≠0 before any process spawn; assert no child + process was created and no event row exists. Registered must-fail case. +- AC2: a Task whose provider credentials cannot be resolved ⇒≠0 with `CredentialError`; **assert the + credential value never appears in stdout, stderr, the event ledger, or the task row.** +- AC3: a successful Task produces, in order: `task.assigned`, `task.started`, `rail.check.*` per gate, + `task.completed` — all in `macp_events`, chain-valid. +- AC4: a Task whose gate fails is recorded `failed`/`gated`, never `completed`. +- AC5: killing the executor mid-run leaves the task in a non-terminal state that the sweeper can + reclaim — **never** `completed`. +- AC6: two executors racing on the same task id → exactly one runs (row-level claim). +- Dogfood: the entire "stranded MACP" finding — zero production calls to + `runGates`/`emitEvent`/`resolveCredentials`. +- Fail-closed points: schema-invalid ⇒ reject; credentials unresolved ⇒ reject; crash ⇒ non-terminal. +- ⚠ **I dissent from the scout's chosen wire-in point.** See Dissent D2 — wiring into + `mosaic_orchestrator.py::run_single_task` puts the choke point inside a controller that is + `"enabled": false`. Wire the live path instead (R-023/R-024) and delete the Python rail (R-030). + +--- + +**R-021 — Gate-runner hardening** +_build 1 · depends: R-020 · 120k · sonnet_ + +The Python rail runs raw string gates (`mosaic_orchestrator.py:213-235`) with no `type`/`fail_on` +support. The package runner supports structured `GateEntry`. Hardening: honor `fail_on`, enforce +per-gate timeouts distinct from the task timeout, and **treat an empty gate list as failure for +gate-required task types** rather than as vacuous success. + +- AC1: `quality_gates: []` on a `coding` task ⇒ task not `completed`; error names `no gates +configured`. Registered must-fail case — this is the Forge-stub bug generalized. +- AC2: a gate exceeding its timeout ⇒ `timed_out: true` and `passed: false` (never `passed: true`). +- AC3: `fail_on: blocker` with 3 non-blocking findings ⇒ pass; with 1 blocker ⇒ fail. +- AC4: a gate command that does not exist (rc 127) ⇒ fail, not skip. +- Dogfood: `packages/forge/src/cli.ts:13-31` stub executor "immediately reports completion with empty + gates" — an inert gate by construction. +- Fail-closed point: **absent/unrunnable gates fail the task.** + +--- + +**R-022 — Hash-chained MACPEvent ledger + lifecycle EventType extension** +_build 1 · depends: R-020, R-011 · 160k · opus_ + +Emitter targets PG (via R-011), not a caller-supplied NDJSON path. Extend `EventType` from +task-centric to lifecycle-first-class: `session.launched`, `mission.generated`, +`compaction.detected`, `session.rotated`, `recovery.started`, `breakglass.used`, +`inbox.received`, `inbox.consumed`, `terminal.disposition`. Executor-emitted, so lines are identical +across Claude/Codex/Pi. + +- AC1: run the same Task under two runtimes → event sequences are byte-identical modulo + `event_id`/`timestamp`/`source`. **This is the runtime-neutrality claim; assert it, don't assume it.** +- AC2: chain verification ⇒0 on a healthy ledger, ⇒≠0 after any row alteration. Registered must-fail case. +- AC3: an emit that fails to persist ⇒ the operation it describes fails (no fire-and-forget logging on + the authority path). +- AC4: `grep -rn "events.ndjson"` in production paths returns nothing (the duplicate island is gone — + enforced permanently by R-065). +- Dogfood: P-AUDIT-001 duplicate Python ledger; `#1018` reconstruction required manual archaeology. +- Fail-closed point: **unpersistable event ⇒ operation fails.** (Yes, this trades availability for + auditability. That is the decision on record.) + +--- + +**R-023 — `packages/coord` submits through the executor** +_build 1 · depends: R-020 · 140k · sonnet_ + +`packages/coord/src/runner.ts:397-427` spawns a child process directly and tracks tasks in +`docs/TASKS.md` + `.mosaic/orchestrator/mission.json`. Replace the spawn with an executor submit; +keep coord's session-lock/crash-recovery, which are good and already durable. + +- AC1: `mosaic coord run` on a fixture mission produces `macp_tasks` + `macp_events` rows; no direct + `spawn` of the runtime remains in `runner.ts` (assert by AST/grep in the R-025 check). +- AC2: crash recovery still works — kill mid-task, resume, and the task is reclaimed exactly once. +- AC3: coord no longer _writes_ `docs/TASKS.md` as authority (read-only mirror at most, per R-062). +- Dogfood: coord/macp "disconnected islands" finding. +- Fail-closed point: **coord cannot complete a task the executor did not run.** + +--- + +**R-024 — Launch path records a typed Task** +_build 1 · depends: R-020, R-022 · 160k · sonnet_ + +`packages/mosaic/src/commands/launch.ts:730-843,1102-1167` dispatches `mosaic yolo|claude|codex|pi` +straight to harness launch with no Task, no gates, no events. This is the path humans and the fleet +actually use — leaving it outside the choke point leaves the choke point decorative. + +- AC1: `mosaic yolo …` creates a `macp_tasks` row and `session.launched` event before the + harness starts. +- AC2: the launch is refused if the spine is unreachable (per R-012). Registered must-fail case. +- AC3: seat identity is bound at launch and present on every subsequent event from that seat. +- AC4: an interactive/ephemeral launch class is supported explicitly and is _labeled_ as ungated — + honest capability labeling, not a silent hole. +- Dogfood: "direct `mosaic yolo|claude|codex|opencode|pi` also bypasses it" (scout, §2). +- Fail-closed point: **no spine ⇒ no launch** (for managed seats). + +--- + +**R-025 — No-second-path gate** ★ the invariant that makes R-020 mean anything +_build 1 · depends: R-020, R-022 · 140k · opus_ + +A choke point is only a choke point if nothing else can write a terminal outcome. Two layers: +(1) DB — only the executor's role may insert/transition to terminal status; (2) CI — a static check +that fails the build if any file outside the executor writes terminal task status, appends to the +ledger, or spawns a runtime process directly. + +- AC1: a test that connects as a non-executor role and attempts to set `status='completed'` ⇒ error. + Registered must-fail case. +- AC2: add a file that writes terminal status → CI ⇒≠0 naming the file. **A reviewer must actually add + such a file and watch it fail.** +- AC3: the check enumerates its own coverage (which paths it scanned) so an empty scan is visible — + an inert _checker_ is the same disease. +- AC4: known-legitimate exceptions live in an explicit allowlist with a reason string; an empty-reason + entry ⇒≠0. +- Dogfood: three parallel MACP islands, each able to declare a task complete. +- Fail-closed point: **unknown writer ⇒ build fails / DB rejects.** +- ⚠ **Ordering hazard:** if R-025 lands a milestone _after_ R-020, the intervening period is when a + fourth island gets built, and R-025 then arrives as a large adversarial refactor nobody wants to + merge. Ship them together. + +--- + +**R-026 — Delete the Forge stub executor** +_build 1 · depends: R-020 · 90k · codex_ + +- AC1: `mosaic forge run` on a fixture pipeline produces real gate results; a fixture with a failing + gate ⇒≠0. Registered must-fail case (today it would exit 0 with empty gates). +- AC2: `grep -n "stub" packages/forge/src/cli.ts` returns nothing; type-only MACP imports become value + imports. +- Dogfood: `packages/forge/src/cli.ts:13-31,167,185`. +- Fail-closed point: **no executor injected ⇒ forge refuses to run** (not "runs with a stub"). + +--- + +**R-027 — Seat identity + mandatory tri-state write outcomes** +_build 1 · depends: R-020 · 150k · opus_ + +Two halves of P-WRAPPER-001. (a) Identity derives from `MOSAIC_AGENT_NAME` at launch, survives +respawn, resolved through the executor's credential binding, fail-closed. (b) Every write/send +wrapper returns exactly one of `written+verified` / `written+unverified` / `not-written`, with distinct +exit codes. Today `send-message.sh:111` prints `✓ sent … (submission state indeterminate)` and +**exits 0** — a written+unverified reported as success, the discarded-measurement anti-pattern. + +- AC1: respawn a seat pane; the seat's identity is unchanged and `MOSAIC_GIT_IDENTITY` resolves + without being inlined by hand. (rev-974's respawn dropped it → token-not-found → could not post.) +- AC2: with identity unresolvable, the wrapper ⇒≠0 and performs no write. Registered must-fail case. +- AC3: force an indeterminate submission (target pane exists, no verification possible) ⇒ exit code + distinct from both success and hard failure, and the string `unverified` on stderr. +- AC4: a caller that treats any nonzero as success is caught by an R-002 registry case. +- AC5: safe target metadata (repo / id / head SHA) is named in the wrapper contract and verified by + construction — a review posted to the wrong SHA ⇒≠0. +- Dogfood: rev-974 identity drop (OpenBrain 33bef845); pepper written+verified; the + written+unverified silent-success class. +- Fail-closed point: **cannot verify ⇒ never report success.** + +--- + +**R-028 — Typed state claims with HMAC integrity** +_build 1 · depends: R-011, R-020 · 170k · opus_ + +Replace the prose-blob checkpoint with typed claims: `{key, value, source, confidence, ttl, +refreshed_at}`, signed. A corrupt or unsigned claim set refuses to rehydrate and forces refresh. + +- AC1: write a claim set, flip one byte, attempt rehydrate ⇒≠0 with `integrity`. Registered must-fail case. +- AC2: an expired claim is returned as expired and cannot satisfy a required-claim check. +- AC3: an unsigned claim set (legacy/prose) ⇒ refuse, with a named migration path. +- AC4: the HMAC key is resolved through the credential path and never appears in any output. +- Dogfood: `MOS-ORCHESTRATION-BOARD-LIVE.md` is the model-maintained manual prototype of this; + Jason's "assuming data not corrupted" is the requirement being mechanized. +- Fail-closed point: **corrupt checkpoint ⇒ refuse-rehydrate + force refresh.** + +--- + +**R-029 — Contract-hash binding; stale generation loses authority** +_build 1 · depends: R-020, R-028 · 180k · opus_ + +Session binds a hash of (Constitution + AGENTS + runtime contract + skill set) at launch **and** at +rotation. On policy change or compaction-detected, the seat must re-attest before acting. The executor +refuses mutations carrying a stale generation. + +- AC1: submit a task with a stale contract hash ⇒ rejected with `stale_generation`. Registered must-fail case. +- AC2: change any directive file → the next submit from an unrefreshed seat ⇒ rejected. +- AC3: after re-anchor, the same submit ⇒0. +- AC4: the rejection is _mechanical_ — assert it happens with the seat's LLM removed from the loop + (a scripted client reproduces it). +- Dogfood: the postmortem's opening incident — a compacted orchestrator that kept acting after its + directives rotted; this very mission's `CLAUDE.md` context-rot guard is the prose version. +- Fail-closed point: **stale hash ⇒ mutations rejected.** +- Note: this is the single most likely task to produce fleet-wide breakage on rollout. Ship behind a + report-only mode first (log rejections without enforcing) for one measured window, then flip. + Report-only mode must itself be time-boxed and expire — see D5. + +--- + +**R-030 — Retire the Python controller + `plugins/macp` island** +_build 1 · depends: R-023, R-024, R-025, R-026 · 110k · codex_ + +Delete `mosaic_orchestrator.py`'s exec/gate/event block and the redefined types in +`plugins/macp/src/macp-runtime.ts:43-77`; repoint the OpenClaw ACP backend at the Node executor. Also +removes the dangling reference to `tools/macp/dispatcher/pi_runner.ts`, which does not exist in this +checkout. + +- AC1: island count is 1 — `grep -rn "def emit_event\|def append_event" packages/` returns nothing. +- AC2: `plugins/macp` integration test drives a task end-to-end through the Node executor. +- AC3: `.mosaic/orchestrator/{tasks,state}.json` and `events.ndjson` are no longer written by any code + path (assert by running a full mission and checking mtimes). +- AC4: a re-added duplicate emitter ⇒ R-025/R-065 check fails. +- Dogfood: "three parallel islands, none wired." +- Fail-closed point: n/a — this is a deletion; its guard is R-065. + +### P3 — Rotation lifecycle + +--- + +**R-040 — Durable compaction/token sensor** +_build 3 · depends: R-022, R-029 · 130k · sonnet_ + +Nothing durable tracks compactions today (the Claude compaction hook "revokes leases only"). Add a +per-seat token/compaction counter persisted to the spine, with per-runtime thresholds +(Claude ~200k, Codex/Pi ~372k) as configuration, not constants in code. + +- AC1: driving a seat past the configured threshold emits `compaction.pending` with the observed count. +- AC2: an actual harness compaction emits `compaction.detected` — assert by triggering a real compaction, + not by unit-mocking the hook. (A mocked sensor is an inert sensor.) +- AC3: sensor failure (hook not installed) ⇒ the seat is reported _unmonitored_, and managed seats + refuse to start unmonitored. Registered must-fail case. +- AC4: counters survive a seat respawn. +- Dogfood: this orchestrator lineage itself — a compacted session that kept operating. +- Fail-closed point: **no sensor ⇒ seat not managed ⇒ launch refused.** + +--- + +**R-041 — Typed checkpoint writer** +_build 3 · depends: R-028, R-040 · 150k · opus_ + +Structured essentials only — goal, current state, completed, blocked, next steps, constraints +(mission-control FR-5) — as signed claims. **Never the transcript.** Atomic write + digest, into +`interaction_checkpoints` (which already has `checkpoint_id`, `content_digest`, `compaction_epoch`). + +- AC1: checkpoint written; digest verifies; `compaction_epoch` increments monotonically. +- AC2: kill the writer mid-write → no partial checkpoint is readable (atomic rename or tx). Registered + must-fail case: a torn checkpoint must be rejected, not repaired-by-guess. +- AC3: the checkpoint contains no transcript text — assert by size bound and by a content check that + rejects raw conversational turns. +- AC4: required claim classes are declared in a manifest; a checkpoint missing one is _invalid at write + time_, not at read time. +- Dogfood: the abandoned coordinator PoC "compacted THE CHAT (the anti-pattern) not canonical mission + state." +- Fail-closed point: **incomplete claim set ⇒ checkpoint rejected.** + +--- + +**R-042 — Rotation daemon** ★ the build-3 deliverable +_build 3 · depends: R-041, R-023 · 240k · opus_ + +The missing piece: a deterministic program (no LLM in the loop) that watches the sensor, checkpoints +atomically, kills the old session, launches a fresh one, and rehydrates. Reuse coord's +`buildContinuationPrompt`, session lock, `writeAtomicJson`, and crash recovery. The abandoned +`apps/coordinator` PoC stalled exactly here: `_spawn_agent()` was a stub and `_check_context()` only +_logged_ "rotation needed" and never called `trigger_rotation()`. + +- AC1: a seat crossing threshold rotates without operator action; the new seat reports the same mission + and next action. +- AC2: **`_check_context`-equivalent regression test:** a test asserts that threshold-crossing actually + _invokes_ rotation (not merely logs it). This is the specific historical failure — assert the call, + not the log line. Registered must-fail case: stub out the rotation call → test ⇒≠0. +- AC3: no LLM call occurs in the rotation path — assert by running the daemon with network/model access + denied; rotation still completes. +- AC4: rotation is idempotent under double-trigger (only one new session). +- AC5: if checkpoint or rehydration fails, the daemon does **not** kill the old session. +- Dogfood: `apps/coordinator` residue (Jan–Feb 2026, deliberately abandoned). +- Fail-closed points: **checkpoint failure ⇒ no kill**; **rehydration failure ⇒ no promote** (R-043). +- ⚠ Secretly greenfield: the daemon does not exist in any form. `packages/coord` has primitives, not a + daemon. Budget accordingly. + +--- + +**R-043 — Rehydration attestation gate** +_build 3 · depends: R-041 · 130k · opus_ + +The pessimist's core objection to rotation: an incomplete rehydration produces a clean-looking session +with a silent gap — worse than a compacted one, because nothing signals the loss. A fresh seat must +attest that every required claim class is present and digest-valid _before_ it is permitted to act. + +- AC1: with a complete claim set, the new seat's first mutation ⇒0. +- AC2: with one required claim class removed, the new seat's first mutation ⇒ rejected with + `rehydration_incomplete`, and the seat is quarantined, not silently continued. Registered must-fail case. +- AC3: attestation is enforced executor-side (R-029 machinery), so a seat cannot self-certify. +- AC4: the operator sees a distinct, greppable state for `rehydrated-degraded`. +- Dogfood: this session lineage's hand-run `mosaic-context-refresh` "fail-closed residency attestation" + — the prose version of exactly this gate. +- Fail-closed point: **incomplete rehydration ⇒ seat may not mutate.** + +--- + +**R-044 — Fault-injected rotation soak** +_build 3 · depends: R-042, R-043 · 180k · sonnet_ + +The "100 rotations lossless" bar, made adversarial. 100 clean rotations prove almost nothing; the +failure mode is a kill at the worst moment. + +- AC1: 100 consecutive rotations, claim-set diff empty at every boundary (machine-compared, not + eyeballed). +- AC2: fault matrix, each ≥10 iterations: kill during checkpoint write; kill after checkpoint before + old-session kill; kill after kill before relaunch; DB unavailable during checkpoint; corrupt + checkpoint; two daemons racing; contract hash changed mid-rotation. **Each must end in a defined + state — either fully rotated or fully not; never half.** +- AC3: any iteration ending `rehydrated-degraded` fails the suite. +- AC4: the suite runs against real runtime artifacts (real seats, real spine), not mocks — + P-CONFORMANCE-001 criterion (1). +- Dogfood: all of P-LIFECYCLE-001; the harness is the P-CONFORMANCE down payment. +- Fail-closed point: **any half-state ⇒ suite red ⇒ rotation does not ship.** + +--- + +**R-045 — Delete `/compact and continue` from the persistent-seat path** +_build 3 · depends: R-042, R-044 · 60k · codex_ + +Removal = substitution. Keep it for ephemeral seats (one doctrine per session class). + +- AC1: `grep -rn "compact and continue"` in orchestrator/persistent guidance returns nothing. +- AC2: ephemeral-seat guidance still contains it, and a test asserts the two session classes are + distinguished by a machine-readable attribute, not by prose. +- AC3: a persistent seat attempting the old flow is redirected to rotation by the daemon. +- Dogfood: P-GUIDE-001; the "live irony" that the deciding session was itself a hand-re-anchored + compacted orchestrator. +- Fail-closed point: n/a (guidance change) — but see the hazard. +- ⚠ **Ordering hazard (severe):** merging this before R-042/R-044 are _live on the fleet_ removes the + coping mechanism while leaving the failure in place. Hard edge, and the orchestrator should verify + deployment, not just merge. + +### P4 — Comms service + +--- + +**R-050 — `comms/v1` envelope + protocol-version negotiation** +_build 4 · depends: R-011 · 140k · opus_ + +Version the protocol, not the participants. Envelope carries `comms/v1`; receiver rejects unsupported +versions LOUDLY; N-version compatibility window; framework version is diagnostic-only (avoids the N² +matrix). This is the first breaking comms change, so the negotiation must exist before anything rides +on it. + +- AC1: a `comms/v1` envelope round-trips; digest and correlation id preserved. +- AC2: a `comms/v99` envelope ⇒ rejected with a machine-readable `unsupported_protocol_version` and a + visible operator message. Registered must-fail case. +- AC3: a malformed/unsigned envelope ⇒ rejected (never best-effort parsed). +- AC4: the supported-version window is data, not code branches; shrinking it in config immediately + rejects the dropped version. +- Dogfood: P-AUTHORITY-001; "bare tmux = comms P0/PoC" with no version field at all. +- Fail-closed point: **unknown protocol version ⇒ reject, loudly.** + +--- + +**R-051 — Comms service: PG state machine** +_build 4 · depends: R-050, R-013 · 200k · opus_ + +Sole-path service. `PENDING → RECEIVED → CONSUMED → DEAD-LETTER`, ack cursor, per-recipient filter. +`interaction_inbox` already provides the unique `(session_id, idempotency_key)` index and the +status enum — extend rather than fork. + +- AC1: a message with no consumer remains `PENDING` and is retried; it is never reported delivered. +- AC2: a bounced transport attempt does not advance the cursor. Registered must-fail case: force a + bounce, assert the cursor is unchanged and a retry occurs. +- AC3: after N failed attempts the message goes `DEAD-LETTER` and raises a visible operator signal — + dead-lettering silently is the same bug in a new hat. +- AC4: `RECEIVED` and `CONSUMED` are distinct and both observable; a manual liveness check is never + required to learn a message's fate. +- Dogfood: the MACP scout bounce ("tmux target not found") — never RECEIVED, no retry, discovered only + by hand. +- Fail-closed point: **un-acked ⇒ not consumed ⇒ retried.** + +--- + +**R-052 — tmux transport adapter behind the service** +_build 4 · depends: R-051, R-060 · 160k · sonnet_ + +tmux becomes the first dumb adapter, not the authority. Durable retry before cursor advance. + +- AC1: sending to a nonexistent pane ⇒ message stays `PENDING`, retried, and the sender receives + `not-written` (R-027 tri-state), not `✓`. +- AC2: sending to a busy pane ⇒ `written+unverified` until the receipt is observed, then `RECEIVED`. +- AC3: adapter failure never loses the message — kill the adapter mid-send, assert redelivery. +- AC4: the adapter cannot mark anything `CONSUMED`; only the recipient's ack can. Registered must-fail case. +- Dogfood: scout-bounce; `send-message.sh:71` (`tmux target not found` → exit 1, no retry) and `:111`. +- Fail-closed point: **transport cannot self-certify delivery.** +- ⚠ Ordering: requires R-060 — identity addressing is meaningless while seats are split across the + `default` and `mosaic-fleet` sockets (that split _was_ the bounce). + +--- + +**R-053 — Per-class coalescing + supersede** +_build 4 · depends: R-051 · 130k · sonnet_ + +The `#1018` case: pepper's "awaiting your word" arrived _after_ the PR had merged — stale-consumed-as-live. + +- AC1: two messages of the same class for the same subject where the later supersedes → the recipient + sees one, the newer, with the older marked superseded (not deleted — auditability). +- AC2: a message whose subject has reached a terminal state is delivered _marked stale_, never as live + actionable. Registered must-fail case: assert an actionable-class stale message does not present as + actionable. +- AC3: ordering within a class is monotonic per sender. +- AC4: coalescing never drops a message with no successor. +- Dogfood: `#1018` stale-consumed. +- Fail-closed point: **ambiguous freshness ⇒ mark stale, never present as live.** + +--- + +**R-054 — Redis hot path + provenance guard** +_build 4 · depends: R-051, R-013 · 170k · opus_ + +Redis Streams + PEL as a derived, rebuildable accelerator. PG remains the protected tier. + +- AC1: flush Redis entirely → the sweeper rebuilds the hot queue from PG with zero message loss and + zero duplicates. This is the acceptance test that matters. +- AC2: every read from Redis carries a provenance flag; a code path that returns Redis-derived data as + authoritative ⇒ CI check fails (registered with R-025's machinery). Registered must-fail case: add + such a path and watch the build go red. +- AC3: Redis unavailable ⇒ service degrades to PG-only and _says so_; it does not fail open to + "assume delivered". +- AC4: split-brain injection (Redis has a message PG does not) → reconciliation resolves toward PG and + emits an event. +- Dogfood: synthetic. Justification: the moment a fast tier exists, someone reads it as truth — this + guard is cheap now and impossible later. +- Fail-closed point: **Redis is never authority.** + +--- + +**R-055 — Retire direct tmux sends** +_build 4 · depends: R-052, R-053 · 100k · codex_ + +- AC1: a CI check fails on any direct `tmux send-keys` to an agent pane outside the adapter. +- AC2: `agent-send.sh` becomes a thin client of the service; its exit codes remain tri-state per R-027. +- AC3: existing fleet scripts are migrated; the check enumerates what it scanned (no silent empty scan). +- Dogfood: the whole bare-tmux P0 comms substrate. +- Fail-closed point: **out-of-band send ⇒ build fails.** + +### P5 — Retirements, hygiene, conformance + +--- + +**R-060 — One roster-owned socket/host, quarantine, stale GC** +_build 5 · depends: R-005 · 150k · sonnet_ + +- AC1: every roster seat resolves to the single roster-owned socket; a seat launched on another socket + is quarantined and reported. Registered must-fail case. +- AC2: `fleet status` lists unmanaged sessions distinctly; count is nonzero in a test that plants one. +- AC3: stale-session GC reaps or rotates by max-age/max-context, with the decision recorded as an event. +- AC4: GC never reaps a session holding an active mission claim. +- Dogfood: scout-bounce root cause — "seats split default vs mosaic-fleet socket." +- Fail-closed point: **unmanaged seat ⇒ quarantined, not addressed.** + +--- + +**R-061 — Auto-sync allowlist + worktree isolation** +_build 5 · depends: R-002 · 110k · sonnet_ + +`brain-sync.sh:99` does `git add -- "$@"`; `session-end.sh:48` stages `data/ views/ domains/ docs/ '*.md'`. +The measured incident (517bd5c26) staged agent-authored `docs/postmortem-spec/site/*` mid-write. + +- AC1: staging is allowlist-driven; a path outside the allowlist is never staged. Registered must-fail + case: place an unknown path, run sync, assert it is untouched **and** reported. +- AC2: a half-written agent-authored file under an agent-owned path is never swept (lease/worktree check). +- AC3: the sync reports what it declined to stage (silence on decline is how this gets re-broken). +- Dogfood: P-WORKFLOW-001, sweep 517bd5c26. +- Fail-closed point: **unknown path ⇒ not staged, and said out loud.** +- Note: criterion (1) of P-WORKFLOW-001 is partially obviated by R-062 (DB tracking), but (2)+(3) + remain for code/docs in project repos. Do not cancel this task on the strength of R-062. + +--- + +**R-062 — Flat-file orchestration tracking → DB cutover, with rollback artifact** ★ highest data risk +_build 2/5 · depends: R-024, R-028, R-042 · 200k · opus_ + +Jason's directive: hard cutover, no flat-file interim. I comply on the _write_ path and dissent on the +unqualified form (D3). Scope: mosaic fleet orchestration state only — missions, agent-task +assignments, `MOS-ORCHESTRATION-BOARD-{LIVE,LEDGER}`, `docs/TASKS.md`, `.mosaic/orchestrator/*`. +**jarvis-brain PDA flat files (`data/projects`, `data/tasks`) are explicitly out of scope and must be +proven untouched.** + +- AC1: a one-shot importer migrates the current board + ledger + `docs/TASKS.md` into the spine; + re-running it is idempotent (no duplicate missions/tasks). +- AC2: **in-flight safety** — a mission with a running task is migrated without the task being lost or + double-claimed. Test by migrating while a task is mid-execution; assert exactly-once completion. +- AC3: after cutover, flat files are no longer written (mtime unchanged over a full mission run). +- AC4: a **read-only export** regenerates a human-readable board from the DB on demand. This is the + rollback artifact, not a fallback tracking system — it is never read back as authority (enforced by + R-025's writer check). +- AC5: documented, tested rollback: restore the pre-cutover snapshot and resume flat-file tracking + within one operator step. Exercised once in a rehearsal before the real cutover. +- AC6: `git diff --stat` over `jarvis-brain/data/` across the cutover is empty. +- Dogfood: the flat-file clobber class (P-WORKFLOW-001); the co-location of fleet state inside the + brain repo, which the postmortem names as part of the bug. +- Fail-closed points: **importer aborts on any ambiguity rather than guessing**; **cutover refuses to + run while a rotation is in flight.** + +--- + +**R-063 — Conformance harness** +_build 5 · depends: R-044, R-051, R-062 · 260k · opus_ + +Fault-inject the six live failure classes against real runtime artifacts on the DB substrate: +compaction/rotation, broker-unavailable, delivery-bounce, identity-drop, stale-hash, queue-inert. +Flat-file cases become regression guards. + +- AC1: each of the six classes has an injector, an expected-outcome assertion, and a _demonstrated + red_ — the harness must be shown failing when the corresponding fix is reverted. **A harness that has + never been red is an inert gate.** +- AC2: the six seed incidents are reproduced by id: Pi brick, scout-bounce, gate-6 inert, #1019 + recursion, identity drift, auto-sync sweep, #1018 stale-consumed. +- AC3: harness runs in CI on a schedule and on release; results land in the event ledger. +- AC4: harness self-reports coverage (which classes ran); a skipped class is a failure, not a silent pass. +- Dogfood: it _is_ the dogfood — the automated form of the standing directive. +- Fail-closed point: **skipped class ⇒ suite red.** + +--- + +**R-064 — Fleet-wide inert-gate audit** +_build 5 · depends: R-002 · 120k · sonnet_ + +Apply the R-002 registry to every existing gate in `~/.config/mosaic/tools/` and the repo. Expect +casualties beyond gate-6. + +- AC1: every gate is registered; the audit report lists each gate with its must-fail case and the + observed exit code. +- AC2: every gate found inert is either fixed in this PR or filed with an issue id in the registry; + an unexplained inert gate ⇒ CI red. +- AC3: the report is committed as evidence (this is the "prove it" artifact for P-QUEUE-001's + interim doctrine: _queue-guard green = zero information until proven otherwise_). +- Dogfood: gate-6 was inert **fleet-wide** and nobody knew; the base rate of inert gates is unknown and + that is itself the finding. +- Fail-closed point: **inert gate without a filed issue ⇒ red.** + +--- + +**R-065 — Retirement proof** +_build 5 · depends: R-030, R-055, R-062 · 90k · codex_ + +A CI check asserting the three cross-cutting retirements are complete **and stay complete**: no +flat-file orchestration tracking writes, exactly one MACP implementation, no silent bypass markers. + +- AC1: reintroduce each retired pattern in a scratch branch → check ⇒≠0, once per pattern. Registered + must-fail cases (three of them). +- AC2: the check enumerates what it scanned. +- AC3: allowlisted exceptions carry an expiry date; an expired exception ⇒≠0. +- Dogfood: the entire "built-but-unwired / retired-but-resurrected" disease. +- Fail-closed point: **resurrection ⇒ build fails.** + +--- + +## 4. Fail-closed register + +Every point where this plan chooses safety over availability. If any of these is later softened, the +corresponding failure class returns. + +| # | Point | Task | What it costs when it fires | +| ----- | ------------------------------------------------------------- | ------------ | ------------------------------------------------------- | +| FC-1 | Unregistered gate ⇒ build fails | R-002 | New gates need a negative control before merge | +| FC-2 | Unknown/no-status/malformed CI state ⇒ merge blocked | R-003 | Merges wait on flaky status APIs | +| FC-3 | Broker absent + no break-glass ⇒ deny | R-004 | Requires R-005 first or the fleet bricks | +| FC-4 | Version skew ⇒ launch blocked | R-005 | Seats refuse to start after a partial upgrade | +| FC-5 | Skipped migration ⇒ error | R-010 | First-install must be fixed, not worked around | +| FC-6 | Ledger mutation ⇒ DB error | R-011 | No "cleanup" of bad events; only compensating entries | +| FC-7 | No DB ⇒ no managed work | R-012, R-024 | Spine outage stops the fleet. Accepted deliberately | +| FC-8 | Schema-invalid / credentials unresolved ⇒ reject before spawn | R-020 | Bad task definitions surface loudly | +| FC-9 | Executor crash ⇒ non-terminal state | R-020 | Requires a reclaim sweeper | +| FC-10 | Empty/unrunnable gate set ⇒ task fails | R-021 | Gate-less task types must be declared explicitly | +| FC-11 | Unpersistable event ⇒ operation fails | R-022 | Auditability outranks availability | +| FC-12 | Unknown terminal-status writer ⇒ build/DB rejects | R-025 | Every new writer needs an allowlist entry with a reason | +| FC-13 | Identity unresolvable ⇒ no write | R-027 | Respawned seats must re-derive identity or stop | +| FC-14 | Cannot verify ⇒ never report success | R-027 | Callers must handle a third exit code | +| FC-15 | Corrupt checkpoint ⇒ refuse rehydrate | R-028, R-041 | Forces a refresh cycle | +| FC-16 | Stale contract hash ⇒ mutations rejected | R-029 | Highest blast radius; ship report-only first | +| FC-17 | Unmonitored seat ⇒ launch refused | R-040 | Hook install becomes mandatory | +| FC-18 | Checkpoint failure ⇒ old session not killed | R-042 | Rotation stalls rather than losing state | +| FC-19 | Incomplete rehydration ⇒ seat may not mutate | R-043 | Quarantined seats need operator attention | +| FC-20 | Unknown protocol version ⇒ reject loudly | R-050 | Rollout needs the N-version window honored | +| FC-21 | Un-acked ⇒ not consumed ⇒ retried | R-051 | Duplicate-tolerant consumers required | +| FC-22 | Transport cannot self-certify delivery | R-052 | tmux "✓" becomes tri-state | +| FC-23 | Ambiguous freshness ⇒ marked stale | R-053 | Some genuinely-live messages arrive marked stale | +| FC-24 | Redis never authority | R-054 | Extra PG round-trip on the read path | +| FC-25 | Unmanaged seat ⇒ quarantined | R-060 | Ad-hoc tmux seats stop being addressable | +| FC-26 | Unknown path ⇒ not staged, and reported | R-061 | Sync gets noisier, deliberately | +| FC-27 | Importer aborts on ambiguity | R-062 | Cutover may need manual disambiguation | +| FC-28 | Skipped conformance class ⇒ suite red | R-063 | No partial conformance claims | +| FC-29 | Retirement resurrection ⇒ build fails | R-065 | Exceptions must carry expiry dates | + +--- + +## 5. Ordering hazards (later task silently depends on an earlier invariant) + +**H-1 — R-004 before R-005 re-bricks the fleet.** The three bypasses are load-bearing _because the +broker was never deployed_. Remove the fail-open before deployment coherence exists and you reproduce +2026-07-22 exactly, including the Pi brick. Hardest edge in the DAG. + +**H-2 — Every migration-adding task silently depends on R-010.** R-011/R-013/R-022/R-028/R-050/R-051 +will all pass in CI on PGlite and fail on the first real Postgres install. The defect is documented in +a TODO and has never been hit because nobody first-installed the PG tier. + +**H-3 — R-025 must ship with R-020, not after.** The choke point's only real property is exclusivity. +The gap between "executor exists" and "executor is the only path" is exactly when island #4 gets built. + +**H-4 — R-045 before R-042/R-044 are _deployed_ removes the coping mechanism.** Merge ≠ live. The +orchestrator must confirm rotation is running on the fleet, not just merged. + +**H-5 — R-052 depends on R-060.** Identity addressing across two tmux sockets is the bounce that +motivated the whole comms build. Building the adapter first just relocates the bug. + +**H-6 — R-062 depends on R-042.** Cutting over tracking while rotation is half-built means a rotation +lands mid-migration with state split across both substrates. R-062 must also refuse to run during an +in-flight rotation (FC-27). + +**H-7 — R-029 depends on R-028's claim signing.** A contract hash that is not itself integrity-protected +is a suggestion. Also: R-029 has the widest blast radius of any task here; report-only first. + +**H-8 — R-021's "empty gate set = failure" invariant is assumed by R-026.** If R-026 lands first, +Forge's stub is replaced by a real executor that will happily run zero gates. + +**H-9 — R-063 assumes every earlier fix is _revertible in a test harness_.** If tasks land without +feature flags or clean revert points, "demonstrate the harness red" (AC1) becomes impossible and the +conformance suite silently becomes decorative. **Every P0–P4 task should land with a documented way to +disable its fix in a test context.** This is a cross-cutting requirement the orchestrator should add to +the charter, not a per-task note. + +**H-10 — R-002's registry is only as good as its enumeration.** If the registry is populated by hand +from a partial list, unregistered gates keep passing. R-002 AC3 (unregistered-gate detection) is the +load-bearing half; R-064 is its audit. + +--- + +## 6. Secretly-greenfield register + +"Finish, don't re-spec" is accurate at the _specification_ level and misleading at the code level. Of +38 tasks, these have no existing implementation to finish: + +| Task | Reality | +| ----------- | --------------------------------------------------------------------------------------------------- | +| R-002 | Gate registry + negative-control runner — new, no precedent in repo | +| R-011 | Hash-chained append-only ledger — the existing `events` table is calendar-shaped | +| R-013 | Outbox sweeper — tables exist, sweeper does not | +| R-020 | Executor — the package has **no exported programmatic submit** (scout §1); only a CLI placeholder | +| R-025 | No-second-path enforcement — entirely new | +| R-028 | HMAC claim integrity — new | +| R-029 | Contract-hash binding — new | +| R-040 | Durable compaction sensor — "nothing durable today" | +| R-042 | Rotation daemon — the PoC's `_spawn_agent()` was a stub; `packages/coord` has primitives, no daemon | +| R-043 | Rehydration attestation — new | +| R-050/R-051 | comms/v1 envelope + service — tables exist, protocol and service do not | +| R-054 | Provenance guard — new | +| R-063 | Conformance harness — new | + +**Estimate: ~13 of 38 tasks (≈40% of tokens) are new construction.** What genuinely exists and should +be _reused, not rebuilt_: `packages/macp` types/credential-resolver/gate-runner/risk-floor; +`packages/db` schema + drizzle migrations (post-R-010); `packages/queue` `QueueAdapter`; +`packages/coord` mission/session/capsule/crash-recovery primitives; `interaction_*` tables; +PG + Redis already running in-stack. That is a real head start — it is just not "wiring." + +--- + +## 7. Migration & rollback (the hard cutover) + +Per Jason: hard cutover, no flat-file interim. Concretely, R-062 executes in five steps, each of which +must be reversible until the last: + +1. **Rehearsal** (throwaway DB): run the importer against a copy of the current board/ledger/TASKS.md. + Diff the reconstructed board against the flat original. Any unexplained delta ⇒ stop. +2. **Freeze window**: no rotation in flight (FC-27), no task mid-execution beyond a known set. +3. **Snapshot**: tag the flat-file state (git tag + tarball) — this is the rollback artifact. +4. **Import + flip**: importer runs; writers switch to the spine in one commit; flat files become + read-only outputs. +5. **Verification window**: for one measured period, a scheduled job regenerates the human board from + the DB and diffs it against the frozen snapshot's _shape_ (not content). Divergence in shape ⇒ + investigate. **This is not a dual-write and not an interim tracking system** — it is a + one-directional export that proves the DB is complete. + +**Rollback trigger:** any of — importer AC1 idempotency failure, a lost in-flight task (AC2), or spine +unavailability exceeding the agreed window. **Rollback action:** restore the snapshot, revert the +writer flip, resume flat-file tracking. Must be exercised once in rehearsal or it is not a rollback +plan, it is a wish. + +--- + +## 8. Dissent + +**D1 — The charter's sequencing starts one phase too late.** +`MISSION.md` sequences "spine + choke-point first." I insert P0 (R-001…R-005) ahead of it. Reason: +every task in Builds 1–4 introduces gates, and this fleet has demonstrated — twice, on the same +defect — that it cannot detect an inert gate. Building the choke point first means its gates are +unverifiable _by construction_, and we would be shipping the exact class of artifact the postmortem +indicts. R-001 is also a hard blocker (nothing can be pushed today). Cost of the insertion: ~420k +tokens, roughly 8% of the plan. I consider it the highest-leverage 8% here. + +**D2 — I disagree with the scout's chosen wire-in point.** ★ headline dissent +`MACP-WIRING-SCOUT.md` names the "single integration point" as replacing the exec/gate/event block in +`mosaic_orchestrator.py::run_single_task` (:126-276). But that controller is **disabled** +(`.mosaic/orchestrator/config.json:2` — `"enabled": false`) and references a dispatcher path +(`tools/macp/dispatcher/pi_runner.ts`) that does not exist in this checkout. Wiring the new choke +point into a disabled rail produces a stranded executor — the identical disease, one layer up, and it +would look "done" in a PR. + +The paths that actually carry work today are `packages/mosaic/src/commands/launch.ts` (`mosaic +yolo|claude|codex|pi`, :730-843, :1102-1167) and `packages/coord/src/runner.ts` (:397-427). **Wire +those (R-023, R-024) and delete the Python rail (R-030) rather than porting it.** The scout's analysis +is excellent and its file:line evidence is what let me reach this conclusion — I am disagreeing with +its recommendation, not its findings. Concrete consequence: R-030 becomes a deletion task, not an +integration task, and Build 1's acceptance must be measured on a live `mosaic yolo` invocation. + +**D3 — "Hard cutover, no interim" is right about tracking and wrong about evidence.** +I comply with the directive: one write path, no flat-file interim, no dual-write. But a hard cutover +with **no rollback artifact** will, on the balance of this codebase's history, lose an in-flight +mission. R-062 AC4/AC5 add a one-directional read-only export and a rehearsed rollback. These are not +an interim tracking system — nothing reads them as authority (enforced by R-025) — they are the +snapshot you need at 3am. If the orchestrator judges even this to violate the directive, escalate to +Jason rather than silently dropping it; the difference between "no interim" and "no rollback" is worth +one question. + +**D4 — The "100 rotations lossless" bar as written is not a test, it is a demo.** +100 clean rotations exercise the happy path 100 times. The failure mode is a kill at the worst moment. +R-044 replaces the bar with a fault matrix and defines "lossless" as a machine-compared claim-set diff. +I would not accept Build 3 on 100 clean rotations alone, and I would treat any suite that has never +been observed red as unproven (R-063 AC1). + +**D5 — Report-only modes must expire, or they become the new fail-open.** +I recommend report-only rollout for R-029 (contract hash) and R-025 (no-second-path) because their +blast radius is fleet-wide. But a report-only gate is definitionally inert, and this organization has +proven it will not notice. **Every report-only mode must carry a hard expiry timestamp after which it +enforces or refuses to start** — same mechanism as R-004's break-glass. Without that clause I withdraw +the report-only recommendation and prefer a hard flip with a scheduled window. + +**D6 — P-QUEUE-001 should not be scheduled inside Build 1, and its ownership is currently ambiguous.** +The queue guard is a shell script in the shared tools path; it has no dependency on the spine or the +executor, and it is P0. Scheduling it "inside Build 1" delays a P0 fix behind a 1M-token foundation. +R-003 sits in P0 instead. Separately: PR #1023 is explicitly **parked under Mos** while HOLD is lifted +for this workstream — two lanes can legitimately claim it. **The orchestrator must resolve this with +Mos before dispatching R-003.** A third recursion on this defect would be the postmortem's own +anti-pattern, performed by the remediation. + +**D7 — Build 4's adapter fan-out (Matrix/Discord/Slack/Telegram) does not belong in this mission.** +The comms _service_, the envelope, the state machine, and one adapter (tmux) fix the observed failures. +Additional adapters are reach, not correctness, and each one adds a delivery-semantics surface that +R-063 must then cover. I recommend explicitly deferring adapters beyond tmux to a follow-on mission, +and saying so in the charter so it is a decision rather than a slip. + +**D8 — FC-7 and FC-11 are real costs, and I want them acknowledged in writing, not discovered.** +"No DB ⇒ no managed work" and "unpersistable event ⇒ operation fails" mean a Postgres outage stops the +fleet, where today a flat-file fleet limps on. That is the correct trade for a system whose defining +failure is _silent_ continuation — but it is a genuine availability regression, and the first time it +fires at 2am someone will be tempted to add a fallback. **Pre-commit to the answer now:** the fallback +is "the fleet stops," and the mitigation is spine availability (backups, restart policy, monitoring), +not a degraded write path. If that is not acceptable to Jason, the right time to say so is before R-012, +not during the incident. + +--- + +## 9. Handoff notes for the orchestrator + +- **Dispatch order for the first wave (parallelizable):** R-001 → then R-002 and R-005 concurrently → + then R-010 concurrently with R-003; R-004 waits on R-005. +- **Do not dispatch any P2 task before R-002 is merged.** That is the whole argument of D1; if it gets + compressed away under schedule pressure, the plan's central claim goes with it. +- **Pre-registration:** every AC above is written to be committed _before_ the diff is read. They are + drafts — the reviewer should tighten them per task, but the must-fail cases are non-negotiable. +- **Charter addendum recommended (H-9):** every P0–P4 task lands with a documented way to disable its + fix in a test context, so R-063 can demonstrate red. +- **Three decisions needing a human or Mos:** (1) R-003 ownership vs. parked PR #1023 [D6]; + (2) rollback artifact vs. "no interim" reading [D3]; (3) accepting the FC-7/FC-11 availability + trade [D8]. diff --git a/docs/remediation/DECOMP-SOL.md b/docs/remediation/DECOMP-SOL.md new file mode 100644 index 00000000..8fbbef65 --- /dev/null +++ b/docs/remediation/DECOMP-SOL.md @@ -0,0 +1,424 @@ +# Adversarial Decomposition — Pragmatic / Shortest-Path Side + +**Planner:** `planner-sol` +**Bias:** make one real fleet task pass through one enforced path as early as possible; reuse before building. +**Scope source:** `MISSION.md`, `MACP-WIRING-SCOUT.md`, `BOARD.md`, existing `@mosaicstack/macp`, `packages/coord`, PG/Valkey, Tess durable inbox/outbox, and the Mission Control PRD. + +## Executive position + +The first useful milestone is **not** “complete Builds 1 and 2.” It is this narrow vertical slice: + +> A DB-backed mission task is atomically claimed by `packages/coord`, executed by one Node `@mosaicstack/macp` TaskExecutor, gated, and terminally recorded with identity-bound events and a tri-state mutation result. No `docs/TASKS.md`, `mission.json`, `tasks.json`, Python gate loop, or NDJSON ledger participates. + +That slice is **SOL-03 → SOL-04 → SOL-05 → SOL-06 → SOL-07 → SOL-08**, estimated at **72K tokens**, mostly Codex. PG polling is acceptable for this first proof. Redis acceleration follows only after correctness is observable. This is the shortest path that is both dogfoodable and not throwaway work. + +### Cost posture + +- **25 PR tasks, ~294K tokens total:** ~164K Codex, ~130K Sonnet, **0K Opus**. +- First live choke-point dogfood: ~72K on the hard path; SOL-01 and SOL-02 can run beside it. +- Opus is not justified for planned implementation. Escalate only if an independent security review finds an unresolved architecture-level authority flaw. +- Every row is one PR. Estimates include implementation, focused tests, docs affected by that PR, and one remediation pass—not orchestration/reviewer overhead. + +## Gates and critical path + +| gate | opens when | proof required before downstream work | +| ------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------- | +| **G0 — trustworthy launch gates** | SOL-01, SOL-02 | non-root checkout works; queue status cannot become false-green | +| **G1 — first dogfood / minimum viable cut** | SOL-08 | one live fleet task completes DB → MACP executor → gates → DB with no flat-file state | +| **G2 — Builds 1+2 closed** | SOL-09..SOL-12 | all producers use the executor; duplicate islands retired; Redis loss is recoverable from PG | +| **G3 — rotation real** | SOL-13..SOL-16 | stale generation cannot mutate; fresh session resumes typed state; Pi-brick recovery works without broker | +| **G4 — sole-path comms real** | SOL-17..SOL-22 | roster identity is stable; bounced/stale messages converge through PG/Redis and adapters | +| **G5 — mission proof** | SOL-23..SOL-25 | workflow sweep cannot capture unknown files; fault bank passes, including 100 rotations | + +**Critical path:** `03 → 04 → 05 → 06 → 08 → 10 → 12 → 13 → 14 → 15 → 16 → 17 → 18 → 19 → 20 → 21 → 22 → 24 → 25`. + +## Ordered task list + +### SOL-01 — Repair activation coherence and non-root checkout hygiene + +- **build:** 5 (hygiene; pulled forward) +- **depends_on:** — +- **acceptance criteria (diff-blind testable):** + 1. A clean non-root checkout can run dependency/bootstrap preparation without accessing `/root`. + 2. A root CI checkout still uses an isolated writable pnpm store. + 3. Activation installs CLI, hooks, broker/runtime assets, and version manifest transactionally: induced failure leaves the prior complete generation active. + 4. Launch with a deliberately skewed component version is rejected with the exact repair command; diagnostics remain usable. + 5. No test uses `--no-verify` or suppresses hooks. +- **dogfood seed:** BOARD D-1 root-pinned `.npmrc` and D-2 root-owned Husky path. +- **est. tokens:** 6K +- **suggested runtime tier:** codex + +### SOL-02 — Make queue guard fail-safe with exit-asserting tests + +- **build:** 1 +- **depends_on:** — +- **acceptance criteria (diff-blind testable):** + 1. Fixture responses `pending`, `success`, `failure`, no-status, malformed JSON, provider error, and unknown status produce explicitly asserted process exits. + 2. Only terminal success/no-active-queue returns 0; unknown, malformed, and transport failure return non-zero with actionable output. + 3. A payload larger than 150 KiB is consumed without argv expansion or truncation. + 4. At least one mutant changes unknown→success and is killed by the test suite. +- **dogfood seed:** inert gate-6 and recursive #1019 failure (unknown→exit 0; ARG_MAX). +- **est. tokens:** 8K +- **suggested runtime tier:** codex + +### SOL-03 — Complete the canonical MACP contract, not another protocol + +- **build:** 1 +- **depends_on:** — +- **acceptance criteria (diff-blind testable):** + 1. `@mosaicstack/macp` validates typed Task, TaskResult, lifecycle Event, state Claim, and `verified | written-unverified | failed` mutation outcome records. + 2. Claims require source, confidence, issued-at, TTL/expiry, refresh instruction, and HMAC integrity; tamper/expiry returns a typed refusal, never partial data. + 3. Lifecycle events include launch, mission generation, checkpoint, rotation, recovery, inbox receipt, and terminal disposition while preserving existing task events. + 4. `MOSAIC_AGENT_NAME` is required for mutating execution and appears in credential/actor binding; missing identity fails closed. + 5. Target metadata requires repository identity, task/record ID, and head or generation where applicable. +- **dogfood seed:** rev-974 identity drift plus the three observed write outcomes. +- **est. tokens:** 8K +- **suggested runtime tier:** codex + +### SOL-04 — Add the narrow PG orchestration spine schema + +- **build:** 2 +- **depends_on:** SOL-03 +- **acceptance criteria (diff-blind testable):** + 1. Migration up creates mission, task, dependency, task-claim, MACP event, typed state-claim, session-generation, and dispatch-outbox records with tenant/mission keys and uniqueness constraints. + 2. The database rejects a task without a mission, a dependency outside its mission, duplicate idempotency keys, and terminal→running regression. + 3. Event and claim records reference canonical mission/task/generation identities; claims store integrity metadata. + 4. Migration rollback on an empty test DB succeeds; rerunning migration is safe. + 5. No comms-specific “universal message” schema is invented here; Build 4 reuses/extends existing interaction inbox/outbox tables. +- **dogfood seed:** mission convention existed but a lane could act with no mechanically valid mission/task. +- **est. tokens:** 12K +- **suggested runtime tier:** codex + +### SOL-05 — Implement atomic PG task claims, transitions, ledger, and outbox + +- **build:** 2 +- **depends_on:** SOL-04 +- **acceptance criteria (diff-blind testable):** + 1. Two concurrent claimers for one runnable task yield exactly one lease owner. + 2. Dependencies are evaluated transactionally; an unmet dependency can never be claimed. + 3. Claim, state transition, MACP event, and dispatch-outbox append commit atomically or all roll back. + 4. Expired leases are reclaimable with a higher fencing generation; stale owners cannot complete or mutate. + 5. Querying mission status is derived solely from PG and returns the next runnable task deterministically. +- **dogfood seed:** model-maintained live board and stale claims surviving session changes. +- **est. tokens:** 12K +- **suggested runtime tier:** codex + +### SOL-06 — Build the one production Node MACP TaskExecutor + +- **build:** 1 +- **depends_on:** SOL-03, SOL-05 +- **acceptance criteria (diff-blind testable):** + 1. A public Node executor accepts only a claimed canonical MACP Task, resolves credentials/identity, runs the worker, runs structured gates, and persists terminal result/events through SOL-05. + 2. Worker exit 0 plus a failed gate cannot produce `completed`; worker failure cannot skip terminal ledger emission. + 3. Claude, Codex, and Pi fixture backends emit the same runtime-neutral lifecycle sequence. + 4. Every mutation returns one mandatory tri-state outcome; callers cannot compile while discarding it. + 5. Crash after worker success but before terminal commit leaves a recoverable fenced claim and no false completion. +- **dogfood seed:** stranded MACP, gate-6 inert completion, and `written-unverified` being treated as success. +- **est. tokens:** 16K +- **suggested runtime tier:** sonnet + +### SOL-07 — Provide one-shot flat-file import and cutover readiness audit + +- **build:** 2 +- **depends_on:** SOL-05 +- **acceptance criteria (diff-blind testable):** + 1. A dry-run parses existing mission/TASKS artifacts, reports unsupported/ambiguous rows, and performs zero writes. + 2. Apply is idempotent and records source digests; repeated apply creates no duplicates. + 3. Unknown status, dangling dependency, duplicate task ID, and malformed table block import with row-level diagnostics. + 4. Readiness reports “cutover-ready” only when imported PG projections exactly match source counts/dependencies/statuses. + 5. This command is migration-only; it exposes no dual-write or ongoing sync mode. +- **dogfood seed:** current remediation board/TASKS state needs a clean DB landing without silently losing tasks. +- **est. tokens:** 8K +- **suggested runtime tier:** codex + +### SOL-08 — Hard-cut `packages/coord` to PG and dogfood one live task + +- **build:** 1 +- **depends_on:** SOL-06, SOL-07 +- **acceptance criteria (diff-blind testable):** + 1. `mosaic coord run/status/continue` reads and mutates PG only; absent/unmigrated DB state fails with the SOL-07 repair path. + 2. No fallback reads/writes `docs/TASKS.md`, mission JSON, task JSON, state JSON, results JSON, or events NDJSON. + 3. A live canary task assigned to a fleet seat travels PG claim → TaskExecutor → worker → gate → terminal PG result/event and closes only after gate success. + 4. Killing the coordinator after claim and restarting it neither duplicates execution nor allows the stale lease to close the task. + 5. Evidence query shows actor seat, mission/task, target metadata, gate results, and tri-state outcome. +- **dogfood seed:** this remediation mission itself; reproduce a gate-6-style non-null task and identity-bound write. +- **est. tokens:** 16K +- **suggested runtime tier:** sonnet + +> **G1 FIRST-DOGFOOD:** stop and validate here before broadening. If SOL-08 cannot carry a real task, do not build Redis, rotation, comms, or UI. + +### SOL-09 — Route Forge and OpenClaw/MACP producers through TaskExecutor + +- **build:** 1 +- **depends_on:** SOL-08 +- **acceptance criteria (diff-blind testable):** + 1. Forge and the OpenClaw MACP runtime submit the canonical Task type to SOL-06; neither executes a worker or gate itself. + 2. Their success callbacks are derived from canonical terminal results, not local/stub completion. + 3. A failed canonical gate is observed identically from Coord, Forge, and OpenClaw fixtures. + 4. Repository search plus an executable import boundary test finds no production-local redefinition of Task/TaskResult/GateResult on these paths. +- **dogfood seed:** Forge’s immediate empty-gate completion and the plugin’s redefined MACP-shaped result. +- **est. tokens:** 10K +- **suggested runtime tier:** codex + +### SOL-10 — Retire flat-file orchestration and the disabled duplicate rail + +- **build:** 1 +- **depends_on:** SOL-09 +- **acceptance criteria (diff-blind testable):** + 1. The Python controller execution/gate/event path, `tasks_md_sync`, plugin-local protocol types, orphaned context loader, and production flat-file orchestration writers/readers are absent from shipped assets. + 2. Framework guides/templates/startup context point to DB mission commands, not `docs/TASKS.md` as orchestration SoR. + 3. A regression scan fails CI if production code reintroduces `events.ndjson`, `tasks.json`, `mission.json`, or `docs/TASKS.md` orchestration mutation. + 4. jarvis-brain PDA flat files and unrelated project docs remain untouched. + 5. Upgrade removes/quarantines obsolete generated rail files without deleting user source/docs. +- **dogfood seed:** three parallel islands and stale `.mosaic/orchestrator/mission.json` 0/0 residue. +- **est. tokens:** 14K +- **suggested runtime tier:** sonnet + +### SOL-11 — Add Redis hot dispatch as a derived outbox consumer + +- **build:** 2 +- **depends_on:** SOL-08 +- **acceptance criteria (diff-blind testable):** + 1. Task creation commits mission/task/outbox in PG before any Redis enqueue. + 2. Induced Redis failure leaves the task durable and pending; a sweeper later enqueues it exactly once logically. + 3. Deleting the Redis queue and rebuilding from PG restores all non-terminal dispatches without reviving terminal tasks. + 4. Duplicate delivery is neutralized by PG claim fencing/idempotency. + 5. Existing `packages/queue` adapter/config is reused; no second broker API is introduced. +- **dogfood seed:** inert/unknown queue transport and broker outage during task dispatch. +- **est. tokens:** 12K +- **suggested runtime tier:** codex + +### SOL-12 — Lock Builds 1+2 with black-box failure cases + +- **build:** 2 +- **depends_on:** SOL-02, SOL-10, SOL-11 +- **acceptance criteria (diff-blind testable):** + 1. A black-box suite proves: unknown queue status blocks; malformed task blocks; gate failure blocks completion; dropped identity blocks mutation; HMAC corruption forces refresh; Redis loss recovers from PG; stale lease cannot close. + 2. The suite invokes shipped CLI/service boundaries, not internal mocks. + 3. Every asserted failure checks process/result status and durable terminal/non-terminal state. + 4. The same canary task succeeds under Claude, Codex, and Pi adapters or a documented unavailable-runtime fixture fails explicitly. +- **dogfood seed:** gate-6/#1019, identity drift, and built-but-unwired MACP. +- **est. tokens:** 8K +- **suggested runtime tier:** sonnet + +### SOL-13 — Bind session authority to contract hash and generation + +- **build:** 3 +- **depends_on:** SOL-12 +- **acceptance criteria (diff-blind testable):** + 1. Launch computes a stable hash over the effective Constitution/AGENTS/runtime/skills set and stores it with session generation. + 2. Policy change or compaction detection marks the generation stale before any subsequent mutation. + 3. A stale/mismatched generation can read diagnostics but cannot claim, write task state, acknowledge comms, merge, or close. + 4. Re-attestation creates a new generation; old credentials/leases remain fenced. + 5. Hash input order/path normalization is deterministic across two clean launches. +- **dogfood seed:** compacted orchestrator losing directives and D-4 ignoring an in-message reset. +- **est. tokens:** 12K +- **suggested runtime tier:** sonnet + +### SOL-14 — Persist compact typed rotation checkpoints using Coord primitives + +- **build:** 3 +- **depends_on:** SOL-13 +- **acceptance criteria (diff-blind testable):** + 1. Checkpoint contains mission/task, completed/blocked state, next three actions, constraints, claims, contract hash/generation, and cursors—never transcript text. + 2. Checkpoint is HMAC-verified before rehydration; corrupt/expired/missing required claims refuse resume and request deterministic refresh. + 3. Writing checkpoint and rotation-intent event is atomic in PG. + 4. Existing Coord continuation capsule semantics are reused; no competing handoff schema/file is created. +- **dogfood seed:** manual MOS-ORCHESTRATION-BOARD checkpoint and incomplete-rehydration risk. +- **est. tokens:** 12K +- **suggested runtime tier:** codex + +### SOL-15 — Finish the deterministic coordinator rotation daemon + +- **build:** 3 +- **depends_on:** SOL-14 +- **acceptance criteria (diff-blind testable):** + 1. Configured token threshold triggers checkpoint → revoke old authority → terminate → launch fresh → verify rehydration in that order. + 2. Compaction-detected is a backstop that forces the same rotation path; it never requests recursive compaction. + 3. A launch failure leaves the mission recoverable and visibly paused, not assigned to two active generations. + 4. Ephemeral seats die/respawn without mission checkpoint; persistent/orchestrator seats rotate. + 5. The implementation extends `packages/coord`; untracked `apps/coordinator` residue is not revived. +- **dogfood seed:** planner-sol dirty-context dispatch and the old coordinator’s log-only `_check_context()` behavior. +- **est. tokens:** 16K +- **suggested runtime tier:** sonnet + +### SOL-16 — Add broker-independent recovery and remove silent MOSAIC BYPASS + +- **build:** 3 +- **depends_on:** SOL-15 +- **acceptance criteria (diff-blind testable):** + 1. With Redis/broker unavailable, a diagnostic/bootstrap command can inspect PG mission state, repair broker configuration, and resume without traversing the broker gate. + 2. Normal recovery remains broker-gated and is labeled as such. + 3. Break-glass requires explicit scope and expiry, emits a durable event, displays a loud banner, and auto-expires; permanent/silent bypass text or behavior is absent. + 4. The Pi-brick fixture recovers the broker, then returns to normal gated operation without editing source/config by hand. + 5. Orchestrator guidance removes “/compact and continue” only after the rotation command is available; ephemeral guidance remains explicit. +- **dogfood seed:** Pi brick and silent `MOSAIC BYPASS 2026-07-22`. +- **est. tokens:** 12K +- **suggested runtime tier:** sonnet + +### SOL-17 — Converge each host on one roster-owned lifecycle domain + +- **build:** 5 (hygiene; hard prerequisite for addressed comms) +- **depends_on:** SOL-16 +- **acceptance criteria (diff-blind testable):** + 1. Reconcile establishes exactly one roster-declared tmux socket/lifecycle domain per host. + 2. Unknown sessions are reported and quarantined; they are never killed without positive unmanaged classification. + 3. Max-age/max-context stale sessions invoke SOL-15 rotation for persistent seats or reap for ephemerals. + 4. Seat identity survives respawn and equals the roster/MOSAIC_AGENT_NAME binding. + 5. A fixture matching the current four unmanaged remediation seats converges them or produces explicit quarantine actions. +- **dogfood seed:** scout-bounce and BOARD D-3 seats split between default and `mosaic-fleet` sockets. +- **est. tokens:** 14K +- **suggested runtime tier:** codex + +### SOL-18 — Publish authenticated `comms/v1` envelope and compatibility rules + +- **build:** 4 +- **depends_on:** SOL-13, SOL-17 +- **acceptance criteria (diff-blind testable):** + 1. Envelope validates protocol version, message/idempotency ID, sender/recipient seat identity, class, ordering/coalesce key, creation/expiry, correlation, payload digest, and authentication. + 2. Current and immediately previous supported protocol versions are accepted; unsupported versions are rejected loudly with supported range. + 3. Framework/runtime version is diagnostic metadata and never the compatibility key. + 4. Forged sender, changed recipient/payload, expired envelope, and replay with conflicting content fail closed. +- **dogfood seed:** wrong-socket bare tmux message with no authoritative sender/recipient receipt. +- **est. tokens:** 8K +- **suggested runtime tier:** codex + +### SOL-19 — Build the logical PG-first comms service with tmux adapter + +- **build:** 4 +- **depends_on:** SOL-18 +- **acceptance criteria (diff-blind testable):** + 1. Sending commits envelope/payload and PENDING state in PG before adapter delivery. + 2. State machine enforces PENDING → RECEIVED → CONSUMED or DEAD-LETTER; illegal regressions are rejected. + 3. Recipient-filtered claims and append/coalesce policy are deterministic by message class. + 4. tmux is a dumb adapter: delivery failure changes no PG authority state and is retryable. + 5. Existing Tess durable repository/state-machine patterns are extended or generalized; no new deployable microservice or second inbox framework appears. +- **dogfood seed:** MACP scout bounce that was discovered only by manual liveness check. +- **est. tokens:** 16K +- **suggested runtime tier:** sonnet + +### SOL-20 — Make `agent-send` use the sole path and prove stale-message handling + +- **build:** 4 +- **depends_on:** SOL-19 +- **acceptance criteria (diff-blind testable):** + 1. Normal `agent-send` creates a comms/v1 record and observes RECEIVED/CONSUMED; it cannot directly invoke tmux. + 2. A wrong/missing socket leaves PENDING with retry diagnostics, then reaches RECEIVED after roster repair without resending. + 3. A stale coalescible message arriving after a newer terminal message is marked superseded/consumed and is not surfaced as live work. + 4. Duplicate identical send is idempotent; same ID with changed content is rejected. + 5. Inbox receipt/terminal disposition emits canonical MACP lifecycle events. +- **dogfood seed:** scout-bounce and #1018 stale-consumed message arriving after merge. +- **est. tokens:** 12K +- **suggested runtime tier:** codex + +### SOL-21 — Add Redis Streams hot delivery and PG reconciliation + +- **build:** 4 +- **depends_on:** SOL-11, SOL-20 +- **acceptance criteria (diff-blind testable):** + 1. PG commit precedes XADD; induced XADD failure is repaired by sweeper. + 2. Consumer uses a PEL; ack sequence is PG CONSUMED commit before XACK. + 3. Redis flush/restart rebuilds pending delivery from PG without duplicating consumed messages. + 4. Pending, abandoned, and dead-letter transitions are observable with bounded retry/backoff. + 5. Existing Redis/queue connection/configuration is reused. +- **dogfood seed:** delivery bounce plus broker loss between durable write and hot enqueue. +- **est. tokens:** 12K +- **suggested runtime tier:** codex + +### SOL-22 — Prove adapter pluggability with the existing Matrix connector + +- **build:** 4 +- **depends_on:** SOL-21 +- **acceptance criteria (diff-blind testable):** + 1. Existing Matrix connector consumes/produces comms/v1 through SOL-19 without owning authority state. + 2. The same envelope can fail tmux and later deliver through Matrix while producing one logical message lifecycle. + 3. Matrix retry/reconnect cannot regress PG state or duplicate CONSUMED work. + 4. Removing Matrix availability leaves PG/Redis/tmux behavior intact. +- **dogfood seed:** cross-socket scout notification bounce; alternate reach must not become alternate authority. +- **est. tokens:** 8K +- **suggested runtime tier:** codex + +### SOL-23 — Constrain auto-sync and agent writes by allowlist and lease + +- **build:** 5 +- **depends_on:** SOL-10 +- **acceptance criteria (diff-blind testable):** + 1. Auto-sync stages only an explicit allowlist; an unknown modified/untracked docs/source file remains unstaged and is reported. + 2. Agent source/docs writes require the correct worktree/lease; two seats cannot acquire the same mutable target concurrently. + 3. Generated files are positively identified, not inferred by denylist. + 4. The measured annotation/index mid-write fixture cannot be swept into an unrelated commit. + 5. DB orchestration state is absent from repository staging concerns. +- **dogfood seed:** auto-sync sweep commit `517bd5c26` capturing agent-authored docs mid-write. +- **est. tokens:** 8K +- **suggested runtime tier:** codex + +### SOL-24 — Build the real-artifact lifecycle conformance harness + +- **build:** 5 +- **depends_on:** SOL-16, SOL-17, SOL-22, SOL-23 +- **acceptance criteria (diff-blind testable):** + 1. Harness launches shipped CLI/runtime artifacts and fault-injects compaction, broker outage, delivery bounce, identity drop, stale contract hash, queue unknown/malformed, Redis loss, and auto-sync collision. + 2. One deterministic test executes 100 sequential rotations with no lost/duplicated task, claim, receipt, or terminal disposition. + 3. Tests assert DB state/event order and process exits, not log substrings alone. + 4. Harness runs against isolated PG/Redis namespaces and cleans only resources it created. + 5. Every banked dogfood seed has a named case and evidence output suitable for CI/release attachment. +- **dogfood seed:** the complete failure bank: Pi brick, scout-bounce, gate-6/#1019, identity drift, auto-sync, #1018 stale-consumed, D-4 dirty context. +- **est. tokens:** 18K +- **suggested runtime tier:** sonnet + +### SOL-25 — Complete operator cutover docs and activation proof + +- **build:** 5 +- **depends_on:** SOL-01, SOL-02, SOL-10, SOL-16, SOL-22, SOL-24 +- **acceptance criteria (diff-blind testable):** + 1. Operator docs give exact DB import/cutover, rollback-before-cutover, recovery, break-glass expiry, rotation, comms, quarantine, and conformance commands. + 2. Link/command checks find no orchestrator instruction to mutate flat-file mission/tasks, use silent bypass, direct-tmux normal comms, or “compact and continue” a persistent seat. + 3. A clean non-root install activates one coherent version and runs the conformance smoke subset. + 4. Release evidence maps all 15 decisions and every live seed to a passing check or an explicit deferred item below. +- **dogfood seed:** activation skew plus the tendency to leave built fixes unwired or undocumented. +- **est. tokens:** 6K +- **suggested runtime tier:** codex + +## Explicit DEFER list (10) + +These are not rejected; they are **past first dogfood** and should not delay G1/G2. Each is gold-plating unless a live failure makes it necessary. + +1. **DEFER — Mission dashboard/TUI views.** CLI/DB queries are enough to operate and prove the spine. +2. **DEFER — PRD-to-board automatic decomposition.** This is LLM/judgment-heavy and unrelated to enforcing already-decided tasks. +3. **DEFER — General heuristic churn scoring.** Implement token threshold + compaction sensor first; repeated-tool-loop inference can follow measured need. +4. **DEFER — Discord comms adapter.** Existing plugin reach remains; migrate only after tmux+Matrix prove the service contract. +5. **DEFER — Slack comms adapter.** No current dogfood dependency. +6. **DEFER — Telegram comms adapter.** No current dogfood dependency. +7. **DEFER — Public MCP comms surface.** `agent-send` and service API are sufficient for the mission proof. +8. **DEFER — Protocol-v2 features/general negotiation framework.** Ship v1 with a bounded current/previous acceptance window; do not predict v2. +9. **DEFER — Multi-region/HA PG or Redis.** Existing in-stack PG+Redis and rebuildability satisfy current failure classes. +10. **DEFER — Event analytics/search UI and long-term warehouse.** Indexed PG evidence plus CLI queries is enough for audit/conformance. + +## Suspect abstractions register + +| proposed thing | verdict | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Canonical Node `TaskExecutor` | **JUSTIFIED:** explicitly required single choke point; wraps existing MACP functions rather than replacing them. | +| PG repository methods | **JUSTIFIED but narrow:** ordinary adapters around existing Drizzle/DB patterns, not a new “state platform.” | +| Rotation daemon | **JUSTIFIED:** finishes `packages/coord`; do not revive `apps/coordinator` or create another service. | +| Comms service | **JUSTIFIED only as a logical in-process boundary:** reuse Tess durable inbox/outbox and existing connectors; no new deployable microservice this cycle. | +| Universal queue/broker abstraction | **SUSPECT / DO NOT BUILD:** reuse `packages/queue`, PG outbox, and Redis Streams/BullMQ configuration already present. | +| Universal envelope/state framework | **SUSPECT / DO NOT BUILD:** MACP Task/Claim/Event and comms/v1 have different bounded purposes. | +| Generic compatibility-negotiation engine | **SUSPECT / DEFER:** a small supported-version check meets v1 needs. | + +## Dissent (7) + +1. **Do not wire new production behavior into `mosaic_orchestrator.py::run_single_task`.** The scout correctly identified the duplicated block, but BOARD’s later ruling says the disabled Python rail is residue and must be retired. Building a Node bridge only to delete it is throwaway. Put the Node TaskExecutor in `@mosaicstack/macp`, route the live Coord path to it at SOL-08, migrate remaining producers at SOL-09, then delete the Python block at SOL-10. +2. **Redis is not on the first-dogfood critical path.** PG claim/polling is sufficient for one real task and exposes correctness earlier. Add Redis only after G1; otherwise queue debugging obscures whether the choke point works. +3. **“One choke-point service” does not justify a new deployable service.** An exported executor plus Coord daemon is enough. A new Nest app, RPC protocol, deployment, auth layer, and health plane would be greenfield. +4. **The Mission Control PRD’s file-first and board-regeneration assumptions are superseded.** Keep its mission/rotation semantics, but obey the accepted hard DB cutover; do not implement its file-first milestones or PRD-to-board generator now. +5. **Do not gate every interactive runtime launch as if it were a mission task.** Enforce every tracked task/data mutation at the executor/DB authority boundary. Ephemeral interactive shells may launch, but receive no task mutation authority unless attached to a valid claim. +6. **Do not implement broad “churn intelligence.”** Token threshold and compaction-detected are deterministic sensors. Repeated-loop semantic detection is expensive, noisy, and premature until telemetry demonstrates a gap. +7. **The 100-rotation test is a final conformance bar, not an early unit-test tax.** First prove one rotation, then fault cases, then 100 repetitions in SOL-24. Requiring 100 before G3 would delay feedback without changing the design. + +## Orchestrator reconciliation notes + +- Pre-register each task’s acceptance checks from this document before showing implementation diffs to its reviewer. Author and reviewer remain different seats. +- SOL-07 permits a one-time import, **not** an interim store: no shadow writes, dual reads, or sync daemon. +- G1 is the budget escape hatch. If the 72K hard-path slice does not work, stop and remediate instead of spending the remaining ~222K. +- Docs belong in each behavior-changing PR where required; SOL-25 is cross-link/cutover validation, not permission to postpone essential docs. diff --git a/docs/remediation/KICKSTART.md b/docs/remediation/KICKSTART.md new file mode 100644 index 00000000..662a7557 --- /dev/null +++ b/docs/remediation/KICKSTART.md @@ -0,0 +1,49 @@ +# mos-remediation — Orchestrator Kickstart / Compaction-Survival Resume + +**You are `mos-remediation`, the project orchestrator for the Mosaic Stack remediation, launched in `/src/mosaic-stack`.** +This file is your fail-closed resume procedure. Read it on EVERY fresh/cleared session and on the FIRST turn +after any compaction. This mission's whole point is that manual compaction-survival is fragile — so follow this +mechanically until Build 3 (rotation) makes it automatic. + +## On resume (do in order, before any orchestration action) + +1. `cd /src/mosaic-stack`, then **`git fetch origin remediation/state`**. + ⚠ **The live board is on the rolling branch `remediation/state`, NOT on `main`.** `main` carries only + periodic snapshots, so reading the board from `main` will silently give you a STALE tick. Read the + live files at `origin/remediation/state` (e.g. `git show origin/remediation/state:docs/remediation/BOARD.md`), + or check that branch out. Every tick is pushed there immediately, so its HEAD is always the newest state. +2. Read `docs/remediation/MISSION.md` — the charter (goal, 4 builds, 15 decisions, sequencing, directives). +3. Read `docs/remediation/BOARD.md` **at `origin/remediation/state`** — the LIVE state: current phase, + in-flight tasks, fleet seat assignments, gate status. Single source of in-flight truth (kept < 8 KB; + older entries roll to `BOARD-LEDGER.md` via `board-roll.sh`). +4. Read the discussion checkpoint for full rationale if needed: + `../jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md` (or the jarvis-brain repo path). +5. **Residency attestation (fail-closed):** restate from the reloaded files — (a) the goal in one line, (b) the + current build/phase, (c) the BOARD head (in-flight tasks + who owns them). If you cannot, HALT and re-read. + Do NOT act on memory alone; a compaction may have dropped context silently. + +## Standing invariants (never violate) + +- **North star:** deterministic-right-answer → code/gate; LLM only for judgment. +- **Delivery gates:** author≠reviewer; PRE-REGISTERED diff-blind checks committed before reading the diff; + CI terminal-green; completion = merged PR + closed issue. rev-974 = the mosaicstack reviewer identity. +- **Dogfooding:** every fix validated against its live seed case (MISSION.md lists them). +- **Tracking → DB** (hard cutover); do NOT re-invest in flat-file tracking. jarvis-brain PDA is off-limits. +- **Git identity:** export `MOSAIC_GIT_IDENTITY=` so wrappers author correctly and survive respawn. + +## After every significant event + +Overwrite stale lines in `BOARD.md`, keep it < 8 KB, commit + push. The board IS your checkpoint until the +DB-backed rotation daemon (Build 3) exists. Persist typed state (phase, tasks, owners, gates) — never the transcript. + +## Fleet + +- Adversarial planners: `planner-opus` (robustness), `planner-sol` (pragmatic) — dispatch for task decomposition; reconcile their oppositional decomps. +- Coders/reviewers: dispatch per roster + delivery gates. Comms: `~/.config/mosaic/tools/tmux/agent-send.sh` + (`-L -s -S : --class `); always pass `-S`. +- Lead coordinator: Mos (`mos-claude`). Escalate only on the Constitution's escalation triggers. + +## Remote control + +On first startup, activate remote control for this session (`/remote-control`) so Jason can reach/drive you while +away. If the command is unavailable in this runtime, report it to Mos and continue — it is not a blocker. diff --git a/docs/remediation/MACP-WIRING-SCOUT.md b/docs/remediation/MACP-WIRING-SCOUT.md new file mode 100644 index 00000000..c3ba49b5 --- /dev/null +++ b/docs/remediation/MACP-WIRING-SCOUT.md @@ -0,0 +1,98 @@ +# MACP wiring investigation + +**Scope:** `/src/mosaic-stack` inspected at HEAD `b79336a8c11e2a4646a47ff8d295a226e0c71404`; read-only. Existing dirty/untracked state was not touched. + +## Verdict + +**(c) STRANDED.** `packages/macp` is exported, unit-tested, and registered as a CLI command group, but no production dispatch/execution code invokes its credential resolver, gate runner, or event emitter. +A separate MACP-named OpenClaw/orchestrator rail exists, but it redefines task/result types and gate/event logic instead of importing `@mosaicstack/macp`; direct `mosaic yolo|claude|codex|opencode|pi` also bypasses it. + +## 1. Production call sites vs tests + +### Production references to `@mosaicstack/macp` + +| Surface | Evidence | Actual use | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Unified CLI | `packages/mosaic/src/cli.ts:8,385` | Imports and registers `registerMacpCommand`; no task/gate/event execution. | +| Forge | `packages/forge/src/types.ts:1,17,68,79` | **Type-only** imports of `GateEntry` and `TaskResult`. Pipeline calls an injected abstract executor at `packages/forge/src/pipeline-runner.ts:189-190,299-300`, not MACP. | +| Mosaic package metadata | `packages/mosaic/package.json:36`; `packages/mosaic/src/runtime/update-checker.ts:172` | Dependency/update inventory only. | +| Agent | No match under production `packages/agent/src/**` | No MACP import/call. | +| Coord | No match under production `packages/coord/src/**`; dependency list is only `@mosaicstack/types` at `packages/coord/package.json:25-27` | No MACP import/call. | +| Plugins | No `@mosaicstack/macp` import under `plugins/**` | No package use; the MACP-named plugin is an independent implementation (below). | + +**Repository-wide production call-site search result:** excluding `packages/macp/**`, tests, worktrees, and build output, there are **zero** calls to `runGate`, `runGates`, `emitEvent`, `appendEvent`, or `resolveCredentials`. + +### `packages/macp` implementation is internally connected only + +- Public exports: `packages/macp/src/index.ts:1-48` exports Task/GateEntry/MACPEvent/TaskResult, credential resolution, `runGate(s)`, risk-floor, and event emission. +- Gate runner calls its own event emitter: `packages/macp/src/gate-runner.ts:187-236`. +- Event persistence implementation appends NDJSON to a caller-supplied path: `packages/macp/src/event-emitter.ts:11-27`. +- There is **no exported programmatic `submit` implementation** in `packages/macp/src/index.ts:1-48`; only the CLI placeholder named `submit`. + +### Test-only invocations + +- Gate runner: `packages/macp/__tests__/gate-runner.test.ts:96-242` invokes `runGate/runGates`. +- Event ledger: `packages/macp/__tests__/event-emitter.test.ts:46-133` invokes `appendEvent/emitEvent` against temporary `events.ndjson` files. +- Credential resolver: `packages/macp/__tests__/credential-resolver.test.ts` exercises resolver behavior. +- CLI tests only verify command registration: `packages/macp/src/cli.spec.ts:37-73`; `packages/mosaic/src/cli-smoke.spec.ts:8` imports registration. + +## 2. Gate on the live dispatch path + +### Direct Mosaic runtime launch bypasses MACP + +- Runtime commands dispatch directly to harness launch: `packages/mosaic/src/commands/launch.ts:730-801`. +- Claude/Pi go through the lease broker, then spawn the runtime: `packages/mosaic/src/commands/launch.ts:817-843`. +- Commander wiring sends `mosaic yolo ` and direct runtime commands to `launchRuntime`: `packages/mosaic/src/commands/launch.ts:1102-1157,1165-1167`. +- None of those ranges imports/calls `@mosaicstack/macp`, `runGates`, or `emitEvent`. + +**Result:** a direct `mosaic yolo`, `mosaic claude/codex/opencode/pi`, or underlying exec does not create a typed MACP Task, run the package gate-runner, or append a package MACPEvent. + +### Coord bypasses MACP + +- Coord reads/updates `docs/TASKS.md`: `packages/coord/src/runner.ts:6,306-386`; parser/writer is `packages/coord/src/tasks-file.ts:326-377`. +- Coord launches a child process directly: `packages/coord/src/runner.ts:397-427`. +- Mission state is its own `.mosaic/orchestrator/mission.json`/`next-task.json`: `packages/coord/src/mission.ts:8-12`; `packages/coord/src/runner.ts:15-16,355-384`. + +**Result:** Coord task execution has no MACP Task validation, package gate runner, or event append. + +### Forge bypasses MACP execution + +- Forge defines its own `ForgeTask` and abstract `TaskExecutor`: `packages/forge/src/types.ts:48-80`. +- The production CLI injects a **stub executor** that immediately reports completion with empty gates: `packages/forge/src/cli.ts:13-31,167,185`. + +**Result:** even `mosaic forge run` does not execute MACP gates or persist MACP events. + +### Separate MACP-named rail is not `packages/macp` + +- OpenClaw plugin registers an ACP backend named `macp`: `plugins/macp/src/index.ts:1-18,72-102`. +- It locally redefines `OrchestratorTask`, `TaskResult`, and gate-result shapes instead of importing package types: `plugins/macp/src/macp-runtime.ts:43-77`. +- It appends directly to `.mosaic/orchestrator/tasks.json`, triggers an external controller, and polls `results/.json`: `plugins/macp/src/macp-runtime.ts:290-329,437-483`. +- The controller independently implements `append_event`, `emit_event`, shell execution, gate execution, and results: `packages/mosaic/framework/tools/orchestrator-matrix/controller/mosaic_orchestrator.py:29-91,126-276`. +- Its gate loop runs raw string gates after worker success: `mosaic_orchestrator.py:213-235`; it does not support the package's structured `GateEntry`/AI-review behavior. +- Current checkout disables this controller: `.mosaic/orchestrator/config.json:2` (`"enabled": false`). +- Plugin references `tools/macp/dispatcher/pi_runner.ts` at `plugins/macp/src/macp-runtime.ts:85-91`, but `tools/macp/` does not exist in this checkout. + +**Result:** there is a parallel, optionally enabled MACP-shaped rail, not package integration. It cannot make `packages/macp` the enforced path. + +## 3. Event ledger status + +- Package persistence exists only as a library primitive: `packages/macp/src/event-emitter.ts:11-27` appends JSON lines to an arbitrary `eventsPath`. +- Package event emission is reached only from package `runGates`: `packages/macp/src/gate-runner.ts:204-236`. +- No production caller invokes package `runGates/emitEvent/appendEvent`; therefore no runtime destination path is configured for the package ledger. +- Test-only ledgers use temp paths: `packages/macp/__tests__/event-emitter.test.ts:35-133`; gate tests use temp `events.ndjson`: `packages/macp/__tests__/gate-runner.test.ts:171-242`. +- The separate Python controller writes `.mosaic/orchestrator/events.ndjson`: `mosaic_orchestrator.py:129-133,159-161,219-235`; the Mosaic Framework plugin only **reads** that file for context at `plugins/mosaic-framework/src/index.ts:279-316,430-438`. +- In this checkout, `.mosaic/orchestrator/events.ndjson` is absent and the controller is disabled (`.mosaic/orchestrator/config.json:2`). + +**Conclusion:** `MACPEvent` from `packages/macp` is defined/tested but not emitted or persisted by live production call sites. The similarly shaped Python ledger is a duplicate island. + +## 4. Coord link + +- `packages/coord` has no `@mosaicstack/macp` dependency/import: `packages/coord/package.json:25-27`; no matches in `packages/coord/src/**`. +- Coord's task model is Markdown `docs/TASKS.md` plus mission/session JSON: `packages/coord/src/tasks-file.ts:1-10,257-377`; `packages/coord/src/mission.ts:8-12`; `packages/coord/src/runner.ts:306-427`. +- It does not consume `.mosaic/orchestrator/events.ndjson`, MACP Task, MACPEvent, GateEntry, or TaskResult. + +**Conclusion:** Coord and `packages/macp` are disconnected islands. + +## Shortest wiring gap + +**Single integration point:** replace the duplicated execution/gate/event block in `mosaic_orchestrator.py::run_single_task` (`:126-276`) with one production Node `TaskExecutor` backed by `@mosaicstack/macp` (typed Task validation + `resolveCredentials` + `runGates` + `emitEvent`), and make Coord/Forge/OpenClaw submit through that executor. This queue/controller choke point is where `yolo|acp|exec` worker outcomes can be gated and journaled before completion is recorded. diff --git a/docs/remediation/MISSION.md b/docs/remediation/MISSION.md new file mode 100644 index 00000000..65df03a3 --- /dev/null +++ b/docs/remediation/MISSION.md @@ -0,0 +1,166 @@ +# Mosaic Stack Remediation — Mission Charter + +**Owner:** project orchestrator `mos-remediation` (Claude, launched in `/src/mosaic-stack`). +**Origin:** 2026-07-16..31 fleet lifecycle postmortem. **Status:** EXECUTING (planning complete; RM-01 in flight). +**HOLD lifted** for this workstream by Jason, 2026-07-31 — "begin full mosaic fleet operation on this." + +## Goal + +Convert the 15 accepted postmortem remediation proposals into a working, **dogfooded** implementation. +**North star:** anything with a deterministic right answer moves OUT of the LLM into a deterministic +gate/program; the LLM handles only genuine judgment. + +### First-class principle — observe the property, not the exit code + +> **No write is done until the requested PROPERTY is observed. A success exit code is not evidence.** +> +> **Success output is designed to be believed.** That is the whole reason the inert-gate class exists +> and why P-WRAPPER-001's tri-state (`verified` / `written-unverified` / `failed`) is not optional. The +> failure is not carelessness — a green is _engineered_ to be trusted, so trusting it is the default +> behaviour of a competent operator, not a lapse. +> +> Promoted to the charter by Mos (2026-07-31) after the orchestrator committed this exact error: a +> `--draft` flag was silently dropped by a wrapper fallback that still exited 0, and the PR was reported +> as a draft on the strength of the exit code rather than an observed `draft: true` (D-12). Twelve +> failure instances were banked in that session; **three of them were the orchestrator's own.** That +> ratio is the point — the mechanism must catch the mechanic too, or it is not a mechanism. +> +> Operationally: after any write, read back the property you required. Applies to gates, wrappers, PR +> flags, commit authorship, file installs, and message delivery alike. + +### First-class principle — pre-registration prevents retrofitting, and nothing else + +> **A pre-registered check set can fail in three distinct ways:** +> +> | mode | the set is… | found as | +> | --------------------------- | ------------------------------------------------- | -------- | +> | **WRONG** | a check does not test what it claims | D-8 | +> | **INCOMPLETE** | green while a criterion's requirement is untested | D-17 | +> | **INTERNALLY INCONSISTENT** | two criteria cannot both hold | D-18 | +> +> **Pre-registration protects against exactly one thing: retrofitting a check to fit the implementation +> it is supposed to judge.** It confers neither correctness, nor coverage, nor consistency. "We +> pre-registered the checks" has been treated as though it settled the question — it settles one of +> three. +> +> Promoted to the charter by Mos (2026-07-31). All three modes were found on this mission's own **first +> delivery**, by the machinery applied to its own work — not by inspection, and not by looking for them. +> +> **Enforceable form — RM-02's four clauses.** The registry must establish that: (1) each check is +> **right** — proven red for its own stated reason before its green counts; (2) the set **covers** — +> every criterion bound to a case that actually exercises it; (3) no two criteria **conflict** — +> mutual unsatisfiability is a registry defect discoverable by construction; (4) when a criterion's +> meaning changes, the registry **retains original text, restatement, and reason**, so evolution stays +> auditable. A criterion with no case that can fail for its own reason is unregistered in substance, +> however it reads in the manifest. + +### Corollary — never ship an integrity claim dressed as a property + +> A verification artifact that can be forged by whoever it is meant to catch verifies nothing. If a +> manifest, marker, ledger, or receipt is writable by the same actor whose behaviour it certifies, it +> **certifies the attack.** Such an artifact must sit inside the integrity envelope it belongs to, +> publish atomically, and carry a **tamper negative-control observed red** — otherwise its integrity is +> a _claim_, not a _property_. +> +> **If it cannot be made tamper-evident, say so and reconsider the approach.** Laundering foreign +> content as certified is the only unacceptable outcome; an honest "this cannot be verified" is always +> available and always preferable. + +### First-class principle — when a property cannot exist at the layer it was specified + +> Some required properties are **impossible at the layer that asked for them** — not hard, impossible. +> A local check cannot defend against an actor who can rewrite the check itself. When that happens, +> there are exactly three honest moves, and all three are mandatory: +> +> 1. **Implement what the layer _can_ guarantee.** Partial protection against the class it was actually +> born from is worth having. +> 2. **State the boundary precisely, in BOTH directions.** What it does _not_ defend, **and** beside it +> what it _does_. A reader who sees only the negative dismisses the check as worthless; one who sees +> only the positive over-trusts it. **Both together is the honest artifact** — either alone misleads. +> 3. **Record where the real guarantee will come from — as a TRACKED DEPENDENCY, not prose.** It must +> name a task that someone must close. _A documented gap with no owner becomes a permanent gap that +> reads as intentional._ +> +> **A written-down gap is acceptable engineering. An implied-fixed gap is this mission's core failure in +> a new costume** — a verification artifact that verifies nothing, with a green to prove it. +> +> Promoted to the charter by Mos (2026-07-31) from D-19. Origin: the RM-01 symlink manifest could not be +> made tamper-evident against a same-UID actor (CWE-345), because the manifest and its marker share one +> writable tree. The implementing seat **escalated rather than relabelling self-authentication as +> tamper-resistance** — the corollary above firing on its first real adversarial test, on the cheapest +> seat in the loop. Residual risk bound to **RM-59** (`depends_on: RM-12, RM-21, RM-25`), where the +> choke-point executor and spine verify from _outside_ the worktree's authority. + +## Decision record (authoritative, immutable) + +- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.** +- Site + `annotations.json`: `jarvis-brain/docs/postmortem-spec/site/` (committed, origin/main). +- Discussion checkpoint (rich rationale per proposal): `jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md`. +- Postmortem report: mosaicstack/stack PR #107 (merged 88f4ee04). +- MACP wiring scout (verdict c=STRANDED): [`MACP-WIRING-SCOUT.md`](./MACP-WIRING-SCOUT.md) (copied into this dir; TODO discharged). Its findings are sound; its _recommended wire-in point_ is superseded by DECISION-1. + +## The plan — 15 proposals collapse to 4 builds + hygiene + +| Build | Absorbs | What it is | +| ------------------------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **1. One choke-point service** (mechanical enforcer) | MISSION, STATE, AUDIT, WRAPPER, QUEUE | Deterministic program every task/data mutation flows through. **Wire the stranded `@mosaicstack/macp`** — typed tasks, gate-runner, event ledger, credential binding, tri-state write outcomes. ⚠ **Target CORRECTED 2026-07-31 (DECISION-1, Mos):** a new production Node `TaskExecutor` on the **live** dispatch path (`packages/mosaic` launch + `packages/coord`), which Coord/Forge/live-dispatch submit through. **NOT** `mosaic_orchestrator.py::run_single_task` — that controller is `"enabled": false` and references a dispatcher absent from this checkout; wiring it would strand the executor, reproducing this mission's own disease. The Python rail is **deleted**, not ported. Both planners reached this independently. | +| **2. One durable spine + hot path** | (storage under everything) | **PG system-of-record + Redis hot queue** (transactional-outbox). Mission/tasks/state-claims/audit-ledger/comms-inbox all land here. | +| **3. Rotation lifecycle** (finish the Mission Control Plane) | LIFECYCLE, CONTRACT, GUIDE, RECOVERY | Coordinator daemon: contract-hash binding, compaction-detected → rotate-not-compact, checkpoint→fresh-session→rehydrate, broker-independent recovery. Deterministic, not an LLM. Reuse `packages/coord`; existing PRD at `docs/mission-control/`. | +| **4. Comms service** | AUTHORITY, INBOX (+ versioning roadmap) | Envelope (comms/v1) → sole-path service → PG/Redis → pluggable adapters (tmux→Matrix/Discord/Slack/Telegram). Version the protocol, not participants. | +| **+ Hygiene & proof** | FLEET, WORKFLOW, CONFORMANCE | One roster-owned socket/host + stale GC; allowlist auto-sync; the conformance harness that fault-injects the failure classes and proves builds 1–4 hold. | + +## The finding that sets the cost + +**Built-but-unwired disease.** `@mosaicstack/macp` is stranded (nothing calls it); `packages/coord` primitives +exist; the Mission Control PRD exists; PG + Redis already run in-stack. Three duplicate MACP islands, an +orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retire, NOT greenfield. "Finish, don't re-spec."** + +## Sequencing (skeleton — adversarial decomposition refines this) + +1. **Spine + choke-point service** (builds 1+2) — foundation; unlocks MISSION/STATE/AUDIT/WRAPPER/QUEUE at one integration point. + (Per DECISION-1, a P0 phase of provable-gate + activation work precedes this; see `TASKS.md` §3.) +2. **Rotation daemon** (build 3) on that spine — the drift fix proper. +3. **Comms service** (build 4) — envelope → service → PG/Redis → adapters; retire direct-tmux. +4. **Hygiene + conformance** (build 5) — fleet convergence, allowlist sync, dogfood harness. + +- **Cross-cutting retirements:** flat-file orchestration tracking (hard cutover to DB), the 3 duplicate MACP islands, the silent `MOSAIC BYPASS`. + +## Standing directives (Jason, 2026-07-31) + +- **Dogfooding:** validate EACH fix against the live fleet failure that motivated it. Seed acceptance tests: + Pi brick (RECOVERY), scout-bounce (INBOX/FLEET), gate-6 inert + #1019 recursion (QUEUE), identity drift + (WRAPPER), auto-sync sweep (WORKFLOW), #1018 stale-consumed (INBOX). The fleet is its own test bed. +- **Orchestration tracking → DB**, hard cutover ("rip off the bandaid"), NO flat-file interim. jarvis-brain + PDA flat-files untouched. Current flat-file tracking runs as-is/unhardened until DB tracking is real, then one clean replace. + - ⚠ **QUALIFIED 2026-07-31 (DECISION-2, Mos):** the DB spine **must NOT be a single-point hard-stop.** + A broker-independent / degraded mode **and** a rehearsed rollback artifact are **design requirements** + (P-RECOVERY-001), binding now on RM-12, RM-13, RM-23, RM-36 and RM-53. This **supersedes** the earlier + orchestrator recommendation to pre-commit "no DB ⇒ the fleet stops" — that answer is _not_ on record. + Only the specific availability _target_ remains open, queued for Jason; it does **not** block current work. + +## The 15 decisions (one-line; full rationale in the checkpoint) + +1. **P-ACTIVATION-001** accept — transactional CLI+hooks+broker+version release; block launch on skew, fail-SAFE. +2. **P-AUTHORITY-001** MODIFY — structured authenticated inbox; envelope carries comms-PROTOCOL version; version the protocol not participants; N-version window. +3. **P-LIFECYCLE-001** accept — rotation not recursive compaction; pre-empt at token threshold; enforcer = deterministic coordinator; = finish Mission Control Plane. +4. **P-MISSION-001** accept — bind lanes to mission+task ledger; convention exists, ENFORCEMENT is the gap; mission+tasks → DB spine (hard cutover). +5. **P-QUEUE-001** accept — repair queue transport + exit-asserting non-null-case tests (gate-6 was INERT fleet-wide; #1019 fix recursed the same bug). +6. **P-STATE-001** accept — typed claims (source/confidence/TTL) not prose blob; MACP typed record; integrity fail-closed HMAC; don't fork a 4th island. +7. **P-AUDIT-001** accept — MACPEvent lifecycle ledger; EXTEND enum to lifecycle events; runtime-neutral (executor-emitted); retire duplicate Python ledger. +8. **P-WRAPPER-001** accept — identity derives from seat name + survives respawn; tri-state write outcomes MANDATORY; name safe target metadata. +9. **P-CONTRACT-001** accept — bind session to contract hash; re-anchor on policy-change OR compaction-detected; stale generation loses authority MECHANICALLY. +10. **P-INBOX-001** MODIFY — sole-path comms SERVICE; PG durable SoR + Redis hot queue (outbox, reconciliation sweeper); pluggable adapters; protocol-first, PG-first-then-Redis. +11. **P-RECOVERY-001** accept — broker-independent bootstrap recovery; honest capability labeling; break-glass LOUD+AUDITED+TEMPORARY not silent permanent bypass. +12. **P-GUIDE-001** accept — delete `/compact and continue` from orchestrator path (keep for ephemeral); removal = substitution (wire rotation trigger). +13. **P-FLEET-001** accept — one roster-owned socket/host; quarantine unmanaged; stale-session GC; prerequisite for INBOX identity-addressing. +14. **P-WORKFLOW-001** accept — auto-sync ALLOWLIST not denylist; worktree/lease isolation for agent docs/source; DB-tracking obviates the flat-file-sweep criterion. +15. **P-CONFORMANCE-001** accept — fleet lifecycle harness on REAL runtime artifacts + fault injection; the 100-rotations-lossless bar is a test; target the DB substrate. + +## Fleet operating model + +- **Project orchestrator** `mos-remediation` (this seat) owns the mission; coordinates under Mos (lead). +- **Adversarial task decomposition:** `planner-opus` (robustness) + `planner-sol` (pragmatic) each decompose + the plan independently; orchestrator reconciles into `TASKS.md`/DB tasks. Oppositional by design. +- **Delivery gates (non-negotiable):** author≠reviewer, PRE-REGISTERED diff-blind acceptance checks committed + before reading the diff, CI terminal-green, completion = merged PR + closed issue. rev-974 = mosaicstack reviewer. +- **Compaction survival:** see `KICKSTART.md` in this dir — the resume procedure. Persist typed state, not transcript. diff --git a/docs/remediation/TASKS.md b/docs/remediation/TASKS.md new file mode 100644 index 00000000..6d737813 --- /dev/null +++ b/docs/remediation/TASKS.md @@ -0,0 +1,894 @@ +# Remediation Backlog — Reconciled Execution Plan + +**Owner:** `mos-remediation` (sole writer). Workers read; they never modify this file. +**Sources:** [`DECOMP-OPUS.md`](./DECOMP-OPUS.md) (robustness, 38 tasks / 8 dissents) and +[`DECOMP-SOL.md`](./DECOMP-SOL.md) (pragmatic, 25 tasks / 10 defers / 7 dissents), produced +**independently** — neither planner read the other. Charter: [`MISSION.md`](./MISSION.md). +**Status:** EXECUTING — all three blocking decisions RULED by Mos on 2026-07-31 (§5). **RM-01 is +dispatched.** RM-03 is held pending Jason's disposition of PR #1023; nothing else is blocked. + +> **Provenance of the inputs (both clean).** `planner-opus` ran in a fresh session throughout. +> `planner-sol` initially began work at 64.3% dirty context despite a brief instructing it to reset; +> that run was **interrupted and discarded before it produced any output**, the seat was reset +> out-of-band to 0.0%, and the brief was re-dispatched. `DECOMP-SOL.md` is the product of the clean +> run only (it peaked at ~26% context). Both decompositions are therefore clean-context artifacts and +> are weighted equally here. The discarded dirty run is banked as dogfood seed D-4 and as task RM-58 — +> the failure it demonstrates is that _asking_ an agent to reset is not enforcement. + +--- + +## 1. What the two planners agreed on without collusion + +Independent convergence is the strongest signal available here, because neither planner could see the +other's file. Where both arrived at the same conclusion from opposite biases, I treat it as settled. + +| # | Convergent finding | OPUS | SOL | +| --- | --------------------------------------------------------------------------------------------------------------------- | ------------------- | -------------- | +| C1 | **The charter's wire-in point is wrong.** Do NOT wire the choke point into `mosaic_orchestrator.py::run_single_task`. | D2 (headline) | Dissent 1 | +| C2 | P0 hygiene/gate work must precede the spine, not follow it. | D1, phase P0 | G0, SOL-01/02 | +| C3 | No new deployable microservice; the executor is a library + the coord daemon. | implicit throughout | Dissent 3 | +| C4 | Comms adapters beyond tmux are out of scope for this mission. | D7 | DEFER 4/5/6 | +| C5 | The "100 rotations lossless" bar is a late conformance gate, not an early tax. | D4 | Dissent 7 | +| C6 | Redis is a derived hot path, never an authority; PG commits first. | R-013, R-054 | SOL-11, SOL-21 | +| C7 | Reuse `packages/coord`; do NOT revive the untracked `apps/coordinator` residue. | R-042 | SOL-15 AC5 | + +**C1 is the single most consequential output of this exercise.** The charter (`MISSION.md`) and my +kickoff instruction both name `mosaic_orchestrator.py::run_single_task:126-276` as the integration +point. Both planners independently rejected it on the same evidence: that controller is +`"enabled": false` (`.mosaic/orchestrator/config.json:2`) and references a dispatcher path +(`tools/macp/dispatcher/pi_runner.ts`) that does not exist in this checkout. Wiring the new choke +point into a disabled rail produces **a stranded executor — the identical built-but-unwired disease, +one layer up, that would look "done" in a PR.** The live paths are +`packages/mosaic/src/commands/launch.ts` and `packages/coord/src/runner.ts`. +This contradicted the charter and was escalated as DECISION-1 — **now RULED in the planners' favour by +Mos (§5)**. The corrected target is a new production Node `TaskExecutor` on the live dispatch path +(`packages/mosaic` launch + `packages/coord`) that Coord/Forge/live dispatch submit through; the +Python rail is deleted, not ported. + +--- + +## 1a. ★ KEYSTONE DOGFOOD CASE — an inert gate that erased its own evidence + +**A merged commit shipped past `pnpm format:check` — and then the evidence quietly erased itself.** + +Verified chain (blob-level, under the repo's own prettier config, at the file's real path): + +| commit | state of `packages/mosaic/framework/tools/orchestrator/README.md` | +| ------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `b79336a8` — **merged PR #868** | blob `3ee7f104` — **FAILS** `pnpm format:check` | +| `48fd1df2` — merged PR #872 (unrelated: ci-queue-wait 404 handling) | blob `3d3bb132` — passes; incidentally reformatted by that PR's `lint-staged` | +| current `origin/main` (`06e0d403`) | passes — **the gate now looks green** | + +So: PR #868 merged a file that fails a required gate ⇒ **the CI format gate did not block it.** The +gate was inert for that merge. Then an unrelated later PR's pre-commit hook reformatted the file as a +side effect, so `main` went green again **without anyone ever learning the gate had failed to fire.** + +> **Correction on record:** my first report to Mos said "format:check is RED on main _now_." That was +> true of the `main` my checkout was pinned to (`b79336a8`) and is **no longer true of current `main`**, +> which advanced mid-session. The inert-gate finding itself is unchanged and verified; only its +> present-tense framing was wrong. The hygiene PR therefore carries the `.prettierignore` fix only — +> the README needs no fix today. + +### Third live instance, same class — the queue guard, hit by this orchestrator + +Running the **mandated** pre-push guard during TASK-0: + +``` +$ ~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push +[ci-queue-wait] platform=gitea purpose=push branch=main sha=06e0d403… +[ci-queue-wait] state=unknown purpose=push branch=main +$ echo $? → 0 +``` + +**Two distinct defects in one tool**, both feeding RM-03: + +1. **Wrong exit** — `state=unknown` ⇒ `exit 0`. The defect at `ci-queue-wait.sh:282-288` that OPUS + documented and that PR #1023 is parked on. A required gate returned PASS on an indeterminate result. +2. **Wrong branch** — it evaluated `branch=main`, not the branch actually being pushed. Even a + correctly-exiting guard would have been answering the wrong question. + +**Standing doctrine (Mos):** until RM-03 lands, a green from this guard carries **zero information** +and must not be cited as merge evidence. Rely on reviewer clearance + real CI. + +Three independent live instances in a single session — format gate, agent context reset, queue guard — +is the class confirmed, not anecdote. + +### D-20 — the orchestrator's own documentation overclaimed, and a reviewer disproved it empirically + +`rev-974` blocked PR #1027 a second time. **The defect was not in the code — it was in this file**, at +D-18's entry, written by the orchestrator. + +Two faults, both mine: + +1. **D-18's AC2 restatement omitted the scope clause** that D-19 later established as mandatory + ("within an accidental/independent-mutation threat model"). +2. **D-18 asserted that the tampered-manifest control turns integrity "from a claim into a property."** + It does not, and _cannot_. That sentence was written **before** D-19 proved the property impossible + at this layer, and was never revised when D-19 landed. + +**The reviewer did not merely read it — it disproved it.** It performed a **same-UID consistent +manifest + marker rewrite**, and **preflight passed**. My documented claim was falsified by experiment. +Code, README, scratchpad and PR body all stated both threat-model directions correctly; **this file was +the only place still overclaiming.** + +**This is two banked findings firing on the orchestrator at once:** + +- **The integrity-claim corollary** — I wrote an integrity _claim_ in the voice of an integrity + _property_, in the very document that defines the rule against doing so. +- **D-14 (propagation)** — D-19 superseded D-18's assertion. I propagated the consequence into the + charter and the delivery conditions, but **not back into D-18 itself.** A ruling that fails to + propagate _backwards_ into the finding it supersedes is the same defect as one that fails to + propagate forwards, and I did not audit for that direction. + +**Corrected in place**, with the original wording quoted and the empirical disproof recorded, rather +than silently rewritten — the same standard demanded of any restated criterion. + +**Requirement on RM-02 (fifth clause).** Documentation asserting a _security or integrity_ property is +itself a claim requiring a negative control. Where a document states "X is guaranteed", the registry +must hold a case that **fails if X is not guaranteed** — and that case must have been observed red. +**Prose is not exempt from the mission's own evidentiary standard**, and prose in the _governing_ +document least of all: it is the artifact most likely to be quoted as authority long after the code has +moved on. + +**Reviewer credit.** rev-974 was briefed that its highest-priority check was "confirm the PR claims no +more than it can deliver, and a softened or omitted boundary is a finding even though the code works." +It applied that instruction **to the orchestrator's own governing document** and produced an experiment +to settle it. That is the review standard this mission is trying to make ordinary. + +### D-19 — an integrity property that cannot exist at the layer it was specified + +Implementing D-18's manifest, the seat + a Codex security review reached **CWE-345**: the symlink +manifest and the source-hash marker both live in the **same same-UID writable generated tree**, so an +actor with that UID can plant a rogue link, regenerate _both_, retain the fingerprint, and pass. **No +local cryptographic construction fixes self-authentication** without a key outside that actor's +authority; relocating the marker changes the path, not the authority. + +The seat **escalated rather than describing self-authentication as tamper-resistant** — the explicit +failure mode the charter corollary demands. That is the corollary working, on its first real test. + +**Ruling — Option A: scope AC2 to accidental / independent / stale mutation; retain the design.** +Rationale, recorded so it can be challenged: + +1. **The undefendable boundary is not the weak link.** An actor with same-UID write can already edit the + source, the tests, `scripts/preflight.mjs` itself, and `.husky/*`. If they have that, _nothing_ in the + local checkout is trustworthy — hardening the manifest buys no real security while **implying + protection that does not exist**, which is worse than the gap. +2. **What AC2 is actually for.** These checks exist because a five-month-stale `.next` produced 19 + phantom `TS2307` errors indistinguishable from real ones (**D-5**). That is staleness, drift and + foreign residue — and against that class the design demonstrably works. +3. **A real trust anchor arrives later, from this mission's own architecture.** An anchor must live + outside the actor's authority; for a fleet running as one user that means a separate service — + precisely the **choke-point executor + PG spine** of Builds 1–2, which verify outside the worktree's + authority. Hand-rolling key distribution for a local preflight now would duplicate that work badly. +4. Option C (structural policy, no manifest) is strictly worse — it cannot detect a **removed** expected link. + +**Option A is acceptable only with honest labelling**, or it becomes the disease it is meant to cure. +Conditions (last two added/sharpened by Mos): + +- Threat model stated verbatim in the code **and** the PR; the words _tamper-proof / tamper-evident / + secure_ **barred** from that context; the scope carried in AC2's restatement; every control kept + RED-first including manifest-only tamper. +- **State the boundary in BOTH directions.** Not only what it does _not_ defend (same-UID write; no + local construction can) but, beside it, what it **does** defend: accidental / independent / stale / + foreign-residue mutation — the **D-5** class it was born from (the five-month `.next` and its 19 + phantom `TS2307`s). _A reader who sees only the negative dismisses the check as worthless; one who + sees only the positive over-trusts it. Both together is the honest artifact._ +- **The residual risk is a HARD TRACKED DEPENDENCY EDGE, not a comment.** It is **RM-59**, owned by the + choke-point executor + spine work (`depends_on: RM-12, RM-21, RM-25`), and the AC2 scope note must + cite that id. _"Record where the real guarantee comes from" only holds if the record is a live + dependency someone must close._ **A documented gap with no owner becomes a permanent gap that reads + as intentional.** + +**The generalizable rule.** When a required property **cannot exist at the layer where it was +specified**, the honest moves are: implement what the layer _can_ guarantee, **state the boundary +precisely**, and record where the real guarantee will come from. **A known gap that is written down is +acceptable; a gap that is implied fixed is not.** Silence here would have shipped a verification +artifact that verifies nothing — with a green to prove it. + +### D-18 — two pre-registered criteria were mutually unsatisfiable, discoverable only at implementation + +Implementing D-17's fix surfaced a conflict **between** pre-registered criteria: + +- **AC2** (as written) — reject symlinked generated state. +- **AC4** — the canonical `pnpm -w build` succeeds and leaves no residue. + +Verified independently rather than taken on report: `apps/web/next.config.ts:4` sets +`output: 'standalone'`, and the built tree contains **42 legitimate pnpm dependency symlinks** under +`.next/standalone/node_modules`. A blanket descendant-symlink rejection makes the canonical build fail +its own preflight with exit 43. **AC2 read literally is unsatisfiable alongside AC4 under this +configuration**, and nothing short of building the tree would have revealed it. + +**Third distinct failure mode of a pre-registered check set**, completing the chain: + +| finding | a pre-registered check set can be… | +| ------- | ------------------------------------------------------------------ | +| D-8 | **wrong** — a check that does not test what it claims | +| D-17 | **incomplete** — green while a criterion's requirement is untested | +| D-18 | **internally inconsistent** — two criteria that cannot both hold | + +The implementing seat escalated instead of silently picking a winner. That matters: **quietly resolving +a conflict between pre-registered criteria destroys the point of pre-registering them** — the registration +exists so that changes of meaning are auditable rather than absorbed. + +**Resolution (orchestrator ruling).** Approved a **build-certified symlink manifest**: `.next` itself is +still rejected as a symlink; descendants are rejected unless _exactly_ certified by a manifest the build +publishes atomically. Strictly **stronger** than blanket rejection — it also catches a **retargeted** +symlink, which blanket rejection cannot distinguish from a legitimate one. + +**AC2 restated (recorded, not absorbed).** _Generated state must reject `.next` itself being a symlink +or non-directory, and must reject any descendant symlink not exactly certified by the build manifest — +added, removed, retargeted, or manifest-only-tampered all fail with exit 43 — **within an accidental / +independent-mutation threat model.**_ + +> ⚠ **This entry is superseded in part by [D-19](#d-19). Do not read D-18 standalone.** The scope clause +> above is load-bearing: the design **cannot** defend against a same-UID actor, which can rewrite the +> manifest and the marker consistently (CWE-345). D-18 was written **before** that impossibility was +> established. + +**Hardening required before this counts.** The manifest is itself generated state, so **a manifest +writable by whoever plants a rogue symlink certifies the attack** — that is the one way this design +fails. It must sit inside the same ownership/fingerprint envelope, published atomically via the existing +marker mechanism, with negative controls **observed red first** for: added, removed, retargeted, +**manifest-only-tampered**, plus a positive control that the canonical build passes. + +> ⚠ **CORRECTED (D-20).** This paragraph originally ended: _"without it, integrity is a claim rather +> than a property."_ **That overclaimed**, by implying the control makes integrity a _property_. It does +> not, and cannot. The manifest-only-tamper control detects **independent** mutation of the manifest; +> it confers **no authenticity** against an actor who rewrites manifest _and_ marker together. +> `rev-974` disproved the original wording empirically — a same-UID consistent manifest+marker rewrite +> **passed preflight**. Integrity here remains a scoped **drift-detection** property, never an +> authenticity one. See D-19 and the charter principle on properties that cannot exist at their +> specified layer. + +**Requirement on RM-02 (fourth clause).** The registry must detect **conflicts between registered +criteria**, not only wrongness and coverage. Two criteria that cannot simultaneously hold is a registry +defect discoverable by construction — and when a criterion is restated, the registry must retain the +original text, the restatement, and the reason, so the evolution stays auditable. + +### D-17 — a pre-registered criterion passed a green suite without being satisfied + +`rev-974` returned **CHANGES REQUESTED** on PR #1027 with one blocking finding, and it is the sharpest +instance of the session's theme because it occurred **inside our own verification machinery**. + +**AC2** was pre-registered before any code was written, and explicitly required that **symlinked +generated state be rejected**. The implementation does not do it: + +```sh +ln -s /etc/hosts apps/web/.next/reviewer-symlink +pnpm preflight # → "checkout preflight passed", exit 0 + # → required: generated-state exit 43 +``` + +The acceptance suite was **21/21 green** throughout. Confirmed independently rather than relayed: +`scripts/preflight.mjs:82-92` rejects symlinks on the **source** path; `:28` merely _skips_ symlinked +directories rather than rejecting them; and the **generated-state** path at `:141-163` `lstat`s and +checks `uid` (ownership) but **never** calls `isSymbolicLink()`. The suite's only symlink cases +(`preflight.test.mjs:59`, `:115`) cover the turbo binary and a _source_ file. No generated-state case +exists anywhere. + +**So: criterion pre-registered, suite green, requirement unmet.** Nobody was careless — the coverage gap +is _invisible from a green_, which is the entire problem. + +**This sharpens D-8 rather than repeating it.** D-8 established that pre-registration does not confer +_correctness_ (a check can be wrong when written). D-17 establishes the adjacent failure: +**pre-registration does not confer _coverage_** — a suite can be green, and every registered criterion +can appear satisfied, while a criterion's actual requirement is untested. The two together mean a +registry of checks needs **two** properties, not one: each check must be _right_, and the set must +_actually exercise_ what it claims. + +**Requirement on RM-02 (third clause).** The registry must bind each acceptance criterion to the +**specific case that exercises it**, and prove that case red before trusting its green. A criterion with +no case that can fail for _that criterion's stated reason_ is unregistered in substance however it +appears in the manifest. This is mutation testing pointed at the **criterion-to-case mapping**, not +merely at the gate. + +**Credit where due:** the reviewer also declined to re-run AC8, stating plainly that the PR carried it +forward with no runnable command rather than silently substituting a different boundary test. That is +the D-8 clause working a second time, in the same review that produced D-17. + +### D-16 — the local test gate and the CI test gate disagree by environment + +Mos flagged a shape worth chasing: if `pnpm test` exits non-zero on a _pre-existing_ guard, then either +`main` is red and merges step around it (the #868 shape again), or CI does not run that path. **Both +branches turned out wrong, and the truth is a third thing.** Established by running it, not by asking: + +CI runs **exactly** `pnpm test` (`.woodpecker/ci.yml`, `test` step) — the same command. So the path _is_ +exercised. Yet: + +| environment | result | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| CI container | `test` step **green** (#2158, #2167) | +| this host, clean worktree | **exit 97** — `WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated on bash 5.2.15(1)-release … expected [3 4], probe reported [3 5] (#973)`, after `PASS=18 FAIL=0` | +| this host, main checkout | **exit 1** — a _different_, second defect (below) | + +The guard is **environment-dependent**: it aborts on this host's bash and not in CI's container. `git +diff origin/main...` confirms PR #1027 touches **zero** files under `packages/mosaic`, so the guard is +genuinely pre-existing and unrelated — **f10-coder's report was accurate in every particular**, and +`main` is equally affected on this host. + +**So it is not "merges step around a red" — it is worse in one specific way: the local gate and the CI +gate do not agree about what passing means.** No agent on this host can obtain a green `pnpm test` at +all, on any branch, including `main`. A gate an operator cannot run is a gate that only CI enforces, +and a gate only CI enforces cannot be a pre-push gate. This is the hermeticity/portability class +already live as #1007 (PR #1024). + +**Second, independent defect found while establishing the above.** In the main checkout the same +package fails differently — exit 1 — because a test **scans the working tree** and asserts on files it +finds, picking up `apps/coordinator/venv/**` (third-party `site-packages`: `pi = math.pi` in `rich`, +`setuptools`, `mypy`). **A test whose result depends on untracked files present in the tree is not +hermetic.** This is the _same_ contamination source that made `pnpm format:check` unpassable (D-1/D-7 +hygiene) — one untracked foreign tree silently breaking two independent gates. + +**Requirements.** RM-01/RM-04: a gate must produce the same verdict on a developer host and in CI, or +declare loudly that it cannot run here — never diverge silently. RM-02 registers both as cases: the +environment-divergence guard, and a hermeticity control asserting a suite's verdict is unchanged by the +presence of untracked directories. Coordinate with #1007/#1024 rather than opening a third lane. + +**Ownership (Mos, 2026-07-31).** The hermeticity fix **is** PR #1024, which sits in **Jason's parked +delivery stack** — so, like #1023, its disposition is Jason's. Marked `SUPERSEDED-PENDING-JASON` +alongside #1023. D-16 strengthens the urgency but does not transfer ownership: **we do not open a third +lane on a parked PR.** The one-line escalation for Jason: _two independent gates +(`format:check`, `pnpm test`) were broken by a single untracked directory, and a third +(`pnpm test`) disagrees between host and CI — non-hermetic gates make every green host-dependent._ + +**Sharpened statement of the class (Mos).** A pre-push gate an operator _cannot run locally_ is a gate +only CI enforces — so pointing `.husky/pre-push` at it **misrepresents where the gate lives**. Combined +with the shared root cause across two gates, the finding is: **non-hermetic gates make every green +host-dependent.** A gate that only appears to pass depending on which host runs it is this mission's +exact subject, one meta-level up. + +**#1027 disposition (Mos):** proceeds on **CI-green**. CI is the authoritative gate; the local exit-97 +is a known host-specific guard abort, irrelevant to the merge decision. + +### D-15 — token scope is not repository permission (a THIRD capability layer) + +`f10-coder` was provisioned with `gitea-mosaicstack-f10-coder.token`, scopes `write:repository` + +`write:issue`, and the mint was verified by "repo access returns 200". It then failed to push: + +``` +remote: error: User permission denied for writing. +remote: error: pre-receive hook declined +``` + +Verified objectively rather than inferred (per the charter principle): + +| probe | result | +| ------------------------------------------------------ | ------------------------------------------- | +| `GET /repos/mosaicstack/stack/collaborators/f10-coder` | **404** — not a collaborator | +| repo permissions as seen by **its own token** | `admin: false`, `push: false`, `pull: true` | + +**Capability has at least three independent layers, and satisfying two proves nothing about the third:** + +1. **Token file exists** → raw-API authentication works (D-11b). +2. **`tea` login exists** → tea-dependent wrapper paths work (D-13). +3. **Repository permission granted** (collaborator/team membership) → _writes_ are actually authorised. + +A token can carry `write:repository` scope and still be refused, because **scope bounds what a token +may attempt; repository permission decides what the user may do.** They are different systems. + +**This is the charter principle failing on the very check meant to confirm capability.** The mint was +validated by an HTTP 200 on a _read_. A 200 proves reachability; it does not prove the property that +was required, which was **write**. Both the provisioner and I accepted it — the same +`written-unverified` treated as `verified` as D-12, one layer up, on a check whose entire purpose was +verification. + +**Requirements.** RM-50's pre-dispatch capability check must probe the **effective permission for the +operation intended** — for push authority, assert `permissions.push == true` as that seat, not token +existence and not a 200 on a read. RM-04's registry reconciliation covers all three layers, with a +must-fail control for each. A capability check that cannot fail on a seat lacking write permission is +itself an inert gate. + +### D-14 — a ruled decision did not propagate to the authoritative record + +DECISION-1 (the corrected choke-point wire-in target) was ruled by the coordinator and applied to +`TASKS.md`. **`MISSION.md` — the charter, the document a cold-starting seat reads first — kept the +superseded target for hours.** It was flagged `CONTESTED` in a board note, then the ruling landed and +nobody edited the charter. A seat resuming from the charter would have read the _rejected_ target as +authoritative and wired the choke point into a disabled rail — the precise failure the ruling existed +to prevent. + +Caught by hand, during an unrelated edit. Nothing would have caught it otherwise. + +**This is P-MISSION-001 turned on ourselves.** The mission's own thesis is that convention exists and +_enforcement_ is the gap: a decision that lives in a chat ruling and a board note, but not in the +source of truth, has not actually been made — it has been _agreed_. The two are different, and the +difference is exactly what this mission is about. + +> ⚠ **AMENDED by D-20 — propagation is BIDIRECTIONAL.** As first written, this requirement was read by +> both the orchestrator and the coordinator as _forward_ propagation only: a ruling reaches the +> documents that state the new rule. **D-20 proved that insufficient.** When D-19 superseded part of +> D-18, the consequence propagated forward into the charter and the delivery conditions but **never +> backward into D-18 itself**, which went on asserting a withdrawn claim — and a reviewer disproved it +> by experiment. **A supersession must update BOTH the documents that render the new rule AND the +> finding it retires, with the retired wording quoted rather than deleted.** Backward propagation is +> the same defect as forward; neither of us audited that direction until it bit. + +**Requirement (not merely a fix).** A ruled decision must propagate **mechanically** to the +authoritative record; it must not depend on someone remembering to edit a second file. Concretely, once +mission state is DB-backed (RM-53 / the P-MISSION cutover): + +- a decision is a **record**, not prose duplicated across documents; +- documents _render_ decisions rather than restating them, so there is one place to be wrong; +- and where duplication is unavoidable, a check asserts the authoritative record and the derived + document agree — with a must-fail control proving divergence is detected. + +Until then, the interim rule: **the same commit that records a ruling updates every document that +states it — and every finding it supersedes.** Interim rules are exactly what the DB cutover exists to replace. + +**The rule found a second instance within minutes of being written.** Auditing the charter against all +rulings to date (rather than waiting to be bitten again) surfaced that **DECISION-2 had also not +propagated**: `MISSION.md`'s standing directives still stated the DB hard-cutover with no mention of +Mos's binding qualification that _the spine must not be a single-point hard-stop_ (degraded mode + +rollback artifact required). A seat reading the charter would have designed toward an availability +posture the coordinator had explicitly rejected — and would have found the superseded +"no DB ⇒ the fleet stops" recommendation nowhere contradicted. Now corrected in place. + +**Two un-propagated rulings out of two rulings that touched charter text.** The propagation gap is not +an oversight that happened once; without a mechanism it is the _default outcome_. That is the argument +for making this a requirement rather than a discipline. + +### D-13 — two credential registries that can disagree (why the `--draft` fallback fired at all) + +Diagnosing D-12's root cause surfaced a distinct defect. There are **two parallel credential +registries**, and capability in one does not imply capability in the other: + +| registry | contents for identity `mos-dt-0` on `mosaicstack` | +| ------------------------------------------------------ | -------------------------------------------------------------------------------------------- | +| token files — `~/.config/mosaic/secrets/gitea-tokens/` | `gitea-mosaicstack-mos-dt-0.token` **EXISTS** | +| `tea login list` | **NO** `mosaicstack` login for `mos-dt-0` (only `mosaicstack-mos` and `mosaicstack-rev-974`) | + +So `get_gitea_token` succeeds and every raw-API path works, while every **tea-dependent** wrapper path +fails its login validation and silently degrades to the API fallback — which is exactly what dropped +`--draft`. **tea is not "stale"; the login simply does not exist for that identity.** + +This matters beyond one flag: capability was declared authoritative by the token-file set (D-11b), but +that registry does not govern the tea path. A seat can be _fully provisioned_ by the authoritative +registry and still lose functionality with no error — only a warning, and only on the degraded path. + +**Requirements.** RM-04 (activation coherence): the two registries must be reconciled — one source of +truth, or a startup check asserting they agree, with a must-fail control proving disagreement is +detected. RM-50: the pre-dispatch capability check must verify capability for the **path actually +used**, not merely token-file presence. + +**Confirmed working despite the gap** (so this is degradation, not outage): pushes, `pr-merge.sh`, +PR/issue creation via API fallback, comment posting, and all reads. Impact is confined to +tea-only features — `--draft`, `--labels`, `--milestone`. + +**Reconciliation run by Mos (the manual form of RM-04's assert-agreement, done once by hand).** For +`git.mosaicstack.dev`, the token-file registry holds **six** seats; `tea` holds logins for **two**: + +| state | seats | +| ------------------------------------------------ | -------------------------------------------------------- | +| token file present, **no** mosaicstack tea login | `f10-coder`, `jarvis`, `mos-admin`, `mos-dt-0`, `pepper` | +| token file present **and** tea login present | `rev-974` (only) | + +**Five of six provisioned seats are silently degraded on tea-only features.** This is _systemic_, not +a one-off — which is why the fix is registry reconciliation (RM-04) and not a per-seat mint. Minting +one seat would clear a symptom and leave the class live. + +Mos deliberately deferred the mint: it is not on RM-01's critical path, and additively editing shared +`tea` config underneath running work is a change he declined to make without cause. Full remediation — +mint the five missing logins **and** wire the startup must-fail assertion that _detects_ disagreement — +lands as RM-04 at a non-critical seam, or immediately if any seat needs a tea-only feature to progress. + +**Correction of record:** this supersedes D-11(b)'s claim that the token-file set is _the_ authoritative +capability registry. It is **necessary but not sufficient**. Capability is **per-path**: the token file +governs the raw-API path, the tea login governs the tea path, and the two can disagree silently. + +### D-12 — a requested SAFETY flag was silently degraded, and I did not check + +I created PR #1027 with `pr-create.sh ... -d` (draft) because it carries **partial, unproven work**. +`tea` authentication was stale, so the wrapper fell back to its raw-API path — which cannot set draft — +and emitted: + +``` +Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup. +``` + +The PR was created **not-draft**. I read the success output, saw the PR number, and moved on. I then +reported to the coordinator that the PR was "opened as draft". **It was open, mergeable, and marked +ready for ~25 minutes**, protected only by the words "DRAFT" and "do not merge" in its title and body — +i.e. by prose a human might read, not by the platform control I asked for. Detected only because a +watcher polled `draft:` and the value disagreed with my belief. Corrected by setting the `WIP:` title +prefix (Gitea's draft mechanism); `draft: True` verified after. + +**Three distinct failures, and the third is mine:** + +1. **Silent degradation of a safety flag.** The fallback path dropped `--draft` and still exited 0. A + fallback that cannot honour a _safety_ argument must fail, not proceed — degrading `--labels` is a + nuisance; degrading `--draft` publishes unproven work as ready to merge. +2. **The warning went to stderr and nothing consumed it.** It was correct, specific, and ignored — a + warning nobody acts on is indistinguishable from no warning. +3. **I did not verify the flag took effect.** I checked that the PR existed, not that it had the + property I required. This is the mission's own thesis turned on me: **I trusted a success exit code + over an observed state**, on exactly the class of tool this mission exists to distrust. + +**Requirements.** RM-02: a wrapper that cannot honour a safety-relevant argument must exit non-zero — +registered with a must-fail control asserting `--draft` on a degraded path fails rather than proceeds. +RM-24 (tri-state write outcomes): this is precisely `written-unverified` being treated as `verified` — +the PR write succeeded, the _requested property_ was never confirmed, and no one looked. + +### D-11 — seat identity did not survive into git, and seat capability is invisible at dispatch + +Two defects, one dispatch (RM-01 → `f10-coder`): + +**(a) Identity drift — P-WRAPPER-001, reproduced on our own delivery.** The seat's commits are +authored `mosaic-coder ` — the generic fallback. **You cannot tell from +git history which seat did this work.** Recorded, not rewritten: the drift is the evidence. + +> **Mechanism, corrected (Mos).** My original framing here was wrong, and the error was in the brief +> before it was in the finding. `MOSAIC_GIT_IDENTITY` resolves the **token** (which per-slot credential +> the wrappers act with). The **commit author** comes from `git config user.name` / `user.email`, which +> is a **separate setting** — it fell back to the generic value because nothing set it. Exporting the +> identity could never have fixed authorship. **My worker brief instructed only the export, so the +> seat did exactly what it was told and the commits were still mis-attributed.** +> +> **The requirement is coherence: token and authorship must agree.** A seat acting with +> `gitea-mosaicstack-f10-coder` must also commit as `f10-coder `. +> Either half alone is identity drift — one produces the right credential with the wrong author, the +> other the reverse. That coherence _is_ P-WRAPPER-001, and it belongs in seat setup, not in prose +> instructions a seat may follow correctly and still end up wrong. + +**(b) Capability opacity.** Nothing at dispatch time revealed that `f10-coder` had no credential for +the target provider. Per-slot tokens live at `~/.config/mosaic/secrets/gitea-tokens/`; the seat holds +`gitea-usc-f10-coder` but not `gitea-mosaicstack-f10-coder`. This surfaced only when the seat failed +**mid-task, after ~$9 and 69% of its context.** The orchestrator (me) selected a seat without any way +to check it could act on the target repo — and there was no way to check. + +`get_gitea_token` behaved **correctly**: it refused to fall through and borrow another slot's token, +failing loud precisely to protect gate-16 attribution. The tooling was right; the _dispatch-time +information_ did not exist. + +**This is P-RECOVERY-001's "honest capability labeling" applied to seats rather than services.** A seat +should declare what it can actually do — which providers, which repos, which credentials — and that +declaration must be **checkable before dispatch**, not discovered by failure after the budget is spent. + +**Requirements.** + +- **RM-04 (activation coherence)** gains the identity-binding half: seat setup must set **both** the + token identity **and** `git config user.name`/`user.email`, coherently. Verified by an + exit-asserting test that makes a commit and asserts its author — never assumed from an instruction + in a brief. +- **RM-50 (roster ownership)** gains per-seat capability declaration plus a **pre-dispatch capability + check**. Mos (who owns provisioning) confirms the check is mechanically trivial: **capability is + token-file existence.** Before dispatching seat `X` to provider `Y`, test that + `~/.config/mosaic/secrets/gitea-tokens/gitea--.token` exists; if absent, provision it or pick a + provisioned seat. **The token-file set is the authoritative capability registry.** A one-second check + would have replaced a mid-task failure that cost ~$9 and 69% of a seat's context. + +### D-10 — the queue guard's failure modes are exactly backwards + +`ci-queue-wait.sh` — a **required** pre-push/pre-merge gate — was observed this session doing both of +these: + +- **Fails OPEN on an unknown result.** `state=unknown ⇒ exit 0`, five times, during real pushes and + real merges. It also evaluates `branch=main` rather than the branch being acted on. +- **Fails CLOSED on credential resolution.** In a worker seat it aborted with + `Gitea token not found`, hard-blocking a legitimate push of completed, tested work. The worker + correctly stopped (Constitution gate 8). The identical command run from that worker's _own worktree_ + in another shell succeeded, so the checkout and remote were fine — the difference was the worker's + process environment. + +**A gate that waves through work it never checked, and blocks work that is ready, has its failure +modes inverted.** Availability failures (cannot reach the provider, cannot resolve a credential) +should degrade to a loud, auditable _inability to assert_ — never to a hard stop on delivery, and +never to a silent pass. Correctness failures (unknown, malformed, terminal-failure) are what must +block. + +This is also the **Pi-brick shape** (P-RECOVERY-001): a gate whose own unavailability prevents the +work needed to recover from it. + +**Requirement on RM-03, extending its existing two defects:** the guard must distinguish +`CANNOT_ASSERT` (credential/transport/provider unavailable — loud, audited, does not silently pass and +does not permanently block) from `ASSERTED_NOT_READY` (a real non-green CI state — blocks). Both are +registered R-002 cases with must-fail controls; neither may exit 0 silently. + +### D-9 — the comms path shell-interprets message bodies (injection-shaped, found by accident) + +Sending a status message with `agent-send.sh -m "...`backticks`..."` caused bash to **execute** the +backticked text as command substitution. The recipient received a mangled body plus a +`No such file or directory` error; the intended sentence never arrived. The message was reported as +delivered. + +This is the **same class** as the already-noted `pr-create.sh` backtick-quoting bug (M2 scratchpad): +**two tools in the comms path treat a message body as shell input.** A body that can execute on the +sender is a _correctness_ bug before it is ever a security one — and note the failure mode: the +send reported success while silently transmitting something other than what was written. Silent +corruption with a success receipt is precisely the pattern this mission exists to eliminate. + +**Requirement on RM-40 / RM-42 (comms/v1), hardened by Mos.** The envelope must carry its payload +**verbatim** and must not be subject to shell interpretation at **any** hop — sender, transport, or +adapter. Concretely: **file/stdin transport, never argv interpolation.** + +**Standing interim rule, effective now (Mos).** Until the envelope lands, use `agent-send.sh -f +` for any message body containing special characters — **never `-m`**. Passing a file sidesteps +argv interpolation entirely. **This rule is mandatory in every worker brief this mission issues**, +alongside the D-8 "if a check is unrunnable, say so" clause. Round-trip fidelity (send a body containing backticks, `$(…)`, quotes, and newlines; assert +byte-identical receipt) is a required registered test case under RM-02, including a must-fail control +proving the assertion can detect corruption. + +### D-8 — a PRE-REGISTERED acceptance check that was not runnable as written + +On PR #1025 the author (me) pre-registered AC2 with the fixture snippet `mkdir -p apps/*/venv/lib`. +In bash, when no `venv` exists the glob is unmatched and passes through literally, creating a +directory named `apps/*/venv/lib` rather than one per workspace. The check as written did not test +what it claimed to test. + +`rev-974` ran it **exactly as written**, observed the wrong behaviour, then re-ran the intended +assertion at an explicit path — **and said so in the review** rather than silently substituting a +working fixture and reporting PASS. + +Two things this establishes: + +1. **The instruction "do not adjust a check to fit the diff; if it is unrunnable, say so explicitly" + worked.** A silent substitution here would have produced a green AC2 that proved nothing, on the + exact task whose subject is gates that appear to work. The disclosure is what made the PASS + meaningful. +2. **Pre-registration does not confer correctness.** A pre-registered check is protected from being + retrofitted to the implementation; it is not protected from being _wrong when written_. This is a + small instance of the mission's own class — an unverified gate — occurring inside the mechanism + built to catch unverified gates. + +**Requirement on RM-02 (non-negotiable, sharpened by Mos).** The registry must **self-verify** that +every registered case demonstrably **runs** and demonstrably **fails on a known-bad input**. +Presence in the registry is **not** evidence. **A check is not trusted until it has been shown to +fail.** This is mutation testing / negative control applied _at the registry level_ — meaning +**the conformance harness must itself be conformance-tested.** A registered case that cannot fail, or +cannot run, is exactly as inert as an unregistered one, and the registry check must detect that +itself rather than assume it. + +**Requirement on RM-55.** The same recursion applies to the harness: it must be observed red before +its green is worth anything (OPUS R-063 AC1 already states this; D-8 is the empirical case for it). + +**Second, equally load-bearing lesson — reviewer disclosure is what makes a review trustworthy.** +rev-974 could have silently swapped in a working fixture and reported `AC2 PASS`. Nothing in the +process would have caught it, and the resulting green would have certified nothing — on the very task +whose subject is gates that only appear to work. The brief's instruction — _"do not adjust a check to +fit the diff; if it is genuinely unrunnable as specified, say so explicitly and explain why rather +than silently substituting your own"_ — is therefore not boilerplate. It is the clause that makes a +PASS mean something, and it must appear in **every** reviewer brief this mission issues. + +### D-7 — shared-tmpfs contention → cascading ENOSPC (live incident, 2026-07-31) + +The shared 30 G `/tmp` hit **100% ENOSPC** mid-session. It broke tool calls in **two different seats** +(mine and Mos's) — a single full disk degrades every agent on the host at once. Recurring: prior +incidents 2026-06-18 and 2026-07-17. + +Attribution matters, because the wrong owner cleans the wrong thing. Measured: + +| path | size | last modified | owner | +| ---------------------------------------------- | --------- | ---------------------------- | ------------------------------------------------- | +| `…/-src-mosaic-stack/6d2faee6…` (this session) | **88 K** | live | mos-remediation | +| `…/-src-mosaic-stack/c743185d…` | **3.6 G** | **2026-07-22** (9 days dead) | abandoned session, same project path | +| `…/claude-1001/pnpm-store` | **1.6 G** | **2026-07-23** (8 days dead) | abandoned; the live store is correctly on `$HOME` | + +So ~5.2 G — the bulk of the pressure — is **dead session scratch that nothing will ever read again**. +This is not a quota problem; it is **P-FLEET-001's stale-session GC, applied to disk instead of tmux +sessions.** The same missing capability (nothing owns reaping dead ephemeral state) produces both the +orphaned-session failure and this one. Reaping dead-session scratch belongs in RM-50 alongside stale +tmux-session GC. + +**Added to RM-01 as acceptance criteria:** heavy build artifacts (node_modules, package stores, build +output) must land on the main disk in the worktree, never on the shared 30 G `/tmp`. + +**Resolution, and the part that is actually the finding.** Mos verified the attribution independently +(mtimes, no process or `lsof` holding either path, no live session maps) and reaped both as lead +coordinator: `/tmp` went to 79%, 6.0 G free. But note _how_ it was resolved — **a human-authority seat +did it by hand, because the authority exists and the reaper does not.** That gap is the finding, not +the disk usage. + +Two doctrine points fall out, both binding on RM-50: + +1. **The fix is not "agents should tidy up."** Asking each seat to clean its own scratch is + `instructions are not enforcement` (D-4) wearing a different hat. A deterministic reaper must own + it — same conclusion the north star reaches for every other class in this mission. +2. **Refusing to unilaterally delete another session's scratch was correct, and the resolution is not + "be braver about deleting."** An agent guessing that someone else's state is garbage is exactly the + unreviewed destructive act the Constitution forbids. The resolution is that _ownership and liveness + become mechanically decidable_, so reaping is a determination rather than a judgement call. + +**Reaper requirements for RM-50:** liveness determined mechanically (process/`lsof`/session-map, not +mtime alone); an age threshold; a dry-run that reports what it would reap and why; and an audit event +per reap. Never a heuristic sweep — that would reintroduce the P-WORKFLOW-001 auto-sync failure in a +more destructive form. + +**The self-erasure is the important part.** An inert gate that is masked by unrelated downstream +commits produces no lasting artifact, which is precisely why this class survives for months. Detection +cannot rely on "is `main` currently red" — it must be per-merge-commit. + +This matters more than the one-line fix: + +- It is the **P-QUEUE-001 / P-CONFORMANCE-001 class** ("gate-6 was inert fleet-wide"), reproduced in + the repository this mission is remediating, discovered incidentally. +- It independently **validates OPUS premise A1** ("every gate is inert until proven otherwise") with + live evidence rather than argument — which is why RM-02 is adopted as the keystone (§2, X2). +- The file fix rides in its own hygiene PR. **The inert gate itself is NOT quiet-patched.** Per Mos: + it stays a first-class backlog item, because patching the symptom would destroy the signal. + +**Binding requirement on RM-02 and RM-55:** the gate registry and the conformance harness must assert +**"every merged commit passed every required gate"** — evaluated **per merge commit, against that +commit's own tree**, not against current `main`. As the table above proves, a "is main green today" +check would have reported all-clear. A merged-commit-that-fails-a-required-gate is the exact detection +signal, and it must be a registered must-fail case. A gate that cannot prove it blocked something has +not been shown to work. + +--- + +## 2. Where they genuinely disagree (not averaged — adjudicated) + +| # | Axis | OPUS | SOL | My ruling | +| --- | ------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| X1 | **Total cost** | 38 tasks, ~5.3M tok | 25 tasks, ~294K tok | **~18× apart.** Not reconcilable by splitting. They measure different things: SOL explicitly excludes orchestration/review/iteration overhead and assumes one remediation pass; OPUS prices the full loop. Adopt **SOL's scope** with **OPUS's rigor**, and treat SOL's G1 as a hard budget checkpoint (§4). Re-estimate empirically after the first three merged PRs rather than trusting either number. | +| X2 | **Gate registry (OPUS R-002)** | Keystone; blocks all P2 | Absent; only a queue-guard fix | **ADOPT OPUS.** Empirically validated in this very session: I found `pnpm format:check` red on `main` via merged PR #868 — a required gate that did not block. OPUS's premise A1 ("every gate is inert until proven otherwise") is not theoretical; it reproduced today, unprompted. Scope it tighter than 120K. | +| X3 | **Drizzle PG first-install defect (R-010)** | Hidden blocker; everything downstream depends on it | Not mentioned | **ADOPT OPUS.** `packages/db/src/migrate.ts:30-38` carries a TODO admitting postgres-tier first-install fails today. The spine has only ever been proven on PGlite. Every later migration silently depends on this. SOL missed it. | +| X4 | **Rollback artifact for the hard cutover** | D3: hard cutover needs a rehearsed rollback snapshot | SOL-07: import-only, explicitly no dual-write | Both obey "no flat-file interim." OPUS wants a one-directional snapshot nothing reads as authority. I read that as compatible with the directive, but it is Jason's call → **DECISION-2** (§5). | +| X5 | **Where the queue guard sits** | P0, independent of spine | SOL-02, also early | Agree it is P0. But ownership collides with **parked PR #1023** → **DECISION-3** (§5). | +| X6 | **Report-only rollout** | D5: only with a hard expiry, else withdraw | not raised | **ADOPT OPUS.** A report-only gate is by definition inert; expiry is the mechanism that stops it becoming the new fail-open. | +| X7 | **Availability trade (FC-7/FC-11)** | D8: "no DB ⇒ fleet stops" must be pre-committed in writing | not raised | Genuine availability regression, correctly identified. Needs Jason → folded into **DECISION-2**. | + +--- + +## 3. Reconciled DAG + +Phases run in order; `⛔` marks a hard barrier. `src` shows lineage (`O`=opus, `S`=sol, `O+S`=both). +Estimates are given as a **range** (SOL low / OPUS high) rather than a fabricated midpoint — the +spread is itself information, and X1 says we calibrate on real merged PRs. + +### P0 — Make gates provable, and stop the fleet re-bricking + +⛔ _No gate-introducing task in any later phase may merge before RM-02._ + +| id | task | src | depends_on | est (S/O) | tier | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------ | ---------------- | ------ | +| RM-01 | Reproducible non-root checkout; gate fails on **code, not env**; heavy artifacts OFF shared `/tmp` (banks D-1/D-2/D-5/D-7) | O+S+live | — | 6K / 60K | codex | +| RM-02 | **Gate registry + negative-control CI check** (anti-inert-gate harness) ★keystone | O | RM-01 | — / 120K | opus | +| RM-03 ⏸**HOLD** | Queue-guard: **two** defects — (a) `unknown`/`no-status`/malformed ⇒ ≠0, (b) guard evaluates `branch=main` instead of the branch being pushed | O+S+live | RM-02 | 8K / 100K | sonnet | +| RM-04 | Activation/version coherence; block launch on skew, fail SAFE; honest `doctor` labels | O+S | RM-01 | (in S-01) / 140K | sonnet | +| RM-05 | Break-glass replaces the three silent `MOSAIC BYPASS` fail-opens | O | RM-04, RM-02 | — / 120K | opus | + +> ⚠ **RM-05 must not merge before RM-04.** The bypasses exist because the lease-broker daemon was +> never _deployed_ on this host — removing the fail-open before deployment coherence is real +> re-creates the 2026-07-22 bricking incident. Hard edge, from OPUS. + +### P1 — Durable spine (PG) + +⛔ _No migration may merge before RM-10._ + +| id | task | src | depends_on | est (S/O) | tier | +| ----- | --------------------------------------------------------------------------------------------- | --- | ---------- | ---------- | ------ | +| RM-10 | **Fix the Drizzle postgres-tier first-install defect** ★hidden blocker | O | RM-01 | — / 90K | sonnet | +| RM-11 | Orchestration spine schema (tasks, attempts, gate_results, hash-chained ledger, typed claims) | O+S | RM-10 | 12K / 160K | opus | +| RM-12 | Spine client, fail-closed connection (no silent PGlite in prod) | O | RM-11 | — / 80K | sonnet | +| RM-13 | Atomic claims/transitions + transactional outbox + reconciliation sweeper | O+S | RM-12 | 12K / 140K | opus | + +### P2 — The single choke point + +⛔ _RM-25 (no-second-path) lands in the same milestone as RM-20, or the choke point is optional._ + +| id | task | src | depends_on | est (S/O) | tier | +| ----- | ---------------------------------------------------------------------------------------------- | --- | ------------------- | ---------------- | ------ | +| RM-20 | Canonical MACP contract completion (Task/Result/Event/Claim/tri-state outcome) | S | — | 8K / (in R-020) | codex | +| RM-21 | **Production `TaskExecutor`** backed by `@mosaicstack/macp` ★keystone | O+S | RM-12, RM-02, RM-20 | 16K / 220K | opus | +| RM-22 | Gate-runner hardening: `fail_on`, timeouts, **empty gate set = failure** | O | RM-21 | — / 120K | sonnet | +| RM-23 | Hash-chained MACPEvent ledger in PG + lifecycle EventType extension | O+S | RM-21, RM-11 | — / 160K | opus | +| RM-24 | Seat identity from `MOSAIC_AGENT_NAME` + **mandatory** tri-state write outcomes | O+S | RM-21 | (in S-03) / 150K | opus | +| RM-25 | **No-second-path gate:** terminal status writable only by the executor | O | RM-21, RM-23 | — / 140K | opus | +| RM-26 | `packages/coord` submits through the executor (retire direct spawn) | O+S | RM-21 | 16K / 140K | sonnet | +| RM-27 | `mosaic yolo/claude/codex/pi` launch path records typed Task + events | O | RM-21, RM-23 | — / 160K | sonnet | +| RM-28 | Delete the Forge stub executor (empty-gate-list "success"); Forge submits through the real one | O+S | RM-21 | 10K / 90K | codex | +| RM-29 | One-shot flat-file import + cutover readiness audit (dry-run, idempotent, no dual-write) | S | RM-13 | 8K / (in R-062) | codex | + +> **★ G1 — FIRST DOGFOOD. Stop here and prove it.** One live fleet task travels +> PG claim → TaskExecutor → worker → gates → terminal PG result/event, with **no** flat-file state. +> Adopted from SOL wholesale. If G1 cannot carry a real task, **do not build Redis, rotation, comms, +> or conformance** — remediate instead. This is the budget escape hatch (§4). + +### P3 — Rotation lifecycle (finish the Mission Control Plane) + +| id | task | src | depends_on | est (S/O) | tier | +| ----- | -------------------------------------------------------------------------------------------- | --- | ------------ | ---------------- | ------ | +| RM-30 | Typed state claims (source/confidence/TTL) with HMAC integrity, fail-closed | O+S | RM-11, RM-21 | (in S-03) / 170K | opus | +| RM-31 | Contract-hash binding; stale generation loses mutation authority **mechanically** | O+S | RM-21, RM-30 | 12K / 180K | opus | +| RM-32 | Durable compaction/token sensor (per-runtime thresholds, PreCompact event) | O | RM-23, RM-31 | — / 130K | sonnet | +| RM-33 | Typed checkpoint writer (structured claims, never transcript) + digest | O+S | RM-30, RM-32 | 12K / 150K | opus | +| RM-34 | **Rotation daemon:** watch → checkpoint → revoke → kill → relaunch → rehydrate | O+S | RM-33, RM-26 | 16K / 240K | opus | +| RM-35 | Rehydration attestation gate: refuse to act on an incomplete claim set | O | RM-33 | — / 130K | opus | +| RM-36 | Broker-independent recovery; remove silent bypass; honest capability labels | S | RM-34 | 12K / (in R-004) | sonnet | +| RM-37 | Delete `/compact and continue` from the persistent-seat path (**substitution**, not removal) | O+S | RM-34, RM-44 | (in S-16) / 60K | codex | + +### P4 — Comms service + +⛔ _RM-50 (one roster-owned socket per host) precedes identity-addressed delivery._ + +| id | task | src | depends_on | est (S/O) | tier | +| ----- | ---------------------------------------------------------------------------- | --- | ------------ | ---------------- | ------ | +| RM-40 | `comms/v1` envelope + protocol-version negotiation, LOUD reject | O+S | RM-11, RM-31 | 8K / 140K | opus | +| RM-41 | Comms service: PG state machine PENDING→RECEIVED→CONSUMED→DEAD-LETTER | O+S | RM-40, RM-13 | 16K / 200K | opus | +| RM-42 | tmux transport as a **dumb adapter**; durable retry before cursor advance | O+S | RM-41, RM-50 | (in S-19) / 160K | sonnet | +| RM-43 | Per-class coalescing + supersede (the stale-consumed-as-live fix) | O+S | RM-41 | 12K / 130K | sonnet | +| RM-44 | Redis Streams hot delivery + provenance guard (**Redis is never authority**) | O+S | RM-41, RM-13 | 12K / 170K | opus | +| RM-45 | Retire direct tmux sends; only the service may write a pane | O+S | RM-42, RM-43 | (in S-20) / 100K | codex | + +### P5 — Retirements, hygiene, conformance + +| id | task | src | depends_on | est (S/O) | tier | +| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------- | ---------------- | ------ | +| RM-50 | One roster-owned socket/host; quarantine unmanaged; **deterministic reaper for stale sessions AND dead-session disk scratch** (D-7) | O+S+live | RM-04 | 14K / 150K | sonnet | +| RM-51 | Auto-sync **allowlist** (never auto-stage unknown paths) + worktree/lease isolation | O+S | RM-02 | 8K / 110K | sonnet | +| RM-52 | Retire the Python controller + duplicate MACP islands (3 → 1) | O+S | RM-26, RM-27, RM-25, RM-28 | 14K / 110K | codex | +| RM-53 | Flat-file orchestration → DB hard cutover, with rehearsed rollback artifact | O+S | RM-27, RM-30, RM-34, RM-29 | (in S-10) / 200K | opus | +| RM-54 | Fleet-wide inert-gate audit against the RM-02 registry | O | RM-02 | — / 120K | sonnet | +| RM-55 | **Conformance harness:** fault-inject the live failure classes on real artifacts | O+S | RM-35, RM-41, RM-53 | 18K / 260K | opus | +| RM-56 | Retirement proof: CI asserts all three retirements are complete **and stay complete** | O | RM-52, RM-45, RM-53 | — / 90K | codex | +| RM-57 | Operator cutover docs + activation proof; map all 15 decisions to evidence | S | RM-04, RM-36, RM-45, RM-55 | 6K / — | codex | +| RM-59 | **Close the D-19 residual risk** — generated-state verification anchored **outside** the worktree's authority (executor/spine-side attestation), retiring the same-UID self-authentication gap | mos-remediation (D-19) | RM-12, RM-21, RM-25 | 20K | opus | +| RM-58 | **Mechanical pre-dispatch context reset** — the orchestrator resets a seat out-of-band and verifies it, rather than asking the agent to reset itself | mos-remediation (D-4) | RM-31, RM-50 | 8K | sonnet | + +**Critical path:** `RM-01 → RM-02 → RM-10 → RM-11 → RM-12 → RM-21 → RM-23 → RM-31 → RM-33 → RM-34 → RM-53 → RM-55`. + +--- + +## 4. Execution discipline + +- **Every row is one PR.** Author ≠ reviewer; `rev-974` is the mosaicstack reviewer identity. +- **Pre-registered, diff-blind acceptance checks are committed BEFORE the reviewer reads the diff.** + Both decomps wrote their ACs in runnable `⇒0` / `⇒≠0` form specifically to make this possible. +- **Every gate-introducing task carries at least one registered must-fail negative control.** This is + RM-02's whole purpose; a gate with no proven failure path manufactures evidence. +- **Cost tiers:** codex for mechanical/unambiguous, sonnet for normal feature work, opus reserved for + security/integrity/cross-cutting-invariant tasks. SOL priced 0 opus tokens; OPUS priced 14 opus + tasks. I am keeping opus only where the failure is _integrity_, not merely complexity. +- **G1 is the budget checkpoint.** If the first-dogfood slice overruns SOL's estimate by >3×, stop and + re-plan rather than spending the remainder. X1 says neither estimate is trustworthy until calibrated. +- **Defer list adopted from SOL** (10 items): mission dashboard/TUI, PRD-to-board auto-decomposition, + heuristic churn scoring, Discord/Slack/Telegram adapters, public MCP comms surface, protocol-v2 + negotiation, multi-region PG/Redis, event analytics UI. + +--- + +## 5. Decisions — all three ruled by Mos, 2026-07-31 + +**DECISION-1 — the wire-in point. ✅ RULED: accept the planners (Mos, 2026-07-31).** +The charter's `mosaic_orchestrator.py::run_single_task` target is the **disabled Python controller +this mission retires**; wiring the new choke point into the rail we are deleting is wrong. + +> **Corrected target (authoritative):** a **new production Node `TaskExecutor`** sitting on the +> **live dispatch path** — `packages/mosaic` launch + `packages/coord` — which Coord, Forge, and live +> dispatch all **submit through**. This is the MACP scout's _full_ recommendation ("replace the block +> **with** a Node executor **and** make Coord/Forge submit through it"), not a resurrection of the +> Python controller. RM-52 is therefore a **deletion** task, and Build 1's acceptance is measured on a +> live `mosaic yolo` invocation. + +Mos ruled this resolvable from the already-accepted retire-the-Python-rail decision — his authority, +not a Jason escalation. RM-21/RM-26/RM-27/RM-52 all take the corrected target. + +**DECISION-2 — rollback artifact + availability trade. ⏸ JASON-PENDING — NOT BLOCKING.** +The DB build is phases away, so this is queued for Jason's next session rather than escalated now. +**Binding requirement in the meantime (Mos, from P-RECOVERY-001):** the DB spine **must NOT be a +single-point hard-stop.** Design for a broker-independent / degraded mode **plus** a rollback +artifact. Jason finalises only the specific availability target. This reverses my earlier reading of +OPUS D8 ("the fallback is: the fleet stops") — that answer is **not** pre-committed; a degraded mode +is now a design requirement on RM-12, RM-13, RM-23, RM-36 and RM-53. + +**DECISION-3 — RM-03 vs. parked PR #1023. ✅ RULED: HOLD RM-03 (Mos, 2026-07-31).** +Do **not** open a third gate-6 lane — that is the postmortem's own anti-pattern performed by the +remediation. PR #1023 sits in Jason's **parked delivery stack**; its disposition (close, or supersede +by RM-03) is Jason's at his next session. + +- **PR #1023 → `SUPERSEDED-PENDING-JASON`.** RM-03 stays `HOLD`; when Jason rules, RM-03 proceeds as + the single correct lane. +- **RM-02 and RM-55 proceed independently and are NOT held.** The per-merge-commit gate-assertion + requirement is the _conformance_ capability, not the gate-6 fix itself — different scope, no + ownership collision. + +--- + +## 6. Status + +| phase | state | +| ------------------ | ------------------------------------------------------------------------------------------------------------ | +| Decomposition | DONE — both planners delivered independently | +| Reconciliation | DONE — this document | +| Blocking decisions | **RULED** — all 3 closed by Mos 2026-07-31 (§5); D-2's availability target is Jason-pending but non-blocking | +| Dispatch | **RM-01 IN FLIGHT** — f10-coder (codex), worktree-isolated, AC1–AC8 pre-registered | +| Review | PR #1025 with rev-974; ACs pre-registered 22:12:26Z before diff exposure | diff --git a/docs/reports/code-review/756-code-review.md b/docs/reports/code-review/756-code-review.md new file mode 100644 index 00000000..fb8fd184 --- /dev/null +++ b/docs/reports/code-review/756-code-review.md @@ -0,0 +1,27 @@ +# Independent Code Review — #756 Official Discord Channel Plugin + +**Verdict: APPROVE** + +## Scope reviewed + +Complete current uncommitted #756 delta: multi-binding trusted agent selection, privileged ingress-route validation, attachment validation/persistence/resume, egress route lifecycle and idempotency, concurrent gateway stream state, Discord lifecycle/thread/rate behavior, compatibility ingress, and tests. + +## Review result + +No blocking or change-request finding remains. + +- **Trusted multi-agent routing:** each binding requires a provisioned `agentConfigId`; the gateway resolves that config server-side, verifies its logical-agent name, and never accepts a Discord-controlled agent selection or applies generic routing to Discord ingress. +- **Auth and route integrity:** allowlist, pairing, role, and canonical logical-agent/channel-or-thread conversation-route validation occur before gateway processing. Privileged approval/stop paths use the same binding and route validation. +- **Attachments:** ingress rejects malformed, over-bounded, credential-bearing, fragment-bearing, or query-bearing URLs. Valid attachment metadata, including `sizeBytes`, persists and is reconstructed into resume history as explicitly untrusted context. +- **Discord reliability:** the adapter supports degraded Socket.IO reconnect, parent/thread routing semantics, bounded pre-side-effect ingress rates, and terminal response-route cleanup. Egress validates route/message alignment, sends deterministic nonces, distinguishes permanent from transient errors, and uses bounded retries. +- **Concurrent state:** per-client/conversation keys isolate listener, redaction, tool, and stream state for simultaneous threads sharing a Discord socket; disconnect cleanup covers all associated conversations. +- **Harness neutrality:** contracts retain logical-agent/channel data only; no harness/provider identity leaks into adapter routes or message boundaries. + +## Verification performed + +- `git diff --check` — passed. +- `pnpm --filter @mosaicstack/discord-plugin typecheck` — passed. +- `pnpm --filter @mosaicstack/discord-plugin lint` — passed. +- `pnpm --filter @mosaicstack/discord-plugin test` — passed: 44 tests; coverage 92.18% statements/lines, 86.55% branches, 100% functions (all ≥85% threshold). +- `pnpm --filter @mosaicstack/gateway typecheck` — passed. +- `cd apps/gateway && pnpm exec vitest run src/plugin/discord-ingress.security.spec.ts src/chat/chat.gateway-redaction.spec.ts src/__tests__/integration/tess-cross-surface.integration.test.ts` — passed: 32 tests. diff --git a/docs/reports/compaction-refresh/830-documentation-checklist.md b/docs/reports/compaction-refresh/830-documentation-checklist.md new file mode 100644 index 00000000..83add361 --- /dev/null +++ b/docs/reports/compaction-refresh/830-documentation-checklist.md @@ -0,0 +1,34 @@ +# #830 Documentation Completion Checklist + +## Required artifacts + +- [x] `docs/PRD.md` contains the M1 compaction-refresh trust-lifecycle requirements and acceptance criteria. +- [x] Operator behavior and recovery are documented in `docs/guides/lease-broker-operations.md`. +- [x] Developer architecture and protocol behavior are documented in `docs/architecture/compaction-revocation.md`, `lease-broker-protocol.md`, and `mutator-class-gate.md`. +- [x] Security boundaries and residuals are documented in `docs/architecture/lease-broker-security.md` and `compaction-revocation.md`. +- [x] `docs/SITEMAP.md` links the new architecture page. +- [x] User-guide changes are not applicable: observers are mandatory internal runtime controls with no end-user workflow. +- [x] OpenAPI/endpoint changes are not applicable: the broker remains an internal Unix-socket protocol, not a public HTTP API. + +## Contract coverage + +- [x] Claude and Claudex lifecycle signals, matchers, commands, and fail-closed behavior are documented. +- [x] Pi pre-/post-compaction signals and session replacement reasons are documented. +- [x] Private generation-file ownership, monotonic update, same-PID replacement, and failure fencing are documented. +- [x] `revoke_lease` input purpose, broker response state, and denial behavior are documented. +- [x] T12b/T30 explicitly names the bounded residual stale window and reports within-TTL **ALLOWED** / after-TTL **DENIED**. +- [x] Documentation explicitly disclaims a within-window mutator-action bound. +- [x] T-A, T-C, same-principal, and protected-branch boundaries are retained. + +## Structure and review + +- [x] New architecture content is under `docs/architecture/`. +- [x] This report is under `docs/reports/compaction-refresh/`. +- [x] Session evidence is under `docs/scratchpads/`. +- [x] Documentation changes are in the same logical change set as code and tests. +- [ ] Independent exact-head code and Opus security reviews pending coordinator sequencing after the deterministic-main rebase gate. + +## Publishing + +- [x] Canonical documentation remains in-repository. +- [x] No external publishing target is required for this internal M1 control. diff --git a/docs/reports/deferred/758-fleet-config-deferrals.md b/docs/reports/deferred/758-fleet-config-deferrals.md new file mode 100644 index 00000000..f753a7e5 --- /dev/null +++ b/docs/reports/deferred/758-fleet-config-deferrals.md @@ -0,0 +1,54 @@ +# FCM-M5-001 Fleet Documentation Deferrals and Holds + +**Issue:** #758 · **Branch:** `docs/758-fleet-config-operator-docs` + +These are accepted existing DAG boundaries, not omissions silently claimed as delivered. + +## FCM-M3-002 hold + +- Boot/reboot preservation for roster members persisted stopped or disabled. +- Current installation may enable all agent units, while the launcher projection does not yet carry + `lifecycle.enabled` or `desired_state`; documentation therefore does not claim lifecycle-safe reboot. +- Heartbeat/liveness integration into roster-v2 `status`, `doctor`, and `verify`; current observations + cover systemd active state, tmux sessions, holder ownership, and unmanaged sessions only. + +## FCM-M4-002 hold + +- Executable v1-to-v2 cutover, reversible canary, and rollback. +- Stale-projection/orphan migration classification and current-host managed/unmanaged fixture coverage. +- Any live migration, lifecycle, systemd/tmux/session, or rollback action. + +M5 docs describe prerequisites and the preview boundary only. A ready preview is not migration or rollback evidence. + +## Explicit validate-operation gap + +- `FCM-REQ-03` requires a documented programmatic `mosaic fleet validate` operation. +- The current CLI does not expose that operation. Existing mutation/reconcile validation and the + documentation example test are not a replacement for the missing command. +- FCM-M5-001 documents this implementation gap without inventing syntax, JSON, exit behavior, or an + owning implementation card. Parent #758 must remain open until the requirement is implemented and + evidenced or the PRD/DAG is explicitly revised through the authoritative process. + +## FCM-M5-002 hold + +- Deterministic source-versus-installed asset revision detection and safe refresh implementation. +- Rolling local canary, independent validator certificate, final release evidence, merge-gate approval, and parent #758 closure. + +`operations/upgrade-assets.md` is therefore a fail-closed hold, not an invented procedure. + +## Compatibility interpretation + +The M0 cross-cutting row requiring every retained/migrated artifact to validate through the executable contract is satisfied by each artifact's declared executable disposition, not by forcing versioned v1 fixtures through the v2 parser: + +- retained examples are explicit `version: 1` fixtures validated by the production v1 parser; +- canonical profiles validate through the shared baseline plus `roles.local` resolver; +- the service preset validates through its production service-policy reader; +- migration candidates validate through the production v2 compiler and shared semantic resolver. + +The executable disposition inventory rejects undeclared additions/removals and prevents silent legacy drift. + +## Repository-wide documentation structure + +The accepted #758 IA is the domain book under `docs/fleet/`. Creating global `USER-GUIDE`, `ADMIN-GUIDE`, or `DEVELOPER-GUIDE` books and cleaning unrelated pre-existing `docs/` root files are outside this bounded card. The repository sitemap links the fleet book. No HTTP/API/auth contract changed, so OpenAPI and endpoint-index updates are not applicable. + +Canonical documentation remains in-repository; no external publishing or generated publishing output is in scope. Parent issue #758 stays open through M5. diff --git a/docs/reports/documentation/756-discord-plugin-checklist.md b/docs/reports/documentation/756-discord-plugin-checklist.md new file mode 100644 index 00000000..4fae2032 --- /dev/null +++ b/docs/reports/documentation/756-discord-plugin-checklist.md @@ -0,0 +1,36 @@ +# Documentation Completion Checklist — #756 Official Discord plugin + +## Required artifacts + +- [x] `docs/PRD.md` includes the #756 workstream, assumptions, and acceptance criteria. +- [x] User workflow updated in `docs/tess/USER-GUIDE.md`. +- [x] Administrator configuration and authorization policy updated in `docs/guides/admin-guide.md`. +- [x] Developer/plugin authoring guidance updated in `docs/tess/PLUGIN-GUIDE.md`. +- [x] Channel architecture updated in `docs/architecture/channel-protocol.md`. +- [x] Package operations/development guide added at `plugins/discord/README.md`. +- [x] `docs/SITEMAP.md` links the official channel plugin documentation. + +## API coverage + +- [x] No HTTP or WebSocket endpoint was added, removed, or changed. +- [x] No OpenAPI update is needed. +- [x] Shared TypeScript contracts are documented in architecture and plugin-authoring guides. +- [x] Discord authentication, authorization, thread failure, and control-command behavior are documented. + +## Structural standards + +- [x] Working notes remain under `docs/scratchpads/`. +- [x] Review and checklist artifacts remain under `docs/reports/`. +- [x] No generated publishing output was added. +- [x] Existing repository documentation structure was preserved; no unrelated root cleanup was attempted. + +## Review gate + +- [x] Independent documentation/contract review passes (shared-contract review plus final code review of the current documentation delta). +- [x] Independent code review verifies documentation matches implementation (`docs/reports/code-review/756-code-review.md`: APPROVE). +- [x] Independent security review verifies documented controls (`docs/reports/security/756-security-review.md`: APPROVE). + +## Publishing + +- [x] Canonical source remains in-repository. +- [x] No external publishing action is in scope for this slice. diff --git a/docs/reports/documentation/758-fleet-config-ia-closure.md b/docs/reports/documentation/758-fleet-config-ia-closure.md new file mode 100644 index 00000000..1bc424fb --- /dev/null +++ b/docs/reports/documentation/758-fleet-config-ia-closure.md @@ -0,0 +1,44 @@ +# FCM-M5-001 Fleet Documentation IA Closure Evidence + +**Issue:** #758 · **Task:** FCM-M5-001 + +## Artifact map + +- Fleet entry point and desired/observed decision tree: `docs/fleet/README.md`. +- Concepts: `docs/fleet/concepts/` covers authority/projections, identity separation, role authority/leases, and the generated launch chain. +- Operator workflows: `docs/fleet/how-to/` covers CRUD, lifecycle, interaction and validator instances, and role overrides. +- Operations: `docs/fleet/operations/` covers reconciliation/recovery, quarantine, systemd/tmux troubleshooting, backup/restore boundaries, and upgrade-asset holds. +- References: executable schema, complete field/default/constraint reference, CLI/JSON/exit behavior, lifecycle/status/drift, role authority, and generated environment boundary under `docs/fleet/reference/`. +- Migration: preview field map, lifecycle preservation, backup/recovery prerequisites, aliases, and executable artifact dispositions under `docs/fleet/migration/`. +- Navigation: `docs/SITEMAP.md` and the fleet entry point. + +## Acceptance mapping + +| Checklist area | Evidence | +| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Roster authority and fail-closed legacy handling | Root PRD FCM-REQ-01/05/08; desired/observed and quarantine pages. | +| Classes and authority | Root PRD FCM-REQ-07; role authority concept/reference; configurable interaction/validator how-tos. | +| Lifecycle | Root PRD FCM-REQ-04; lifecycle transition table and operator lifecycle how-to. | +| Local-only generated launch boundary | Root PRD FCM-REQ-05/09; generated launch concept/reference. | +| Complete DAG and artifact inventory | `docs/TASKS.md`; M0 inventory; executable disposition tests. | +| IA pages | Every path named by the M0 checklist exists and is linked from `docs/fleet/README.md`. | +| Examples | `docs/fleet/examples/roster-v2.yaml` validates through production v2 compiler/shared resolver; shipped artifact dispositions validate through declared production readers. | +| Links | Deterministic local Markdown link test covers the entire fleet book and sitemap, including local heading-fragment resolution. | +| Sensitive/example safety | Validator scans backtick- and tilde-fenced fleet-book examples plus the canonical roster for sensitive-looking keys, common credential formats (including Anthropic, OpenAI project, and Stripe restricted keys), path-qualified privileged commands, package-manager/root commands, arbitrary command override, and hardcoded Tess/Ultron identities; findings report only file/block and violation kind, never matched values. | +| Holds | `docs/reports/deferred/758-fleet-config-deferrals.md` records M3-002, M4-002, M5-002, compatibility, and repository-structure boundaries. | + +## Documentation completion checklist + +- [x] Root PRD exists and remains the #758 requirements authority. +- [ ] The accepted project-specific fleet book is indexed, but it is not complete against `FCM-REQ-03`: the required explicit programmatic `mosaic fleet validate` operation is not implemented. The CLI reference and deferral report record this gap without inventing behavior. +- [x] Sitemap links the fleet entry point and operator-critical pages. +- [x] No HTTP/API/auth contract changed; OpenAPI/endpoint rows are not applicable. +- [x] Working evidence remains under `docs/scratchpads/`; closure and deferral evidence remains under `docs/reports/`. +- [x] Canonical source remains in-repository; no external publishing action is in scope. +- [ ] Independent exact-head documentation review, PR CI, and FCM-M5-002 release certificate remain post-PR gates and are not claimed here. + +## Live-action boundary + +No migration, canary, rollback, deployment, systemd/tmux/session operation, generated projection, or product mutation was performed. `roster.yaml` remains the sole writable desired-state authority. `mos-comms` remains temporary. Parent issue #758 remains open. + +Validation command results and exact commit/tree evidence are recorded in the task scratchpad and PR body after execution. diff --git a/docs/reports/native-kanban-sot/canon-final-rereview-go.md b/docs/reports/native-kanban-sot/canon-final-rereview-go.md new file mode 100644 index 00000000..f48b42e4 --- /dev/null +++ b/docs/reports/native-kanban-sot/canon-final-rereview-go.md @@ -0,0 +1,59 @@ +VERDICT: GO + +# Native Kanban/SOT canon independent re-review 2 + +Independent read-only re-review of the complete updated staged canon. Prior proposal-audit blocker is closed; no KCR-001–016 regression or new blocker found. + +## Prior blocker closure + +- `contracts/kanban-schema.v1.ts:836-837` declares the required unique `task_events(workspace_id,id)` key before proposal declaration. +- `contracts/kanban-schema.v1.ts:885-894` adds both composite proposal audit FKs—submission and accepted-command event—to that exact workspace-aware key with `RESTRICT`. +- Declaration/migration order is executable and explicit in `SHARED-CONTRACT.md:79-91`: events/key first, proposal table second, both FKs third/fourth, then command enablement. This avoids forward-reference/circular-DDL ambiguity. +- Submission/acceptance semantics are frozen in `SHARED-CONTRACT.md:87-91`: preallocate proposal ID; create exact `change_proposal.submitted` event and proposal in one transaction; on acceptance lock proposal/target, execute the normal command, and bind only a same-workspace/target event with submission causation and `payload.changeProposalId` equal to the locked proposal. +- Required missing, foreign-workspace, unrelated-proposal, unrelated-target, and unrelated-command negatives are explicit in `REQUIREMENTS.md` REQ-SOT-004 and `SHARED-CONTRACT.md:121`; KBN-100/110/140 own migration, service, and integration evidence. + +## KCR closure matrix + +| KCR | Status | +| ------------------------------------------------------ | ------ | +| 001 health/proof | CLOSED | +| 002 error discrimination | CLOSED | +| 003 approval/assignment binding | CLOSED | +| 004 monotonic fencing/composites | CLOSED | +| 005 tenant-safe relations | CLOSED | +| 006 outage proposal persistence/commands/audit binding | CLOSED | +| 007 dependency/API freeze sequencing | CLOSED | +| 008 concrete N-1 map | CLOSED | +| 009 dependency uniqueness | CLOSED | +| 010 project congruence | CLOSED | +| 011 immutable audit retention | CLOSED | +| 012 retry/quarantine/vocabulary | CLOSED | +| 013 archive/tags target semantics | CLOSED | +| 014 recovery validator/owner slice | CLOSED | +| 015 pure Coordinator split | CLOSED | +| 016 health code/state pairing | CLOSED | + +Fixed invariants remain consistent: PostgreSQL is sole writable SOT; writes require transaction-local proof and fail closed; exports never import sources; notes are attributable proposals only; Coordinator has no scope/gate/certify/merge authority; Certifier has no merge authority. + +## Reproducible validation evidence + +Executed read-only with current-stack config/toolchain `/src/mosaic-mono-v1`: + +```text +./node_modules/.bin/prettier --config /src/mosaic-mono-v1/.prettierrc --check +PASS: All matched files use Prettier code style. + +strict TypeScript --noEmit --strict --skipLibCheck --target ES2022 --module NodeNext --moduleResolution NodeNext +PASS + +cascade/TODO/TBD/stale-hold grep plus composite-FK/semantic-marker invariant checks +PASS +``` + +The TypeScript check used a disposable copy under `/home/hermes/agent-work` solely to provide external-file NodeNext dependency resolution; the reviewed staging artifacts were not modified. + +## Residual findings + +None blocking. Implementation must execute the frozen KBN-100/KBN-110/KBN-140 proposal-event-chain tests and SecReview evidence before feature release, as already required by the canon. + +No artifact source repository, branch, PR, or provider state was modified. diff --git a/docs/reports/native-kanban-sot/canon-initial-review-no-go.md b/docs/reports/native-kanban-sot/canon-initial-review-no-go.md new file mode 100644 index 00000000..6a453587 --- /dev/null +++ b/docs/reports/native-kanban-sot/canon-initial-review-no-go.md @@ -0,0 +1,357 @@ +# Independent Review — Native Kanban/SOT Canon + +**Reviewer:** `enhance-sol` (independent of author `planner-sol`) +**Date:** 2026-07-13 +**Review mode:** design/contract only; read-only against the staged canon +**Source plan:** `/home/hermes/agent-work/planning/mosaic-native-kanban-sot-plan.md` (`sha256:96ea4fb91436ec9a53f371d27276e27f62ecf817662599ff9152df0db55296e5`) +**Canon reviewed:** every listed artifact under `/home/hermes/agent-work/planning/kanban-canon/`, including the four TypeScript contracts; the author scratchpad was also read as validation context. + +## Executive verdict + +# NO-GO + +The canon is not freeze-ready. I found **8 BLOCKERs**, **7 MAJORs**, and **1 MINOR**. The prose preserves the ratified authority model well, but the frozen types/schema leave concrete fail-closed, approval, fencing, tenant, outage-proposal, migration, and parallelization gaps. Those gaps would force implementation lanes either to invent contract semantics or to ship paths that violate fixed invariants. + +### Blocking findings + +1. Health/write authorization can be represented as contradictory, stale, or caller-asserted state. +2. Coordinator failures collapse authoritative denial, unknown transport outcome, and version conflict into one permissive shape. +3. Assignment proposals and approval proofs have no authoritative relational binding; lease acquisition accepts a forgeable proof DTO. +4. Fencing uniqueness is present, but monotonic fencing and same-task lease/checkpoint binding are not. +5. Workspace-safe accountable-owner, assignment-principal, and evidence/artifact relationships are not frozen. +6. Attributable post-recovery proposals have neither a canonical table nor command contract. +7. The slice graph starts schema/UI work before prerequisite threat and exact API/DTO freezes and contradicts coder4 lane order. +8. P0 claims a migration map while publishing only generic rules; the concrete N-1 transition from current `origin/main` is absent. + +--- + +## Findings + +### KCR-001 — BLOCKER — “Healthy” is not a proof and can be contradictory or stale + +**Location** + +- `contracts/health-state.v1.ts:21-31` — `KanbanHealthResponseV1` permits every combination of `state`, `readHealthProven`, and `writeHealthProven`. +- `contracts/mechanical-coordinator.v1.ts:40-49` — `CoordinatorContextV1` accepts a caller-supplied `healthState` enum only. +- `contracts/mechanical-coordinator.v1.ts:255-293` — every Coordinator operation, including mutating operations, accepts that context. +- `SHARED-CONTRACT.md:171-184` — mutations are allowed only after live PostgreSQL read/write probes. + +**Violation** + +Fixed invariant 2 / `REQ-SOT-002`: mutations must fail closed unless write health is positively proven. The current type permits `{ state: 'healthy', writeHealthProven: false }`, and the Coordinator mutation boundary can be invoked with a stale or fabricated `{ healthState: 'healthy' }`. A Valkey/client-derived enum could therefore be mistaken for write authorization. + +**Minimal fix** + +1. Make `KanbanHealthResponseV1` a discriminated union with only these legal combinations: `healthy => read=true/write=true`, `read-only-degraded => read=true/write=false`, and `write-unavailable => read=false/write=false`. +2. Do not accept write authority from a public DTO. Require Gateway/domain code to obtain and revalidate a fresh internal PostgreSQL write-health proof at mutation time (including `checkedAt`, bounded validity/policy revision, and transaction-local enforcement). +3. Split pure evaluation context from mutation context; mutation methods must accept only an unforgeable/internal healthy context or perform the probe themselves. +4. Add negative contract tests for contradictory state, expired proof, Valkey-only liveness, and caller-forged `healthy`. + +### KCR-002 — BLOCKER — Coordinator error shape can conflate denial, unknown outcome, and conflict + +**Location** + +- `contracts/mechanical-coordinator.v1.ts:184-216` — one `CoordinatorFailureV1` allows every code to pair with arbitrary `retryable` and either `requestOutcome` value. +- `contracts/health-state.v1.ts:51-106` — the Gateway health contract correctly distinguishes deliberate denial, transport uncertainty, and version conflict. +- `SHARED-CONTRACT.md:177-216` — frozen client semantics require those cases not to be conflated. + +**Violation** + +Charter E and `REQ-SOT-002`. The current Coordinator result can legally encode `WRITE_HEALTH_UNPROVEN` as `retryable: true, requestOutcome: 'unknown'`, or `VERSION_CONFLICT` as retryable. That permits blind retry or a false “unknown” outcome after an authoritative fail-closed denial. + +**Minimal fix** + +Replace `CoordinatorFailureV1` with a discriminated union keyed by code/kind: + +- deliberate health denial: `not_applied`, `retryable:false`; +- version conflict: `not_applied`, `retryable:false`, current version; +- stale fence/session/eligibility/approval failures: exact non-retry semantics; +- transport failure: a separate `retryable_transport_error`, `unknown`, same idempotency key. + +Reuse or map explicitly to `KanbanMutationFailureV1`, and add exhaustive client tests proving 503 authoritative bodies, 502/504/timeouts, and 409 cannot cross-map. + +### KCR-003 — BLOCKER — Approval proof is forgeable and is not linked to the persisted proposal + +**Location** + +- `contracts/mechanical-coordinator.v1.ts:107-137` — proposal and approval DTOs. +- `contracts/mechanical-coordinator.v1.ts:265-270` — `acquireApprovedLease` accepts the entire `ApprovalProofV1` by value. +- `contracts/kanban-schema.v1.ts:650-688` — `task_assignments` has no proposal expiry, task version, session binding, or proposal/approval FK. +- `contracts/kanban-schema.v1.ts:823-863` — `approval_decisions` can target only a task or mission and has no proposal/assignment relation. +- `SHARED-CONTRACT.md:128-137` — lease acquisition requires authoritative approval under the exact policy revision. + +**Violation** + +Fixed invariant 5 and `REQ-COORD-002/003`. A caller can construct an `ApprovalProofV1`; the schema cannot prove that it belongs to the proposal, workspace, task version, agent/session, unexpired policy revision, or still-current approval. The DTO state vocabulary (`awaiting_approval | policy_pre_authorized`) also does not map directly to the persisted assignment states (`proposed | approved | ...`). + +**Minimal fix** + +Persist one authoritative proposal/assignment identity with task version, target agent/session, expiry, state, and policy revision. Add a workspace-aware approval relation to that identity. Change lease acquisition to accept IDs, then reload and lock proposal + approval + task inside PostgreSQL and verify workspace, current version, target session, state, expiry, and policy revision before creating the lease. Freeze one state vocabulary across schema and DTOs. + +### KCR-004 — BLOCKER — Fencing is unique but not monotonically increasing; relational binding is incomplete + +**Location** + +- `contracts/kanban-schema.v1.ts:694-743` — `task_leases` has positive/unique fencing tokens but no monotonic per-task counter. +- `contracts/kanban-schema.v1.ts:748-784` — checkpoints independently carry task, lease, and fencing token. +- `contracts/kanban-schema.v1.ts:905-910` — token equality is deferred to prose; same-task lease binding is not stated. +- `contracts/mechanical-coordinator.v1.ts:140-175` — worker commands depend on fencing safety. + +**Violation** + +Fixed invariant 12 / `REQ-COORD-003`. Uniqueness permits token 10 followed by token 9. A lease can reference assignment A while naming task B in the same workspace, and a checkpoint can reference lease A while naming task B. `bigint(..., { mode: 'number' })` also eventually loses integer precision in JavaScript. + +**Minimal fix** + +Add a durable per-task fencing counter (or equivalent PostgreSQL sequence row) incremented atomically under task lock and use the returned value for every new lease. Add workspace-aware composite constraints tying lease to its exact task+assignment and checkpoint to exact task+lease+fence. Use bigint-safe representation (`bigint`/serialized decimal), and test monotonicity, concurrent claims, stale lower tokens, and mismatched same-workspace IDs. + +### KCR-005 — BLOCKER — Hard tenant boundary is not frozen for several polymorphic relationships + +**Location** + +- `contracts/kanban-schema.v1.ts:315-318` and `475-478` — project/task accountable owners are unvalidated `(kind, text id)` pairs. +- `contracts/kanban-schema.v1.ts:659-663` — assignment principals are unvalidated `(kind, text id)` pairs. +- `contracts/kanban-schema.v1.ts:758` and `841` — checkpoint/evidence artifact relationships are JSON arrays without workspace-aware FKs. +- `SHARED-CONTRACT.md:89-96` — only selected polymorphic checks are delegated to domain transactions; owner/principal/evidence checks are not included. +- `REQUIREMENTS.md:101-108` — every relationship must reject cross-workspace IDs. + +**Violation** + +Fixed invariant 7 / `REQ-TEN-001` and `REQ-ID-001`. The frozen schema can name a team or agent from another workspace as owner/assignee, and can embed foreign-workspace artifact IDs in checkpoint or approval evidence arrays. A global user ID is also insufficient without active workspace membership validation. + +**Minimal fix** + +Use workspace-aware owner/assignment join tables or separate nullable user/team/agent columns with exactly-one checks and composite FKs where possible. Model checkpoint/evidence artifact links as workspace-scoped join rows, or freeze explicit transaction checks for every ID. Require active workspace membership for user principals and workspace-agent/session consistency for agent principals. Add DB/repository/API/Coordinator cross-workspace negative tests without existence oracles. + +### KCR-006 — BLOCKER — Post-recovery outage proposals have no canonical persistence or command surface + +**Location** + +- `REQUIREMENTS.md:93-99` — proposal submission and authorized accept/reject are required. +- `SHARED-CONTRACT.md:26-29` — outage notes may return only as authenticated proposals. +- `SHARED-CONTRACT.md:243-267` — the thin command/query contract contains no proposal submit/get/accept/reject operations. +- `contracts/kanban-schema.v1.ts:1-916` — no proposal table captures proposed command, target/version, attribution, lifecycle, or decision. +- `TASKS.md:99-108` — KBN-110 does not own an outage-proposal command path. + +**Violation** + +Fixed invariant 4 / `REQ-SOT-004`. An implementation lane would have to invent storage or misuse artifacts/approval gates. Either path risks silently applying an outage note or creating shadow state. + +**Minimal fix** + +Add a workspace-scoped `change_proposals`/`outage_proposals` contract with authenticated proposer, source note digest, target aggregate, expected version, proposed typed command/payload, pending/accepted/rejected state, decision actor/reason/time, idempotency key, and audit linkage. Add explicit submit/query/accept/reject Gateway commands. Acceptance must execute the normal command in a healthy transaction; a proposal itself can never claim, order, satisfy a gate, or mutate the target. + +### KCR-007 — BLOCKER — Parallel slice ordering is not freeze-safe and contains a direct lane-order contradiction + +**Location** + +- `TASKS.md:43-60` — dependency graph makes KBN-010 and KBN-100 siblings. +- `TASKS.md:88-97` — KBN-100 nevertheless depends on KBN-010 threat findings that alter constraints. +- `SHARED-CONTRACT.md:243` and `INDEX.md:44-50` — exact route names/DTO placement remain unresolved. +- `TASKS.md:110-130` — KBN-120/130 depend on a frozen endpoint/DTO contract, while mocks may begin before KBN-110 lands. +- `TASKS.md:145-153` — KBN-200 says lane-serial after KBN-120. +- `TASKS.md:248-254` — wave table runs KBN-200 before KBN-120. + +**Violation** + +Charter C and the mandatory freeze-before-parallelize gate. Schema can begin before tenant/threat findings are complete; web/CLI consumers have only semantic operations, not exact DTO/endpoint contracts; coder4 has two opposite legal orders. This does not create same-file edits immediately, but it guarantees contract invention or rework across active lanes. + +**Minimal fix** + +1. Make KBN-010 (or an explicit constraint-impact gate from it) a completed prerequisite of KBN-100. +2. Add a small serialized KBN-105 endpoint/DTO/endpoint-registry freeze, with exact request/response/error DTOs, before KBN-120 and KBN-130 implementation. +3. Choose one coder4 lane order and use it consistently in slice text, graph, and wave table. +4. Name the exact MCP-owned files or assign their Gateway changes to coder3 before coder4 starts. + +### KCR-008 — BLOCKER — Claimed P0 migration map is absent; concrete N-1 hazards remain unresolved + +**Location** + +- `MISSION-MANIFEST.md:153-157` — P0 says to publish a migration map and states the build hold is lifted at line 3. +- `SHARED-CONTRACT.md:101-121` — only generic expand/backfill/contract rules are supplied. +- `contracts/kanban-schema.v1.ts:1-916` — target-state declarations reuse live table names and make target fields required. +- Current foundation evidence: `origin/main:packages/db/src/schema.ts:120-301` has no workspace keys, nullable project/mission links, legacy text status vocabularies, `tasks.tags`, `tasks.assignee`, `tasks.due_date`, mission JSON milestones/config, `mission_tasks.status`, and legacy agent fields. + +**Violation** + +Charter D / `REQ-MIG-001` and the P0 exit claim. The generic rule is correct, but coder2 lacks the required field-by-field transition map. A direct Drizzle reconciliation could attempt type narrowing/status conversion, add required workspace/project/owner columns too early, or drop legacy columns before N-1 readers and writers are retired. + +**Minimal fix** + +Publish a concrete current-main delta map before lifting the hold. For each existing table/column, specify expand, backfill, compatibility read/write, switch, and contract release. At minimum cover: + +- nullable-first `workspace_id`, required project/owner fields, and workspace backfill; +- legacy task/project/mission status aliases or shadow columns before v1 emission; +- `mission_tasks.status` read retirement and write-source prohibition; +- mapping/retention for tags, assignee, due date, mission description/config/milestones, and agent fields; +- current milestone circular FK ordering; +- empty, production-shape, partial-resume, and rollback/downgrade tests already named in §4. + +Explicitly require legacy columns to remain in the unified Drizzle declaration during the expand/N-1 window. + +### KCR-009 — MAJOR — Dependency uniqueness permits parallel duplicate edges + +**Location** + +- `contracts/kanban-schema.v1.ts:561-567` — unique key includes `dependencyType`. +- `SHARED-CONTRACT.md:47-49` — calls for a unique directed edge. +- `REQUIREMENTS.md:142-149` — duplicate edge attempts must fail. + +**Violation** + +`REQ-DEP-001`. The same predecessor/successor pair can be inserted three times, once per dependency type. That is not a unique directed edge and complicates readiness semantics. + +**Minimal fix** + +Make `(workspace_id, predecessor_task_id, successor_task_id)` unique independent of type, or explicitly redefine the requirement as one edge per type and freeze deterministic multi-edge completion semantics. The source plan says unique directed edge, so the former is the minimal faithful fix. + +### KCR-010 — MAJOR — Same-workspace planning relationships can contradict the project hierarchy + +**Location** + +- `contracts/kanban-schema.v1.ts:325` — `projects.currentMilestoneId` has no FK in the declaration. +- `contracts/kanban-schema.v1.ts:427-448` — mission/milestone association checks workspace but not common project. +- `contracts/kanban-schema.v1.ts:490-516` — a task’s project, mission, milestone, and parent only need share a workspace, not a project. +- `contracts/kanban-schema.v1.ts:905-908` — only current milestone is mentioned as a deferred invariant. + +**Violation** + +`REQ-PLAN-001` and schema correctness. A task in project A can point to a mission/milestone/parent task from project B in the same workspace. A mission can associate a milestone from another project despite having one required project. + +**Minimal fix** + +Add project-congruent composite keys/FKs (or freeze mandatory transaction checks) for task→mission, task→milestone, task→parent, mission→milestone, and project→current milestone. Add same-workspace/same-project negative tests. + +### KCR-011 — MAJOR — Immutable/append-only records can be erased by parent cascades + +**Location** + +- `contracts/kanban-schema.v1.ts:798-818` — `task_events` is described as append-only but remains under a workspace cascade. +- `contracts/kanban-schema.v1.ts:911` — only application-role UPDATE/DELETE privilege removal is stated. +- Numerous canonical relationships use `onDelete('cascade')`, including workspace roots and artifact/checkpoint/event owners. +- `REQUIREMENTS.md:41-43` and `154-170` — audit must be append-only, attributable, and reconstructable. + +**Violation** + +`REQ-AUD-001`. Revoking direct DELETE on `task_events` does not prevent a parent delete from cascading into the audit log. Checkpoints and immutable artifacts also lack explicit append-only privilege/retention semantics. + +**Minimal fix** + +Use lifecycle/archive states and `RESTRICT` for canonical parent deletion during normal operation. Freeze a separate, audited retention/break-glass purge procedure. Apply INSERT/SELECT-only or equivalent immutability controls to task events, checkpoints, and immutable artifacts, and test that parent deletion cannot silently erase them. + +### KCR-012 — MAJOR — Coordinator persistence lacks durable quarantine/retry state and DTO/schema alignment + +**Location** + +- `contracts/mechanical-coordinator.v1.ts:239-244` — expiry returns `quarantined` IDs. +- `contracts/kanban-schema.v1.ts:457-490` — task has only untyped `retryPolicy` metadata and no quarantine/execution disposition. +- `contracts/mechanical-coordinator.v1.ts:173` — `evidenceIds` has no corresponding evidence table/type; schema has artifacts. +- `contracts/kanban-schema.v1.ts:479`, `663`, and agent role JSON — specialist roles are free text despite the frozen role vocabulary in `mechanical-coordinator.v1.ts:19-29`. + +**Violation** + +`REQ-COORD-004` and internal consistency. PostgreSQL cannot deterministically reconstruct why/when a task was quarantined, its bounded retry state, or which typed evidence was submitted. Free-text roles allow the schema and engine to disagree. + +**Minimal fix** + +Freeze a durable execution/retry/quarantine record (attempt count, next eligibility, terminal reason, actor/policy, timestamps, version) or typed task columns with events. Align `evidenceIds` to artifact IDs or add a real evidence entity. Use one specialist-role enum/check across tasks, assignments, agents/sessions, DTOs, and Coordinator. + +### KCR-013 — MAJOR — Thin MVP promises task archive and tag filtering without target-state semantics + +**Location** + +- `REQUIREMENTS.md:182-193` — users must archive tasks and filter by tags. +- `SHARED-CONTRACT.md:252-267` — mutations include cancel but not archive task. +- `contracts/kanban-schema.v1.ts:457-490` — no task archive field and no typed tags field/table. +- Current `origin/main` already has `tasks.tags`, making omission from the target declaration a migration-loss hazard. + +**Violation** + +`REQ-UI-001/002` and internal acceptance consistency. “Archive” cannot be implemented without inventing whether it means cancelled, hidden, or soft-deleted; tag filtering has no frozen storage/query contract. + +**Minimal fix** + +Either remove task archive/tag acceptance from P1, or add explicit non-lifecycle archival semantics (`archived_at/by/reason`) and a workspace-safe tags model/query contract. Preserve/migrate the current tags column until the selected model is live. + +### KCR-014 — MAJOR — Recovery contract states critical rules only in comments and has no owning implementation slice + +**Location** + +- `contracts/recovery-posture.v1.ts:97-147` — exported JSON Schema validates only local field shapes. +- `contracts/recovery-posture.v1.ts:150-156` — PITR/WAL, effective RPO, off-cluster, high-assurance minima, and audit rules are comments only. +- `REQUIREMENTS.md:270-277` — parser rejection of impossible combinations is acceptance-critical. +- `TASKS.md:75-244` — no bounded slice owns recovery config parsing, override audit, backup/WAL setup, or restore/break-glass evidence. + +**Violation** + +`REQ-REC-001`. A consumer using the advertised JSON Schema can accept weakened high-assurance values, PITR without WAL, or an impossible RPO. The task plan has no lane accountable for closing that acceptance criterion. + +**Minimal fix** + +Export a normative `validateRecoveryPostureV1`/schema refinement with machine-testable cross-field checks and add a bounded Infra/recovery slice (serialized if it touches shared config) owning config parsing, override audit, mechanism verification, restore test, and break-glass evidence. Recovery config must continue to expose no authority/gate knobs. + +### KCR-015 — MAJOR — Pure Coordinator slice cannot implement two frozen methods without persistence access + +**Location** + +- `contracts/mechanical-coordinator.v1.ts:259-263` — `explainEligibility` receives only `taskId`, not a structured snapshot. +- `contracts/mechanical-coordinator.v1.ts:289-293` — `recoverFromPostgres` explicitly reads PostgreSQL. +- `TASKS.md:145-153` — KBN-200 is a pure engine with no SQL, Drizzle, Gateway, or Valkey. +- `TASKS.md:157-164` — persistence belongs to coder3/KBN-210. + +**Violation** + +Charter C and internal consistency. coder4 cannot implement the frozen port in a pure package without crossing coder3’s persistence boundary. If coder3 implements the port instead, KBN-200’s acceptance and ownership are misassigned. + +**Minimal fix** + +Split the contract into a pure decision engine that receives complete immutable snapshots and a persistence/orchestration service port implemented by KBN-210. Move `recoverFromPostgres` and ID-based loading to the adapter/service; make pure explanation accept a snapshot. + +### KCR-016 — MINOR — Health denial code/state pairs are not correlated by type + +**Location** + +- `contracts/health-state.v1.ts:35-61` — either denial code can pair with either degraded state. +- `SHARED-CONTRACT.md:188-190` — prose defines `KANBAN_WRITE_UNAVAILABLE` specifically for `write-unavailable`. + +**Violation** + +Health contract precision. A client can receive a semantically inconsistent authoritative body even after KCR-001’s broader state fix. + +**Minimal fix** + +Make deliberate denial a two-variant union with exact code/state pairing. + +--- + +## Clean checks / invariants that do hold + +The review did **not** find a gap in these areas: + +- The canon consistently selects current `mosaicstack/stack` + Drizzle/PostgreSQL and rejects greenfield/Prisma revival. +- Every artifact states PostgreSQL is the sole writable SOT and Valkey/files are non-authoritative. +- Generated `TASKS.md`, `mission.json`, and exports are consistently declared read-only and never import sources. KBN-300’s importer is scoped to immutable legacy JSON/Vikunja snapshots, not generated projections. +- Recovery config exposes recovery fields only; it contains no direct fail-open, SOT, Coordinator-authority, or gate-waiver knob. +- The Coordinator interface contains no `createTask`, acceptance-edit, gate-waive, certify, merge, release, or provider-close method. `submitForReview` is type-limited to `in_review`, not `done` or `certified`. +- Certifier is consistently final independent gate with no merge authority. +- The seven canonical task status values match across requirements, shared prose, schema, and Coordinator’s ready/in-review surfaces. +- One-active-lease partial uniqueness, no-self-edge, outbox aggregate-revision/event-type uniqueness, optimistic task/project/mission/milestone versions, and N-1 test categories are explicitly present. +- The file-tree partition is mostly well separated once the ordering/freeze defects in KCR-007 are corrected. + +## Required re-review scope + +After remediation, re-review at minimum: + +1. health/coordinator discriminated unions and mutation-time health proof; +2. proposal/approval/assignment/lease relational model; +3. monotonic fencing and composite bindings; +4. tenant-safe polymorphic relationships; +5. outage-proposal persistence and commands; +6. concrete current-main migration map; +7. corrected dependency graph and exact API/DTO freeze; +8. recovery validator/owner slice; +9. all schema and DTO vocabulary alignment. + +## Overall verdict + +**NO-GO — 8 BLOCKERs must be resolved before the v1 contract is frozen or parallel implementation begins.** diff --git a/docs/reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md b/docs/reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md new file mode 100644 index 00000000..3fbfa55c --- /dev/null +++ b/docs/reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md @@ -0,0 +1,109 @@ +# KBN-101 contract independent security/architecture review + +**Verdict: REQUEST CHANGES** + +## Review identity and scope + +- **Exact reviewed head:** `da742ca2da4a2ff466916c818fe275c4f7ffd384` (`docs(#771): record role-split review evidence`) +- **Required comparison:** `origin/main...da742ca2da4a2ff466916c818fe275c4f7ffd384` +- **Range:** `82ce3252df38a687c50485f8d048b53ca8db5989` is an ancestor of the reviewed head; the final head adds the scratchpad evidence commit and was reviewed. +- **Changed docs:** `docs/PRD.md`, `docs/SITEMAP.md`, `docs/native-kanban-sot/{INDEX.md,KBN-101-DB-ROLE-SPLIT.md,SHARED-CONTRACT.md,TASKS.md}`, and `docs/scratchpads/771-kbn101-db-role-split.md` (300 additions / 25 deletions). +- **Reviewed inputs:** issue #771; current DB/Gateway/storage/config/wizard/installer/compose/Portainer/CI sources; all current migration/DDL references; KBN-010, rc.4/rc.5 shared contract, requirements/canon, KBN-100 #769 branch context, and the final scratchpad. +- **Repository/provider state:** not modified. The pre-existing `.mosaic/orchestrator/*` dirt was not touched. + +The role graph itself is sound in principle: a NOLOGIN platform database owner, separate NOLOGIN schema owner, NOINHERIT migrator which explicitly `SET ROLE`s, and runtime membership only in a capability role with `SET FALSE` does not create circular privilege or application-created login roles. The split of foundation certification before KBN-100 and immutable-operation certification after KBN-100 is also correctly ordered. + +## Findings + +### HIGH — DDL/migration control plane is not closed at every current entrypoint + +The contract requires an explicit, locked migration phase and forbids Gateway/runtime DDL (`KBN-101-DB-ROLE-SPLIT.md:34-39`), but its KBN-101-02 result merely says migration-capable commands use the migration DTO (`:109`). It does not prohibit or route every existing bypass through that one command. + +Current bypasses include: + +- `runMigrations()` falls back from an argument to `DATABASE_URL` and a hard-coded URL (`packages/db/src/migrate.ts:24-35`), while `drizzle.config.ts` likewise uses `DATABASE_URL` plus a default (`packages/db/drizzle.config.ts:3-9`). +- Package scripts expose direct `drizzle-kit migrate` **and** `drizzle-kit push` (`packages/db/package.json:23-26`); `db:push` bypasses the planned journal/fingerprint/lock entirely. +- `mosaic storage migrate --run` shells out to the direct `db:migrate` script (`packages/storage/src/cli.ts:413-452`). +- The federated integration test can create types, tables, and indexes directly against `DATABASE_URL` and intentionally operates without a Drizzle ledger (`packages/db/src/federation.integration.test.ts:28-30,46-134`). + +**Failure mode:** a runtime or CI environment with only `DATABASE_URL`, or an operator invoking an existing command, can apply unverified DDL outside the lock, `SET ROLE` preflight, exact-ledger gate, and deployment sequencing. This breaks the requested fail-closed split even if Gateway startup is repaired. + +**Required remediation:** amend KBN-101-02/03/06 to enumerate these entrypoints and make the dedicated migrator runner the only PostgreSQL DDL path. Production-like `db:push` must be removed/blocked; `db:migrate`, `storage migrate --run`, and migration tests must invoke the same migration runner with `DATABASE_MIGRATION_URL`, lock, identity preflight, and ledger verification. Tests needing schema must consume a pre-migrated disposable database, or be explicitly run only by that migration phase. Add negative tests showing each command refuses `DATABASE_URL`-only execution and cannot reach DDL. + +### HIGH — TLS requirement has no deployable server/bootstrap contract + +The contract correctly requires a mounted CA and hostname-verified TLS (`KBN-101-DB-ROLE-SPLIT.md:25,28,93-95`). However KBN-101-05 promises only a “migration phase and secret binding boundary” (`:112`), not PostgreSQL server TLS, certificate issuance/SANs, CA distribution, startup ordering, or the fresh/existing-database bootstrap trust path. + +Current standalone and federated compose expose plain PostgreSQL with no server TLS configuration or CA mount (`docker-compose.yml:2-14`; `docker-compose.federated.yml:27-44`). The Portainer test stack passes a single plaintext in-network URL and uses the same database login for Gateway and database bootstrap (`deploy/portainer/federated-test.stack.yml:51-60,110-117`). + +**Failure mode:** enforcing the mandatory CA makes current local standalone/federated topologies unable to start; relaxing it to make bootstrap work silently violates K101-REQ-03. A first database cannot be safely migrated until the server certificate, its SAN for the actual service/DNS name, and trusted CA are provisioned, but this lifecycle is not owned or tested. + +**Required remediation:** add a concrete KBN-101-00/05 TLS bootstrap sub-contract: issuer/CA owner; server key/cert and SAN inputs; secure storage/mount permissions; `postgresql.conf`/container TLS enablement; migration and runtime CA mounts; hostname used by each compose/Swarm service; readiness only after TLS authentication; CA overlap rotation; and an existing-database transition. Require a disposable standalone and federated/Swarm test to prove verified TLS succeeds and missing CA, wrong CA, wrong SAN, and `sslmode` downgrade fail before readiness. Do not merge KBN-101-05 with an implicit plaintext exception. + +### HIGH — exact ledger fingerprint and historical 0009 repair are underspecified for existing databases + +The contract requires an “ordered complete set” and rejection of out-of-order rows (`KBN-101-DB-ROLE-SPLIT.md:36-38`), but does not define the canonical serialized tuple, ledger ordering source, or safe upgrade rule for a historical ledger. The current ledger stores only `id`, `hash`, and `created_at` (`packages/db/src/migrate.ts:70-82,105-107`). Its journal is demonstrably non-monotonic: `0008` has `when=1776822435828`, followed by `0009` at `1745280000000` (`packages/db/drizzle/meta/_journal.json:62-79`); the existing PostgreSQL runner documents that this causes skipping (`packages/db/src/migrate.ts:29-35`). + +**Failure mode:** an implementation can either reject a legitimate historical database after correcting 0009, or accept a reordered/duplicated ledger because no precise comparison rule exists. A count/hash-set implementation would fail to detect the condition that this contract explicitly calls unsafe; physical `id` order is not an adequate substitute after historical repair. + +**Required remediation:** freeze a versioned manifest algorithm before implementation: canonical record fields (at least journal index/tag, corrected logical order, migration content hash, and an explicit migration-manifest version), canonical byte serialization, SHA-256 input, and exact observed-ledger mapping. State whether physical ledger insertion order is normative; if not, compare hash-to-manifest tuples rather than timestamps. Add an idempotent migrator-only 0009 existing-database remediation/reconciliation procedure with backup/rollback evidence. Require clean, pre-0009, 0009-skipped, 0009-applied-late, duplicate, unknown, missing, corrupt-pair, and stale-replica cases. No manual ledger insertion is an acceptable production recovery path. + +### MEDIUM — advisory-lock namespace is collision-prone and lacks a fixed identifier contract + +The specified lock is `pg_try_advisory_lock(hashtext('mosaic-schema-migration-v1'))` (`KBN-101-DB-ROLE-SPLIT.md:34`). `hashtext` produces a 32-bit key. Session ownership/crash behavior is otherwise correctly stated (one session, same-session release, connection-close release), but an unrelated database user can accidentally collide or deliberately hold the key and force `DATABASE_MIGRATION_LOCKED`. + +**Failure mode:** avoidable migration denial of service in a shared PostgreSQL database. The current repository already uses separate `hashtext` advisory-lock names for migrate-tier, demonstrating the need for a documented namespace rather than a collision-prone implicit one. + +**Required remediation:** freeze a two-int advisory-lock namespace (fixed documented class/object values) or a documented 64-bit `hashtextextended` key with fixed seed; keep acquisition, migration, verification, and release on the single `max:1` migrator session. Add tests for concurrent migration, connection loss/crash release, readiness while the lock holder is active, and an unrelated lock-key non-interference case. + +### MEDIUM — identifier safety and `search_path` verification need executable constraints + +The contract rightly requires `pg_catalog, ` and rejects writable paths (`KBN-101-DB-ROLE-SPLIT.md:54,70-77`), but uses dynamic placeholders for database/schema and does not state how migration/bootstrap SQL will avoid identifier interpolation. Existing code has raw-SQL facilities (`packages/storage/src/migrate-tier.ts` uses `.unsafe`), so this is not merely theoretical. + +**Failure mode:** a future operator-configured database/schema value that reaches bootstrap or `SET search_path` through raw string construction can inject DDL, or a pooled connection can retain a mutable search path. + +**Required remediation:** require fixed allowlisted identifiers or server-side identifier quoting (`format('%I', ...)`) only; never interpolate URL/config values into SQL. Set and verify the trusted path per connection/session before any query (`SET LOCAL` inside transactions where applicable), forbid `public`/`$user` additions, and add injection-shaped identifier and pooled-connection reset negatives. Include this in KBN-101-00/01 tests. + +## Acceptance and threat traceability + +| Requirement / threat | Review result | Evidence or blocking finding | +| --- | --- | --- | +| K101-REQ-01 / AC-K101-01 split runtime/migration URLs | Partial | Role/DTO boundary is coherent; HIGH DDL-path finding requires all current commands to be closed. | +| K101-REQ-02 / AC-K101-02 explicit migration/readiness | Blocked | HIGH ledger definition and HIGH DDL-bypass findings. | +| K101-REQ-03 / AC-K101-03 least privilege, TLS, grants | Partial | Role model, default privileges, ledger read-only, TEMP/function checks are well specified (`KBN-101...:47-56,70-79`); HIGH TLS bootstrap and MEDIUM identifier constraints remain. | +| K101-REQ-04 / AC-K101-04 immutable relations | Correctly deferred | KBN-101-09 after KBN-100 is the correct serial gate (`KBN-101...:58-66,115-118`); no synthetic-only certification claim found. | +| K101-REQ-05 / AC-K101-05 N-1, secrets, rollback | Partial | No owner-runtime exception and rollback keeps migration URL out of Gateway (`:83-95`); deployable TLS and full command inventory are missing. | +| K101-REQ-06 / AC-K101-07 KBN gates and DAG | Structurally sound | DAG is acyclic: 00→01/{03}; 02→06; 00/01/03→05; 00/04/05/06→07→08→KBN-100→09→KBN-105. KBN-100’s current branch contains docs-only baseline tracking, not schema implementation. | +| T: runtime DDL / migration fallback | Blocked | HIGH finding 1. Current Gateway/storage, CLI, direct Drizzle scripts, and integration DDL require explicit closure. | +| T: race/crash/readiness | Partial | Same-session nonblocking lock and replica-unready rules are present (`:34-38`); lock namespace remediation required. | +| T: immutable evidence rewrite | Correctly staged | Explicit INSERT/SELECT-only matrix and RESTRICT retention are retained; proof is properly after table creation. | +| T: secret leakage / TLS downgrade | Partial | Redaction and distinct Vault paths are specified (`:93-97`), but no server TLS/bootstrap implementation contract exists. | + +## Unresolved assumptions + +1. `standalone` and `federated` are the complete PostgreSQL production-like set (K101-A1). +2. Each eligible deployment can execute a dedicated migration Job/one-shot phase (K101-A2). +3. Vault path names are targets, not verified existing paths; deployment ownership remains to be established. +4. PostgreSQL 17 is available for the selected membership and advisory-lock implementation. +5. The required server-side TLS issuer/certificate lifecycle and Swarm/compose secret transport have not been decided; this is blocking, not a permissible implicit plaintext bootstrap. +6. Historical databases containing the 0009 journal/ledger anomaly have no frozen reconciliation procedure. + +## Independent test and consistency evidence + +Read-only checks run in this review: + +| Check | Result | +| --- | --- | +| `git diff --check origin/main...da742ca2...` | PASS | +| `pnpm exec prettier --check` on all seven changed docs | PASS | +| `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` | PASS | +| `docker compose -f docker-compose.yml config --quiet` (isolated test ports) | PASS | +| `docker compose -f docker-compose.federated.yml --profile federated config --quiet` (isolated test ports) | PASS | +| Static journal inspection | FAILS the required monotonic ordering premise: 0008 → 0009 `when` decreases; current runner documents skipping behavior. | +| Static DDL-entrypoint inventory | Found direct Drizzle scripts, storage CLI shell-out, runtime extension/migration calls, fleet backlog migration, tier probe extension creation, and a direct-DLL federated integration test. | + +No live database, Vault, CI, deployment, issue, PR, or repository mutation was performed. The pass results validate documentation syntax/contract compilation and compose syntax only; they do **not** certify the proposed security behavior. + +## Conclusion + +Do not merge this frozen contract as implementation-ready until the HIGH findings are corrected and independently re-reviewed. The central role ownership/default-privilege design, immutable-table staging, and KBN-100/KBN-105 serial gating should be retained; they are not the reason for this REQUEST CHANGES verdict. diff --git a/docs/reports/native-kanban-sot/ultron-final-go.md b/docs/reports/native-kanban-sot/ultron-final-go.md new file mode 100644 index 00000000..92604f7c --- /dev/null +++ b/docs/reports/native-kanban-sot/ultron-final-go.md @@ -0,0 +1,38 @@ +# #751 Native Kanban/SOT canonical publication — Ultron final gate + +**Verdict: GO** — zero BLOCKER/HIGH findings. + +## Scope / integrity + +- Reviewed `/home/hermes/agent-work/stack-kanban-canon` staged delta only: exactly 16 documentation/contract artifacts; no unstaged delta; `git diff --cached --check` passes. +- This is a publication canon, not a runtime implementation. The explicit implementation hold prevents feature work until canon merge and prerequisite release (`docs/requirements/native-kanban-sot.md:8-9`; `docs/native-kanban-sot/TASKS.md:45-67`). + +## Acceptance mapping and findings + +| Requirement area | Final evidence / result | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Sole PostgreSQL SOT, generated projections, outage proposals | Requirements D3/D4 and fixed invariants prohibit alternate writers and import (`docs/requirements/native-kanban-sot.md:22-23,32-44`). Health contract keeps public observation separate from branded transaction-local proof (`contracts/health-state.v1.ts:44-84`) and freezes 503/409/502/504 mappings (`:91-184`). Proposal table uses workspace-aware event FKs (`contracts/kanban-schema.v1.ts:847-908`); exact submission/acceptance transaction semantics are specified (`SHARED-CONTRACT.md:81-89`). PASS. | +| Workspace tenancy, planning, assignments, evidence | Workspace-composite task and proposal relations plus active-member rules are explicit (`SHARED-CONTRACT.md:40-48`; `kanban-schema.v1.ts:587-637,875-908`). Lease/checkpoint relations bind workspace/task/assignment/session/fence, with one active lease and bigint fencing (`:1062-1114`). PASS. | +| Coordinator, gates, concurrency/recovery | Pure Coordinator has snapshot-only decision methods (`mechanical-coordinator.v1.ts:186-198`); persistence port owns locked ID validation and recovery (`:371-407`). Requirements forbid Coordinator scope/gate/certification/merge authority and Certifier merge authority (`requirements:39-40`; `MISSION-MANIFEST.md` authority table). Recovery validator rejects unknown fields, PITR/WAL/RPO/storage/high-assurance violations (`recovery-posture.v1.ts:193-369`). PASS. | +| Migration/N-1/API/task decomposition | N-1 expand/backfill/compatibility/switch/contract order and proposal DDL sequence are concrete (`SHARED-CONTRACT.md:69-115`). Frozen exact Gateway/DTO registry and non-overlapping lane ownership/prerequisites are present (`SHARED-CONTRACT.md:244-282`; `TASKS.md:45-67,81-259`). PASS. | +| Documentation / seven owner decisions / evidence | D1–D7 are all explicitly ratified (`requirements:20-26`); all 26 REQ sections contain acceptance criteria. Index/manifest/task graph link requirements, frozen contracts, ownership, and evidence. Relative-link audit passes. PASS. | + +## Independent verification performed + +```text +git diff --cached --check PASS +./node_modules/.bin/prettier --check PASS +./node_modules/.bin/tsc --noEmit --strict PASS +Python relative Markdown link audit PASS (0 errors) +Python requirement acceptance audit PASS (26 requirements; 0 missing acceptance sections) +Static staged scope/status check PASS (16 staged docs-only; no unstaged delta) +``` + +The full schema-contract strict type check cannot resolve `drizzle-orm` from this docs-only worktree; this is an environment dependency-resolution limitation, not a contract diagnostic. Independent external publication validation and final re-review record the strict all-four-contract check against the current Stack Drizzle toolchain as PASS. + +## Residual items + +- **LOW:** implementation must deliver the declared KBN-100/KBN-110/KBN-140 proposal-event-chain, tenant, failure-mapping, and SecReview evidence before P0/P1 release. This is a forward implementation obligation already frozen in the canon, not a publication defect. +- **LOW:** selected infrastructure backup provider/recovery tier and migration/cutover thresholds remain owner-controlled implementation decisions, bounded by the normative recovery contract and change control. + +No source, staging, commit, provider, CI, or deployment state was mutated. diff --git a/docs/reports/security/756-security-review.md b/docs/reports/security/756-security-review.md new file mode 100644 index 00000000..8f124ccc --- /dev/null +++ b/docs/reports/security/756-security-review.md @@ -0,0 +1,37 @@ +# Security Review — Issue #756 + +**Scope:** final current uncommitted Discord plugin, shared channel contract, gateway ingress, AgentService, and plugin registration delta +**Snapshot:** `plugins/discord/src/index.ts` SHA-256 `5ee6b6aa4e2ff349f137f76b918d02c1254e12066cb48b136048e57bef36fdb2` +**Verdict:** **APPROVE** + +## Final remediation verification + +| Area | Current evidence | Result | +|---|---|---| +| Attachment confidentiality and integrity | Ingress accepts bounded attachment metadata only when URL is HTTPS, query-free, fragment-free, and credential-free. Count, aggregate size, field length, MIME, and finite non-negative size validation apply before dispatch; `sizeBytes` is signed and retained. | Pass | +| Trusted agent selection | Each binding names a required trusted `agentConfigId`; gateway resolves that config server-side and requires its configured name to equal the binding logical-agent ID. Client/provider input cannot select the agent for Discord ingress. | Pass | +| Privileged operation routing | Gateway revalidates the binding and requires the signed conversation identity to match the configured logical agent before approve/stop actions. Paired admin identity and one-time approval checks remain enforced. | Pass | +| Egress containment and delivery | Egress requires an aligned message/route, configured parent or exact observed thread target, and cleans response routes after completion, errors, typed-ingress failure, or bounded-map pressure. | Pass | +| Side-effect limits | Per guild/authorized-parent/user rolling message and thread limits execute before thread creation or dispatch. | Pass | + +## Security controls reviewed + +- Default-deny guild, parent-channel, user, configured pairing, and role checks precede rate consumption and all side effects. +- Gateway independently enforces Discord service authentication, HMAC integrity, allowlists, binding/role checks, attachment validation, and replay-ID rejection. +- Thread authorization uses only the Discord thread parent; category parents cannot authorize ingress. +- Agent-visible attachment references are explicitly labeled untrusted; binary content is not embedded. Persisted attachment name/URL values are redacted. +- Egress uses deterministic nonces, bounded transient retry, typed terminal errors, and does not send to forged targets. +- No secrets or message content were added to plugin logs. + +## Verification evidence + +| Command | Result | +|---|---| +| `pnpm --filter @mosaicstack/discord-plugin test` | PASS — 44 tests; v8 coverage: 92.18% statements/lines, 86.55% branches, 100% functions | +| `pnpm --filter @mosaicstack/discord-plugin typecheck` | PASS | +| `pnpm --filter @mosaicstack/types typecheck` | PASS | +| `pnpm --filter @mosaicstack/gateway typecheck` | PASS | +| `pnpm --filter @mosaicstack/gateway exec vitest run src/plugin/discord-ingress.security.spec.ts src/agent/__tests__/agent-service-ownership.test.ts` | PASS — 29 tests | +| `git diff --check` | PASS | + +No unresolved critical, high, medium, or low security findings were identified in the reviewed final delta. diff --git a/docs/requirements/native-kanban-sot.md b/docs/requirements/native-kanban-sot.md new file mode 100644 index 00000000..291f5797 --- /dev/null +++ b/docs/requirements/native-kanban-sot.md @@ -0,0 +1,368 @@ +# Native Kanban and Canonical Task SOT — Canonical Requirements + +**Status:** RATIFIED and independently approved for canonical publication under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751) +**Date:** 2026-07-14 +**Decision owner:** Jason +**Publication owner:** web1 control plane (`mos-claude`; `mosaic-100` acting during Claude quota outage) +**Implementation foundation:** current `mosaicstack/stack` main only +**Implementation hold:** no feature implementation begins until this canon is squash-merged to `main` with terminal-green CI. + +## 1. Purpose + +Deliver Mosaic Stack's native project/task control plane and thin writable Kanban on one authoritative PostgreSQL model. This document formalizes the ratified source plan; it does not create a parallel design. + +Normative terms **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are binding as used here. + +## 2. Ratified decisions + +| # | Ratified decision | Canonical result | +| --- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| D1 | Foundation | Extend current `mosaicstack/stack` main with its existing Drizzle/PostgreSQL, NestJS Gateway, Next.js, Better Auth, and Valkey/BullMQ conventions. No greenfield service and no Prisma revival. | +| D2 | Tenant boundary | `workspace_id` is the hard tenant boundary from the first migration. Teams are authorization groups inside a workspace, never tenant substitutes. | +| D3 | Outage authority — Option A with amendment | PostgreSQL is the sole writable SOT and mutations fail closed whenever DB write-health cannot be proven. The amendment permits deployment-specific **recovery posture only**; it does not permit an alternate writer. Human outage notes are attributable post-recovery proposals, never shadow state. | +| D4 | Generated files | `TASKS.md`, `mission.json`, and any file export are generated, read-only, non-authoritative, and never import sources. Generate on demand; commit only where repository review policy requires a snapshot. | +| D5 | Status model | Task statuses are `backlog`, `ready`, `in_progress`, `blocked`, `in_review`, `done`, `cancelled`. Runtime readiness is orthogonal and computed. | +| D6 | Coordinator approval | Hybrid: manual Project Sub-Orchestrator approval by default; automatic routing only under an explicit, approved, versioned low-risk policy. | +| D7 | Initial migration scope | Project, mission, milestone, task, tags/archive, dependency, assignment, outage proposal, evidence/link, and orchestration state only. Calendar, email, GLPI cache, and personal-brain features remain out of scope. | + +## 3. Fixed invariants — every deployment + +These are not tier settings and cannot be weakened by deployment configuration. + +1. PostgreSQL is the **sole writable source of truth**. +2. The implementation uses Drizzle on current stack main. +3. Kanban and orchestration mutations **fail closed** unless DB write-health is positively proven `healthy`. +4. No failed mutation is redirected to Markdown, JSON, browser storage, Valkey, queue payloads, scratchpads, or provider issues. +5. `TASKS.md` and all file exports are generated, read-only, non-authoritative, and never parsed for import. +6. Human notes created during an outage become attributable proposals only after recovery. They do not reserve work, change status, satisfy a gate, or establish ordering. +7. Valkey is derived, expendable coordination infrastructure. PostgreSQL retains task truth, leases, fencing, audit, and the transactional outbox. +8. The Mechanical Coordinator is non-LLM and deterministic. It may evaluate eligibility, dependencies, approval policy, leases, fencing, heartbeat, retry, expiry, and quarantine. It cannot invent scope, alter acceptance criteria, waive gates, certify, or merge. +9. **Certifier** is the final independent quality-gate role. Certifier may pass, reject, or escalate with evidence; it has no merge authority. +10. Every business and orchestration record is workspace-scoped; cross-workspace relationships are rejected. +11. Every mutation is idempotent and expected-version checked where it changes an aggregate. +12. Stale worker mutations are rejected by monotonically increasing fencing tokens. +13. Audit events are append-only and attributable; authoritative state is reconstructable from PostgreSQL without Valkey or files. + +## 4. Configurable recovery posture only + +Deployment tiers configure durability and operational recovery targets. They never configure SOT authority, fail-open writes, or gate bypass. + +### 4.1 Tier defaults + +| Setting | Lite | Standard | High-assurance | +| --------------------------- | --------------------------------------: | ------------------------------------------------------------: | ----------------------------------------------------------------------: | +| Target RPO | 24 hours | 1 hour | **15 minutes** | +| Target RTO | 24 hours | 8 hours | **4 hours** | +| Base backup cadence | Daily | Daily | **Daily** | +| WAL archive cadence | Disabled | Every 15 minutes | **Every 5 minutes** | +| PITR retention | 0 days / disabled | 14 days | **35 days** | +| Restore test frequency | Quarterly | Quarterly | **Monthly** | +| Break-glass drill frequency | Annually | Semiannually | **Quarterly** | +| Off-cluster storage | One encrypted off-cluster backup target | Encrypted off-cluster object storage, separate failure domain | **Encrypted off-cluster base backups and WAL, separate failure domain** | + +A deployment MAY override defaults only through the validated recovery-posture contract. An override MUST record actor, reason, effective time, and policy revision. A claimed RPO MUST be no smaller than the actual backup/WAL mechanism can support. Enabling PITR requires WAL archival and off-cluster storage. + +## 5. Functional requirements and acceptance criteria + +### REQ-SOT-001 — Sole writable PostgreSQL authority + +**Requirement:** All project, mission, milestone, task/tag/archive, dependency, assignment, execution/quarantine, lease, checkpoint, approval, outage proposal, event, link, artifact, and outbox mutations MUST commit through Gateway domain services into PostgreSQL. + +**Acceptance:** + +- Mutation journey tests show web, CLI, MCP, and agents invoke typed Gateway commands. +- Static/process inventory finds no file, Valkey, browser, or provider issue writer acting as canonical state. +- PostgreSQL state survives Valkey loss and reconstructs the same aggregate revisions. + +### REQ-SOT-002 — Fail-closed mutation health + +**Requirement:** A mutation MUST execute only while health state is `healthy`. `read-only-degraded` and `write-unavailable` MUST return the frozen deliberate-denial error contract and MUST NOT enqueue a hidden write. + +**Acceptance:** + +- Public health response is a discriminated union; contradictory state/proof combinations fail contract validation. +- Mutation methods accept only a fresh internal PostgreSQL transaction-local write proof, never caller-asserted/public health state. +- Negative tests reject expired proofs, policy-revision mismatch, Valkey-only liveness, and caller-forged `healthy`. +- Fault tests force both degraded states and prove row counts, outbox, files, and Valkey remain unchanged. +- Exact failure mapping proves authoritative 503 denial, retryable 502/504/timeout uncertainty, and 409 version conflict cannot cross-map. +- Replaying the same idempotency key after recovery returns one canonical result. + +### REQ-SOT-003 — Generated projections + +**Requirement:** `TASKS.md`, `mission.json`, and other exports MUST contain a non-authoritative header, workspace/project IDs, generated time, and source revision. No production parser may mutate DB from an export. + +**Acceptance:** + +- Generated output matches the API snapshot revision. +- Hand editing a projection fails CI validation or is overwritten by regeneration. +- Repository search finds no import path from generated projections. + +### REQ-SOT-004 — Attributable outage proposals + +**Requirement:** Human outage notes MAY be captured outside the system but, after recovery, can enter Mosaic only through workspace-scoped `change_proposals` attributed to an authenticated active member. A proposal stores source-note digest, target aggregate/version, typed command/payload, idempotency, lifecycle, decision actor/reason/time, and audit links. It MUST NOT silently change canonical state. + +**Acceptance:** + +- `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` are composite foreign keys to `task_events(workspace_id, id)`; missing and foreign-workspace event IDs fail before commit. +- Submission preallocates the proposal ID and atomically inserts `change_proposal.submitted` for that exact workspace/proposal with the new proposal referencing it. +- Accept locks proposal and target, obtains fresh write proof, checks expected version, executes the normal typed command, and atomically links that command's event for the same workspace/target and proposal causation. +- Negative tests reject missing submission events, foreign-workspace submission/acceptance events, and same-workspace events for an unrelated proposal, aggregate, target, or command. +- Tests prove a pending/rejected proposal cannot claim/order work, satisfy a dependency/gate, or mutate any target directly. + +### REQ-TEN-001 — Workspace hard tenancy + +**Requirement:** Every canonical business/orchestration row MUST carry `workspace_id`. Workspace-aware constraints and authorization MUST prevent cross-tenant relationships and reads/writes. + +**Acceptance:** + +- API, repository, import, WebSocket, and Coordinator negative tests reject foreign-workspace IDs without existence oracles. +- Project/task owners use exactly-one user/team references; assignment principals use exactly-one user/team/agent reference; agent/session targets are workspace-consistent. +- User owners, principals, proposers, and decision actors require ACTIVE workspace membership in the authoritative transaction. +- Dependency, project hierarchy, assignment, lease, checkpoint, approval-evidence, link, artifact, proposal target, and both proposal-audit-event composite relationships reject mixed workspaces. +- Tenant context is derived from authenticated authority, never accepted blindly from request data. + +### REQ-ID-001 — Workspace identity and service scope + +**Requirement:** Users, teams, agents, and agent sessions MUST be bound to a workspace with explicit role/capability scope. Agents MUST NOT receive raw DB credentials. + +**Acceptance:** + +- Workspace membership and service-identity tests enforce command-family scope. +- Revoked/disabled agents and ended sessions cannot claim, heartbeat, or submit. + +### REQ-PLAN-001 — Normalized planning hierarchy + +**Requirement:** Canonical planning entities are projects, milestones, missions, mission-milestone associations, and tasks. A task belongs to one required project and at most one mission/milestone/parent task. + +**Acceptance:** + +- CRUD tests preserve workspace, hierarchy, versions, and lifecycle constraints. +- Mission membership does not duplicate task status. +- Composite project-congruent constraints reject task→mission, task→milestone, task→parent, mission→milestone, and project→current-milestone mismatches. +- Parent and association constraints reject cycles/orphans where applicable. + +### REQ-TASK-001 — Canonical task fields + +**Requirement:** Tasks MUST support title, description, structured acceptance criteria, canonical status, priority, fractional board rank, accountable owner, assigned specialist role, due/not-before dates, estimate, progress, explicit blocker, retry policy, normalized workspace tags, non-lifecycle archival (`archived_at/by/reason`), metadata, monotonic fencing counter, and optimistic version. + +**Acceptance:** + +- API and UI round-trip every field without silent loss. +- Current `tasks.tags`, `assignee`, and `due_date` remain declared/preserved during N-1 and backfill to the canonical model without loss. +- Archive hides work without changing its canonical lifecycle status and requires actor/reason/time. +- Invalid status, rank, progress, date, owner, tag, archive, or retry data is rejected. +- Concurrent expected-version updates produce a visible conflict. + +### REQ-TASK-002 — Fixed lifecycle and computed readiness + +**Requirement:** Human workflow status MUST use the seven ratified values. Dependency/schedule/policy/lease/retry conditions MUST be exposed as computed readiness, not hidden status rewrites. + +**Acceptance:** + +- A dependency becoming incomplete changes readiness but does not silently rewrite the Kanban column. +- Readiness explanation identifies all active gates. +- State-machine tests reject illegal transitions and require reasons for blocked/cancelled paths. + +### REQ-DEP-001 — Dependency DAG + +**Requirement:** Workspace-local directed dependencies MUST be unique and acyclic. A task is dependency-eligible only after every blocking predecessor is `done` and completion conditions pass. + +**Acceptance:** + +- `(workspace_id, predecessor_task_id, successor_task_id)` is unique independent of dependency type. +- Cycle, duplicate, self-edge, and cross-workspace attempts fail before commit. +- Property/concurrency tests prove all blocking predecessors are evaluated. +- UI displays dependency and readiness errors accessibly. + +### REQ-ASN-001 — Assignment is not a lease + +**Requirement:** Assignment history and execution leases MUST be separate records. One persisted assignment identity freezes task version, exact target agent/session (or exactly-one non-agent principal), specialist role, expiry, state, policy revision, proposer, reason, and timestamps. Approval decisions relate to that assignment with workspace-aware constraints. + +**Acceptance:** + +- One assignment-state vocabulary is identical across schema, DTO, and engine. +- Lease acquisition accepts IDs only, then reloads and locks assignment, approval, task, and target session to verify workspace, current task version, exact target, state, expiry, and policy revision. +- Reassignment preserves history; assignment may exist without a lease; lease expiry does not erase ownership/evidence. + +### REQ-AUD-001 — Semantic audit and outbox + +**Requirement:** Mutating commands MUST append semantic `task_events` with actor, correlation, causation, idempotency key, and aggregate versions in the same transaction as state. Notifications MUST flow from a transactional outbox. + +**Acceptance:** + +- Atomicity tests prove state/event/outbox commit or roll back together. +- Proposal submission and acceptance tests prove their workspace-bound event links identify the exact submission and executed normal command, not merely an existing event UUID. +- Duplicate idempotency keys return the prior result without duplicate events. +- `task_events`, checkpoints, immutable artifacts, and evidence joins are INSERT/SELECT-only for application roles; parent hard deletes are RESTRICTed. +- Normal lifecycle uses archive/cancel, never hard delete; retention purge requires audited break-glass authority and evidence. +- Valkey outage leaves outbox pending and later replayable. + +### REQ-API-001 — Typed Gateway command boundary + +**Requirement:** Gateway MUST expose workspace-safe project/task/dependency/assignment/link/artifact/change-proposal queries and explicit lifecycle commands. Generic patching MUST NOT bypass claim, heartbeat, review, certify, proposal acceptance, or completion invariants. + +**Acceptance:** + +- KBN-105 freezes exact route, request, success, denial, conflict, and transport-normalization DTOs before CLI/web implementation. +- DTO validation, authorization, contract, and integration tests cover each command. +- Exact MCP-owned Gateway files are coder3-owned; coder4 consumes only frozen Gateway contracts. +- Endpoint registry aligns web, CLI, MCP, and generated client paths. +- Direct SQL and raw Valkey writes are absent from clients. + +### REQ-UI-001 — Writable thin Kanban/List MVP + +**Requirement:** Existing Tasks and Projects surfaces MUST become a real-data writable MVP with one shared query contract. + +**Acceptance:** + +- Users can create/edit/cancel/archive tasks, open task detail, and move cards within/across columns. +- Server validates transition and persists fractional board rank. +- Refresh, reconnect, CLI, MCP, and generated projection show the same revision. + +### REQ-UI-002 — Tenant and work context + +**Requirement:** UI MUST show workspace context and support filters for project, mission, milestone, status, priority, owner/specialist, due state, and tags. + +**Acceptance:** + +- Context is visible on every mutation surface. +- Filter tests cannot expose foreign-workspace data. +- Empty/loading/error states are explicit. + +### REQ-UI-003 — Dependency, ownership, lease, and audit visibility + +**Requirement:** Task detail MUST separate accountable owner, specialist assignment, active session/lease expiry, dependencies/readiness, acceptance criteria, blocker, external links, and audit timeline. + +**Acceptance:** + +- Each concept renders from its canonical endpoint. +- A lease is never displayed as ownership or completion. +- Conflict and stale-reconnect states require refresh rather than silent overwrite. + +### REQ-UI-004 — Accessible interaction + +**Requirement:** Kanban MUST support keyboard-accessible moves, non-drag alternatives, responsive layout, and semantic status/error announcements. + +**Acceptance:** + +- Keyboard journey performs every card transition available by drag. +- Automated accessibility checks and manual responsive checks pass. + +### REQ-COORD-001 — Non-LLM Mechanical Coordinator + +**Requirement:** Coordinator decisions MUST be deterministic from structured data and versioned policy. It MUST NOT invoke an LLM to interpret scope or acceptance criteria. + +**Acceptance:** + +- Pure decision engine receives complete immutable snapshots and performs no ID loading, SQL, Gateway, Valkey, or recovery I/O. +- Persistence/service adapter owns ID loading, transaction-local write proof, locking, persistence, and `recoverFromPostgres`. +- Same snapshot and policy revision produce the same eligibility/order explanation. +- Dependency, schedule, durable retry/quarantine, approval, role, and capacity inputs are auditable. +- Code/config inspection finds no model/provider dependency in the scheduling engine. + +### REQ-COORD-002 — Eligibility and approval routing + +**Requirement:** Only `ready` tasks under active project/mission, passed dependencies/schedule/retry/release policy, and without active lease may be proposed. Manual approval is default; auto-route requires an explicit approved policy revision. + +**Acceptance:** + +- Unapproved or gated tasks are never leased. +- Every persisted assignment proposal includes task version, exact target agent/session, expiry, state, deterministic reasons, and policy revision. +- Approval is relationally bound to the assignment identity and cannot be supplied as a forgeable proof-by-value DTO. +- Override/reject/reassign requires an attributable reason. + +### REQ-COORD-003 — Atomic lease, heartbeat, fencing, and recovery + +**Requirement:** Lease acquisition MUST be atomic in PostgreSQL, permit at most one active lease per task, atomically increment the durable per-task fencing counter under task lock, use bigint-safe tokens, require timely acknowledgement/heartbeat, and reject stale workers. Lease and checkpoint relations MUST bind the exact workspace+task+assignment/session+fence. + +**Acceptance:** + +- Concurrent claim tests yield one winner and strictly increasing fencing tokens. +- Lower/expired tokens and mismatched same-workspace task/assignment/lease/checkpoint IDs fail. +- Token values round-trip as bigint/decimal strings without JavaScript precision loss. +- Coordinator restart reconstructs lease/retry/quarantine state from PostgreSQL alone. + +### REQ-COORD-004 — Retry and quarantine + +**Requirement:** Missing acknowledgement, agent loss, or execution failure MUST produce a deterministic release, bounded backoff retry, or quarantine outcome according to retry policy. Ambiguous/non-idempotent work requires Sub-Orchestrator action. + +**Acceptance:** + +- Durable execution state records disposition, attempt/max, next eligibility, terminal reason, actor/policy, timestamps, and version. +- Retry budget/backoff are bounded and tested. +- Exhausted or non-idempotent failures quarantine with workspace-scoped artifact evidence. +- One specialist-role vocabulary is enforced across schema, sessions, assignments, DTOs, and engine. +- No task loops indefinitely or silently returns to ready. + +### REQ-GATE-001 — Role and authority chain + +**Requirement:** Canonical flow is User → Interaction → Portfolio Orchestrator → Project Sub-Orchestrator → Gateway → domain services → Mechanical Coordinator → specialists → Certifier. + +**Acceptance:** + +- Role bindings and approvals are queryable and audited. +- Coordinator cannot create scope or waive gates. +- Certifier cannot merge or close provider artifacts. + +### REQ-GATE-002 — Independent review and certification + +**Requirement:** Author and reviewer MUST differ. Auth, security, tenant, secrets, and data-integrity surfaces MUST receive mandatory SecReview. Certifier is the final quality gate after remediation. + +**Acceptance:** + +- Gate tests reject author self-review and missing required SecReview. +- Certifier receives complete traceability/evidence and returns pass/reject/escalate. +- A Certifier pass does not grant merge authority. + +### REQ-REC-001 — Recovery posture validation + +**Requirement:** A deployment MUST select a validated Lite, Standard, or High-assurance posture and MAY override only recovery knobs. + +**Acceptance:** + +- Runtime invokes normative `validateRecoveryPostureV1`, not shape-only JSON Schema validation. +- Validator rejects PITR/WAL mismatch, impossible RPO, unknown fields, non-encrypted/non-separated storage, and weakened High-assurance values. +- A bounded recovery/infra slice owns parser wiring, override audit, mechanism verification, restore test, and break-glass evidence. +- High-assurance defaults equal RPO 15m/RTO 4h, encrypted off-cluster WAL every 5m, 35d PITR, daily base backup, monthly restore test, and quarterly break-glass. + +### REQ-MIG-001 — One-way shadow migration + +**Requirement:** Migration from jarvis-brain/Vikunja MUST use inventory, immutable source snapshots/checksums, one-way shadow import, read reconciliation, write freeze, final delta, cutover, and read-only stabilization. Dual writes are forbidden. + +**Acceptance:** + +- P0 publishes the current `origin/main` field-by-field expand/backfill/compatibility/switch/contract map before any schema lane starts. +- Legacy columns remain in the unified Drizzle declaration for the entire expand/N-1 window. +- Dry-run/apply/verify modes are idempotent and workspace-safe. +- Import lineage preserves source system/key/file/checksum/batch and rejected-record reports. +- Empty DB, production-shape, partial-resume, downgrade/rollback, status-shadow, workspace-backfill, and `mission_tasks.status` retirement tests pass. +- Shadow records cannot auto-dispatch. + +### REQ-MIG-002 — Cutover and rollback safety + +**Requirement:** Cutover MUST disable legacy writers and switch all clients to Gateway. Before first DB mutation rollback may switch authority back; afterward rollback requires freeze, DB-delta export/reconciliation, and owner decision. + +**Acceptance:** + +- Process inventory proves no active jarvis-brain/Vikunja project/task writer. +- Cutover rehearsal meets signed reconciliation thresholds. +- No reverse and forward sync run concurrently. + +## 6. Explicit non-goals + +The P0–P3 canon does not authorize: + +- replacing Gitea issue/PR storage; +- calendar, email, GLPI cache, CRM, billing, time tracking, or personal-brain migration; +- arbitrary custom workflows/statuses/fields; +- a writable offline/file/Valkey/browser fallback; +- direct client database access; +- LLM scheduling or autonomous scope invention; +- Coordinator gate waiver, certification, merge, release, or provider issue closure; +- Certifier merge authority; +- full mission designer, portfolio analytics, critical-path UX, or advanced board customization in the thin MVP; +- P4/P5 features unless separately released. + +## 7. Global release evidence + +P0–P3 may close only when requirements traceability maps every requirement above to automated and situational evidence, including cross-workspace denials, DB/Valkey fault injection, concurrent leases, stale fencing, generated-file immutability, UI conflict/reconnect behavior, migration reconciliation, independent review, mandatory SecReview, and final Certifier evidence. diff --git a/docs/rfcs/RFC-001-MACP-MATRIX-NATIVE.md b/docs/rfcs/RFC-001-MACP-MATRIX-NATIVE.md new file mode 100644 index 00000000..6b95ee09 --- /dev/null +++ b/docs/rfcs/RFC-001-MACP-MATRIX-NATIVE.md @@ -0,0 +1,449 @@ +# RFC-001 — MACP: A Mosaic-Native, Matrix-Native Comms Layer + +- **Status:** DRAFT — for Team Lead → Orchestrator staffing +- **Author:** MS-LEAD (reviewer identity `ms-lead-reviewer`) +- **Sponsor / veto:** Jason (human lead) +- **Date:** 2026-07-24 +- **Program:** Mosaic Stack comms-evolution +- **Supersedes backbone:** the Hermes MCP chat bridge (strangler-retired, see §9) +- **Audience:** Team Leads, the Mosaic orchestrator, infra, and any harness maintainer (Claude Code / Codex / Pi / Goose) + +> This is a **design document**. No code ships from this RFC. It exists to be decomposed into missions (P1→P5, §10) with per-phase acceptance criteria. Where a claim is uncertain or needs live validation, it is flagged **[VERIFY]**. + +--- + +## 0. TL;DR + +We are building a **Mosaic-native comms layer on Matrix**. We self-host a **Synapse** homeserver and register a privileged **Mosaic Application Service** (the "appservice") that the orchestrator controls. The appservice bulk-provisions one Matrix identity per agent-spin, creates and manages rooms, posts agent introductions, and tracks presence/liveness. Agents talk to it through a thin `packages/comms` client SDK. **tmux stays the P0 same-host fast path**; Matrix is the durable, presence-aware, federated layer above it, and **MACP** (the Mosaic Agent Comms Protocol) is the standard that says which path to use when. Federation is **per-site Synapse homeservers federated over TLS we control** — a direct answer to "the homelab agent went dark and took comms with it." We adopt **Buzz's patterns** (auto-detect/enroll, signed identity, unified event log, humans-and-agents on one surface) without adopting Buzz/Nostr as transport. We migrate off Hermes by the **strangler** pattern: stand native alongside, move channels as proven, retire at parity. + +The **first standalone shippable slice is presence** (P1). + +--- + +## 1. Goals / Non-Goals + +### 1.1 Goals + +- **G1 — Presence & liveness first.** A Team Lead must be able to answer "is my coordinator online, away, or dead?" in seconds, not by polling for 13 hours. Presence is the P1 slice and ships before anything else. +- **G2 — A native backbone we own.** Replace _Hermes-as-backbone_ with a self-hosted Synapse + a Mosaic-controlled appservice. External chat bridging becomes an optional edge, not the spine. +- **G3 — Turnkey harness enrollment.** `mosaic enroll` auto-detects the harness and self-registers the agent via the appservice on spin. No hand-rolled per-bot identity juggling. +- **G4 — A real protocol (MACP v1).** Structured, versioned event schema over Matrix custom event types; a documented routing contract for tmux vs Matrix; a documented escalation policy. +- **G5 — No central SPOF.** Per-site homeservers federated over TLS/DNS we already control, so one site going dark cannot take the fleet's comms with it. +- **G6 — Gate-action integrity.** Reviews / merges / approvals carry **signed authorship** (Buzz pattern) so a gate-critical action is cryptographically attributable, retiring the fragile "distinct bot identity" juggling. + +### 1.2 Non-Goals + +- **NG1 — Do NOT rip out working comms mid-MVP.** tmux fast-path and the existing `mos-comms` git-branch channel keep working until their replacement is proven at parity. This RFC is strangler, not big-bang. +- **NG2 — tmux is NOT being replaced.** tmux inter-agent comms remains **P0**. Matrix is _above_ it, not instead of it. MACP defines the boundary; it does not move it. +- **NG3 — Not adopting Buzz/Nostr as transport.** We adopt Buzz's _patterns_; the wire is Matrix. +- **NG4 — Not building a new chat client in P1–P4.** HIL uses an existing Matrix client (Element or equivalent) until/unless a custom client is justified (open question, §11). +- **NG5 — Not federating to the public Matrix network.** Federation is Mosaic-site-to-Mosaic-site over infrastructure we control. Public `matrix.org` federation is out of scope (and should likely be firewalled off). +- **NG6 — Not a Hermes feature-clone.** We reach _parity on the channels that matter_ (§9 checklist), not bug-for-bug Hermes compatibility. + +--- + +## 2. Architecture + +### 2.1 Layer diagram + +``` + ┌───────────────────────────────────────────────┐ + │ HUMAN (Jason / HIL) │ + │ Element (or custom client) — §11 │ + └───────────────────────┬───────────────────────┘ + │ (same Matrix surface as agents) + │ + ┌──────────────────────────────────────────────▼──────────────────────────────────────────────┐ + │ SYNAPSE HOMESERVER (self-hosted, ours) │ + │ - Client-Server API (agents + humans send/receive events) │ + │ - Application Service API (privileged AS hooks: transactions, user/room namespaces) │ + │ - Presence EDUs, receipts, typing │ + │ - Federation API (S2S) over TLS ── to peer site homeservers (§6, P4) │ + └───────▲───────────────────────────────────▲──────────────────────────────────────▲───────────┘ + │ AS API (hs_token / as_token) │ C-S API (per-agent access_token) │ S2S + │ │ │ + ┌───────┴───────────────────────┐ ┌────────┴─────────────────┐ ┌─────────┴──────────┐ + │ MOSAIC APPSERVICE │ │ packages/comms (SDK) │ │ PEER SITE Synapse │ + │ (apps/matrix-appservice) │ │ used by every harness │ │ (site-B, site-C…) │ + │ THE "native layer" │ │ - login/whoami │ │ own appservice │ + │ replacing Hermes-backbone │ │ - send MACP events │ │ own agents │ + │ │ │ - subscribe/sync │ └────────────────────┘ + │ - bulk-provision MXIDs │◄───┤ - presence heartbeat │ + │ (@mosaic_:site) │ │ - signed-authorship │ + │ - create/manage rooms │ │ envelope (gate acts) │ + │ - post introductions │ └────────────┬─────────────┘ + │ - track presence/liveness │ │ in-process / IPC + │ - enforce room taxonomy │ ┌─────────▼──────────────────────────────────────────┐ + │ - escalation watchdog (§5) │ │ AGENT HARNESS │ + │ - controlled by ORCHESTRATOR │ │ Claude Code / Codex / Pi / Goose │ + └───────▲───────────────────────┘ │ `mosaic enroll` runs on spin (§4.1) │ + │ orchestrator drives AS └─────────┬───────────────────────────────────────────┘ + ┌───────┴───────────────────────┐ │ + │ MOSAIC ORCHESTRATOR │ │ P0 FAST PATH (same host, low-latency) + │ (~/.config/mosaic) │ ┌─────────▼──────────┐ tmux send-keys / pane I/O + │ spins agents, owns rooms, │◄──────►│ tmux (P0) │◄─►│ peer agent on same host │ + │ sets escalation policy │ MACP └────────────────────┘ └─────────────────────────┘ + └───────────────────────────────┘ routing rules decide tmux vs Matrix per message (§4.6) +``` + +Key idea: **the appservice is the backbone.** It is a long-lived privileged process registered with Synapse via an appservice registration file (`hs_token`/`as_token`, namespaces). It is the thing that used to be "Hermes-as-backbone," except we own it, it is inside the orchestrator's control plane, and it speaks native Matrix. + +### 2.2 Message flow: agent spin-up → auto-enroll → room join → introduction → presence-online + +``` +Orchestrator Harness (mosaic enroll) Mosaic Appservice Synapse + │ │ │ │ + 1. spin agent ─────────────────► │ │ │ + │ │ 2. auto-detect harness │ │ + │ │ (Claude/Codex/Pi/Goose) │ │ + │ │ 3. POST /enroll {agent meta} ─► │ + │ │ │ 4. provision MXID │ + │ │ │ @mosaic_:site │ + │ │ │ via AS API register ─► (201, in namespace) + │ │ │ 5. mint access_token │ + │ │ 6. ◄── {mxid, token, rooms}──┤ (or as_token masq) │ + │ │ │ 7. invite+join rooms ─► (mission/team/fleet) + │ │ 8. /sync (via packages/comms)─────────────────────────► (joined state) + │ │ │ 9. post introduction ─► m.room.message + + │ │ │ (mosaic.introduction) custom event → rooms + │ │ 10. set presence ONLINE ─────────────────────────────► presence EDU + │ │ 11. start heartbeat loop │ │ + │ │ (mosaic.presence ping) │ │ + │ 12. appservice reports agent │ │ │ + │ ◄──── live in fleet room ────┤ (watchdog now tracks liveness) │ +``` + +Notes on the steps that matter: + +- **Step 4/5** use the **Application Service API**: the appservice can register users inside its namespace (`@mosaic_*:site`) and act on their behalf. Two viable modes: (a) mint a real per-agent `access_token` via appservice login, or (b) have the appservice **masquerade** using `user_id` query param on C-S calls with the `as_token`. **Recommendation: mint per-agent tokens** for P2 so the agent process holds only its own credential (blast-radius containment, §8); reserve masquerade for bulk/bootstrap operations the appservice itself performs. **[VERIFY]** exact token-lifetime and refresh behavior against the running Synapse version. +- **Step 9** — the introduction is both a human-readable `m.room.message` _and_ a structured `mosaic.introduction` custom event (so other agents can machine-parse capabilities without scraping prose). +- **Step 10/11** — presence goes online immediately, then a **heartbeat** keeps liveness fresh. Native Matrix presence auto-decays to `unavailable`/`offline`, but we do **not** rely solely on it (Synapse presence timeouts are coarse and federation presence is lossy **[VERIFY]**); MACP adds an explicit `mosaic.presence` heartbeat event for deterministic liveness (§4.5, §5). + +--- + +## 3. Repo-home decision (RESOLVED — recommendation) + +The core tension: **product monorepo** (`mosaicstack/stack`, this checkout `/src/mosaic-stack`) vs **framework** (`~/.config/mosaic`, the agent/harness runtime that every agent shares regardless of product). The boundary rule I am ratifying: + +> **Product-monorepo owns the deployed _services and libraries_. Framework owns the _agent/harness contract_ — anything an agent needs the moment it spins, before any product code is checked out.** + +Applying that rule: + +| Piece | Home | Rationale | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Synapse deployment** (compose/helm, config, TLS, `.well-known`, pipelines) | **Product monorepo** → `infra/matrix/` + CI in the monorepo's pipeline dir | It is deployed infrastructure with the same lifecycle/observability as Gateway/Web/DB. Lives beside other `infra/` and Woodpecker pipelines. | +| **Mosaic Appservice** | **Product monorepo** → `apps/matrix-appservice` | It is a first-class deployed service (NestJS-style app, same conventions: ESM, `@Inject()`, DTOs, OTEL-before-bootstrap). It talks to Synapse, holds `hs_token`/`as_token`, and is operated like any other app. It is **controlled by** the orchestrator but **is not** the orchestrator. | +| **Client SDK** | **Product monorepo** → `packages/comms` | A workspace library consumed by product apps _and_ by harnesses. Published/linked like `packages/queue`, `packages/db`. Versioned with the product. | +| **MACP spec** (the standard doc + JSON schemas) | **Framework** → `~/.config/mosaic/spec/macp/` (mirrored/vendored into `packages/comms` at build) | The protocol is an agent-level contract that must exist independent of any one product checkout. Framework is the source of truth; `packages/comms` vendors a pinned copy so the SDK and spec cannot drift silently. | +| **`mosaic enroll` harness glue** (auto-detect, spin hook) | **Framework** → `~/.config/mosaic/tools/enroll/` | Agents/harnesses are framework-level. Enrollment must run _before_ the agent has product context; it cannot depend on `/src/mosaic-stack` being present. This is exactly parallel to the existing `~/.config/mosaic/tools/*` wrappers. | + +**Boundary summary:** _the wire and the services are product; the contract and the spin-time glue are framework._ The one deliberate coupling is **MACP**: framework is authoritative, but `packages/comms` pins a vendored copy and CI fails if they diverge, so an agent enrolling via framework and a service validating via `packages/comms` agree on the schema by construction. + +**Rejected alternative:** putting the appservice in the framework. Rejected because the appservice is a stateful, deployed, secret-holding network service that needs the product's CI/observability/secret plumbing; burying it in `~/.config/mosaic` would split its operational story from every other Mosaic service. + +--- + +## 4. MACP v1 — the standard + +MACP (Mosaic Agent Comms Protocol) v1 is a **profile of Matrix**: it does not invent a transport, it constrains how Mosaic agents use Matrix so that behavior is uniform across harnesses. Versioned via a `macp_version` field on every custom event; v1 is frozen at ratification (P3). + +### 4.1 Enrollment contract + +`mosaic enroll` MUST, on agent spin, in order: + +1. **Auto-detect harness.** Detection order + signal: + - Claude Code — presence of the Claude Code runtime/env (e.g. `CLAUDE_CODE_*` env, `~/.claude`) **[VERIFY exact signal per harness]** + - Codex — Codex runtime markers + - Pi — Pi SDK runtime (`packages/agent` / `packages/mosaic` context) + - Goose — Goose runtime markers + - Fallback: explicit `--harness` flag; if undetectable, enroll as `generic` and warn. +2. **Provision identity** — call appservice `POST /enroll` with `{agent_slug, harness, host, mission_id?, team_id?, capabilities[]}`. Appservice returns `{mxid, access_token, homeserver, rooms[]}` (§2.2 step 4–6). +3. **Join rooms** — accept invites / join the returned room set per taxonomy (§4.6). +4. **Introduce** — post `mosaic.introduction` (+ human-readable `m.room.message`) to each joined room. +5. **Go present** — set Matrix presence `online` and start the `mosaic.presence` heartbeat loop. + +Enrollment is **idempotent**: re-running `mosaic enroll` for an existing agent slug rebinds to the same MXID (re-mints token if needed) rather than creating a duplicate identity. This is what retires the "distinct bot identity juggling." + +### 4.2 Structured event schema (Matrix custom event types) + +All MACP events carry a common envelope in `content`: + +```jsonc +{ + "macp_version": "1.0", + "macp_type": "", + "agent": { "mxid": "@mosaic_teamlead-3:site-a", "slug": "teamlead-3", "harness": "claude-code" }, + "ts": 1753300000000, + "mission_id": "KBN-101", // optional + "signature": { ... } // present ONLY for gate actions, §4.4 + // ...type-specific fields... +} +``` + +Event types (Matrix `type` shown; timeline events use `m.room.message` with a custom `msgtype` where a human-visible fallback is desirable, state events use a dotted custom `type`): + +| MACP type | Matrix carrier | Purpose | Notable fields | +| --------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| **message** | `m.room.message`, `msgtype: "mosaic.message"` (+ `body` fallback) | ordinary agent/human chat | `body`, `thread?` | +| **presence** | `m.room.message` msgtype `mosaic.presence` in fleet room **or** custom EDU-backed state | heartbeat/liveness ping | `status: online\|away\|offline`, `seq`, `interval_ms` | +| **workflow-step** | state event `mosaic.workflow.step` (state_key = step id) | durable record of a workflow/mission step | `step`, `status: started\|blocked\|done\|failed`, `detail` | +| **review / approval** | `m.room.message` msgtype `mosaic.review` | a review verdict or merge/approval | `subject` (PR/commit ref), `verdict: approve\|reject\|request-changes`, `signature` (REQUIRED) | +| **escalation** | `m.room.message` msgtype `mosaic.escalation` | raise a stuck/dark condition to HIL/fallback | `reason`, `target`, `severity`, `since_ts` | + +Rationale for the carrier split: **timeline events** (`m.room.message` variants) are the durable, receipted, replayable log (this _is_ Buzz's "unified event log," §7). **State events** (`mosaic.workflow.step`, presence-as-state) give last-writer-wins current status that a newly-joined agent reads instantly from room state without replaying history. + +Every custom event is validated against a JSON Schema shipped in the MACP spec (§3). Unknown/newer `macp_version` → consumers MUST degrade gracefully (render `body`, ignore unknown fields). + +### 4.3 Agent identity model + +- **MXID:** appservice-namespaced `@mosaic_:`. The `mosaic_` prefix is the AS **user namespace** declared in the registration file (`namespaces.users` regex `@mosaic_.*`), so Synapse routes those users to our appservice and no human can squat the namespace. +- **Provisioning:** exclusively via the appservice (AS API register). Never hand-created. One MXID per agent-spin; idempotent rebind on re-enroll (§4.1). +- **Signed authorship** overlays identity for gate actions only (§4.4). MXID answers "who is this account"; signature answers "did the real key-holder authorize this gate action." + +### 4.4 Signed-authorship for gate-critical actions (Buzz pattern, scoped) + +Gate-critical = **reviews, merges, approvals** — anything that can move code to `main` or unblock a mission gate. + +- Each enrolled agent is issued (or generates) an **Ed25519 keypair**; the **public** key is registered with the appservice at enrollment and published as agent profile state (`mosaic.identity.pubkey`). Private key custody per §8. +- A gate action event carries `content.signature = { alg: "ed25519", key_id, sig }` over a canonical serialization of the envelope (canonical-JSON of `{macp_type, agent.mxid, subject, verdict, ts, mission_id}`). +- Verifiers (the appservice gate-watcher, and any agent acting on a verdict) MUST reject an unsigned or bad-signature gate event. Non-gate events are unsigned (keeps the hot path cheap). + +This is deliberately **narrow**: we do not sign every chat line (Buzz signs everything; we take the pattern only where forgery has teeth). Scope may widen post-P5 if warranted. + +### 4.5 Presence & liveness model + +Three visible states plus an explicit heartbeat: + +- **online** — agent set presence online AND last `mosaic.presence` heartbeat within `heartbeat_interval` (default **30s [VERIFY tuning]**) × miss-tolerance (default 2). +- **away** — presence `unavailable`, or heartbeats late but < dark threshold. +- **offline / dark** — no heartbeat for `dark_threshold` (default **N minutes**, policy value, §5/§11) OR presence `offline`. + +Why not rely on native Matrix presence alone: Synapse presence is (a) coarse-grained, (b) can be disabled for load reasons, and (c) **degrades across federation** **[VERIFY]**. So MACP layers an explicit heartbeat carried as a lightweight timeline/state event in the **fleet presence room**, giving a deterministic, federation-safe liveness signal the escalation watchdog (§5) can reason about. Native presence EDUs are still emitted (they make Element show the right dot for humans) but the _authoritative_ liveness source is the heartbeat. + +### 4.6 Room / channel taxonomy (orchestrator-owned) + +The **orchestrator** (via the appservice) owns room lifecycle. Agents never create backbone rooms ad hoc. + +| Room | Scope | Membership | Purpose | +| ----------------------- | ------------------------------------------ | ---------------------------------------- | ----------------------------------------------------------------------- | +| **Fleet presence room** | one per site (federated view across sites) | every enrolled agent + HIL | heartbeats, the single "who's alive" board. This is the P1 deliverable. | +| **Per-mission room** | one per mission (e.g. `#mission-KBN-101`) | agents on that mission + Team Lead + HIL | workflow-steps, mission chat, reviews for that mission | +| **Per-team room** | one per team | team members + Team Lead | intra-team coordination | +| **HIL room** | one (or one per site) | humans + escalation-privileged agents | where escalations land; Jason's pane on the fleet | + +Rooms are created with orchestrator-controlled power levels: appservice = admin (PL100), Team Leads elevated, worker agents default. Room aliases (`#mission-KBN-101:site-a`) are stable handles. + +### 4.7 tmux ↔ Matrix routing rules (the fast-path/durable boundary) + +MACP mandates this decision per message. **Default bias: if it must survive the agent, be seen by an offline party, cross a host, or be audited — Matrix. If it is same-host, synchronous, and ephemeral — tmux.** + +| Signal | Route | Why | +| ---------------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------- | +| Same-host, live pane-to-pane prompt/nudge, sub-second need | **tmux (P0)** | lowest latency, no server round-trip; this is the working fast path we keep | +| Recipient may be offline / on another host | **Matrix** | durability + store-and-forward; tmux can't reach a dark or remote pane | +| Presence / heartbeat / liveness | **Matrix** | must be observable fleet-wide, including by the watchdog and HIL | +| Workflow-step, review/approval, escalation | **Matrix** | must be durable, receipted, auditable, signed (gate acts) | +| Cross-site anything | **Matrix (federated)** | tmux is same-host only | +| Bulk log spew / high-frequency scratch between co-located agents | **tmux**, with periodic **Matrix** checkpoints | avoid flooding the durable log; keep an audit checkpoint | + +Rule of thumb encoded in the SDK: `comms.send()` takes a `durability` hint; `ephemeral+same-host` short-circuits to tmux, everything else goes Matrix. A message can be **dual-routed** (tmux for immediacy + a Matrix checkpoint) when both speed and durability matter. + +--- + +## 5. Coordinator-availability + HIL escalation (the 13h-stall / homelab-dark fix) + +**The failure we are killing:** a Team Lead blocked ~13h, polling every 15 min, unable to distinguish "coordinator offline" from "coordinator busy"; and a homelab agent that goes dark taking comms with it. + +**The fix — presence-driven, policy-encoded escalation:** + +1. **Deterministic liveness (§4.5).** Every agent heartbeats into the fleet presence room. The appservice **escalation watchdog** subscribes and maintains `last_seen` per agent. No polling by the Team Lead — it _subscribes_ (Matrix `/sync` long-poll) and is pushed state changes. + +2. **Encoded policy in MACP:** a machine-readable escalation policy attached to each agent/role, e.g.: + + ```jsonc + { + "role": "team-lead", + "coordinator": "@mosaic_coordinator-1:site-a", + "dark_threshold_min": 10, // OPEN QUESTION §11 — Jason/Mos to set N + "on_coordinator_dark": { + "action": "escalate", + "fallback": "@mosaic_coordinator-2:site-b", // cross-site fallback + "then": "notify-HIL", + "hil_room": "#hil:site-a", + }, + } + ``` + +3. **Detection & action by the Team Lead:** when the watchdog (or the Team Lead's own SDK) observes `coordinator.last_seen > dark_threshold`, it: + - emits a `mosaic.escalation` event into the mission room and the HIL room (`reason: "coordinator dark", since_ts, severity`), + - **re-routes** to the declared fallback coordinator (possibly on another site — this is why federation matters), + - if no fallback answers within a second threshold, pages **HIL** (Jason) in the HIL room. + The Team Lead **never sits blocked polling**; a dark coordinator is a _pushed event_, and the fallback/HIL path is automatic. + +4. **Homelab-dark specifically:** because heartbeats are federated into a cross-site fleet room, a whole _site_ going dark is visible from other sites — the watchdog on site-B sees site-A's coordinator stop heartbeating and triggers the same escalation. A dark homelab can no longer silently strand its agents, because the liveness signal and the fallback live _off that host_. + +**Design invariant:** liveness authority and fallback targets must never be co-located with the thing they monitor. The watchdog for site-A's coordinator should also run (or be mirrored) on site-B. + +--- + +## 6. Federation (P4) + +**Model:** each site runs its **own Synapse homeserver** with its **own Mosaic appservice** and its own agents. Sites **federate** with each other over the standard Matrix server-to-server (S2S) API, restricted to Mosaic sites. + +**Why per-site, not one central server:** + +- **No SPOF.** The homelab going dark is the founding trauma of this program. A single central homeserver would recreate exactly that risk at fleet scale. Per-site means a site outage is contained: its agents drop, but every other site's comms and the cross-site fleet room survive. +- **Locality.** Same-site agents get low-latency local homeserver traffic; only cross-site events pay the federation cost. +- **Blast radius.** A compromised or misbehaving site can be defederated without touching the rest. + +**How federation is wired (real Matrix mechanics):** + +- **Server discovery** via `https:///.well-known/matrix/server` returning `{"m.server": "matrix.:443"}`, and/or an `_matrix._tcp` **SRV** record. We control the DNS/domains, so we control the federation graph. **[VERIFY]** current `.well-known` vs SRV precedence for the deployed Synapse version. +- **TLS:** federation requires valid TLS on the federation endpoint; we terminate with certs from our own CA/ACME on domains we own. +- **Allowlist:** use Synapse `federation_domain_whitelist` to restrict federation to the set of Mosaic site domains — **no public-network federation** (NG5). This is a hard security boundary. +- **Cross-site rooms:** the fleet presence room and any cross-mission rooms are federated rooms whose membership spans site homeservers. Room state replicates via S2S; presence heartbeats propagate as events (not relying on lossy presence EDUs across federation, §4.5). + +**Cross-site identity:** an agent on site-B is `@mosaic_:site-b`. The signed-authorship pubkey travels in profile state, so a site-A verifier can validate a site-B agent's gate action without trusting site-B's homeserver blindly (signature ≠ homeserver trust). + +--- + +## 7. Buzz-pattern adoption map + +We adopt Buzz's **ideas**, on Matrix rails, phased: + +| Buzz idea | Adopt? | How, on Matrix | Phase | +| --------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| **Harness auto-detect / enroll** | **Yes** | `mosaic enroll` detects Claude/Codex/Pi/Goose and self-registers via the appservice (§4.1) | P2 (enroll v1); scan-machine-and-offer-integrate is **v2** | +| **Keypair signed identity** | **Yes, scoped** | Ed25519 signed-authorship on **gate actions only** (reviews/merges/approvals), pubkey in profile state (§4.4) | P5 (hardening); keys issued at enroll from P2 | +| **Unified event log (humans + agents, one log)** | **Yes** | Matrix room timeline _is_ the unified, receipted, replayable event log; MACP custom events are first-class entries (§4.2) | P2→P3 | +| **Humans and agents on the same surface** | **Yes** | HIL uses the same Matrix rooms via Element/custom client; escalations land where Jason already is (§4.6, §5) | P1 (fleet room) → P2 | +| **Scan machine, offer to integrate existing tools** | **Deferred (v2)** | enroll v1 auto-detects the _harness_; scanning a host for other integratable tools is explicitly **enroll v2**, post-P5 | v2 | +| **Buzz/Nostr as transport** | **No** | transport is Matrix; only patterns are borrowed (NG3) | — | + +--- + +## 8. Security + +- **Homeserver hardening.** Disable open registration (`enable_registration: false`); agents come only via the appservice. `federation_domain_whitelist` to Mosaic sites only (§6). Rate-limiting on. Media repo locked down or disabled if unused. Admin API bound to localhost / behind auth. Run Synapse behind our reverse proxy with TLS termination we control. **[VERIFY]** current recommended hardening flags for the deployed Synapse version. +- **Appservice token custody (`hs_token` / `as_token`).** These are the crown jewels — `as_token` lets the holder act as any user in the namespace; `hs_token` authenticates the homeserver → appservice callbacks. They live **only** in the appservice's secret store (see Vault interaction below), never in an agent process, never in the SDK, never in git. Registration file (which contains them) is a secret artifact, mounted at deploy, not committed. Rotate on suspected exposure; rotation requires updating both Synapse's registration and the appservice. +- **Per-agent credentials (the hard part — fleet key management).** + - Agents hold **only their own** per-agent `access_token` (minted by the appservice at enroll), not the `as_token`. Compromise of one agent ≠ compromise of the namespace. + - **Issuance:** at `mosaic enroll`, the appservice mints the token and returns it over the enroll channel (which must itself be authenticated — enroll requests carry a spin-time bootstrap secret / orchestrator-signed nonce **[VERIFY design in P2]**, so a random process can't enroll a rogue agent). + - **Signed-authorship keys:** Ed25519 private keys should be generated agent-side and only the public key leaves the agent (best custody: private key never transits the network). Where agents are ephemeral, keys are minted per-spin and discarded on teardown; the pubkey-in-profile record remains for audit. + - **Rotation:** tokens are short-lived where the Synapse version supports refresh **[VERIFY]**; otherwise the appservice supports explicit re-issue on re-enroll. A rotation runbook is a P2 deliverable. +- **Federation TLS.** Valid certs on federation endpoints; whitelist-only federation; monitor for cert expiry (a cert lapse silently defederates a site — add to observability). +- **Interaction with existing Vault/secrets.** The appservice's `hs_token`/`as_token` and the enroll bootstrap secret are stored in the existing secret manager (Vault or the project's chosen store — **open question §11**) and injected at deploy, consistent with how Gateway/DB secrets are handled today. No new bespoke secret store. Per-agent tokens are _transient runtime_ secrets, not persisted to Vault (they're re-mintable). **[VERIFY]** current Mosaic secret-management choice — the CLAUDE.md notes secrets/KBN work in flight, so align with whatever KBN-101 lands. + +--- + +## 9. Migration — strangler off Hermes + +**Principle:** stand the native layer up _alongside_ Hermes, move channels over **as each is proven at parity**, retire Hermes only when the parity checklist is green. Never a flag-day cutover (NG1). + +**Sequence:** + +1. **Stand alongside.** Native Synapse + appservice + fleet presence room live in parallel; Hermes still carries everything it carries today. Presence (P1) is _additive_ — it gives us something Hermes never had, at zero risk to existing flows. +2. **Move channels as proven.** Per channel (fleet presence → mission coordination → reviews/approvals → external-chat edge), cut traffic to Matrix, keep Hermes as hot fallback until the channel meets parity for a soak period. +3. **Retire at parity.** When every checklist item is green and soaked, decommission the Hermes MCP bridge as backbone. (External chat platforms, if still needed, can be re-attached as a _bridge at the edge_ of Matrix rather than the spine.) + +**Parity checklist (must all be green before Hermes retires):** + +- [ ] Every message class Hermes carries today has a MACP equivalent (message, review/approval, escalation, workflow-step). +- [ ] Presence/liveness is strictly better than today (it is: today = none). +- [ ] Cross-site delivery works over federation with no central SPOF. +- [ ] HIL (Jason) can see and act on escalations on the Matrix surface. +- [ ] Signed-authorship enforced on gate actions (no unsigned merge/approve accepted). +- [ ] `mosaic enroll` auto-onboards all four harnesses (Claude/Codex/Pi/Goose) with zero manual identity setup. +- [ ] Delivery receipts / durability demonstrably ≥ Hermes (no lost messages over a soak window). +- [ ] Runbooks exist: appservice token rotation, site defederation, dark-site escalation, homeserver restore. +- [ ] Observability: appservice + Synapse traced into OTEL/Jaeger like the rest of the stack. +- [ ] Rollback path documented (re-enable Hermes channel) for the soak period. + +--- + +## 10. Phased delivery plan (P1 → P5) with acceptance criteria + +> This section is the decomposition surface: each phase → one or more missions for the orchestrator. + +### P1 — Presence / availability on a minimal single-site Synapse _(the first standalone shippable slice)_ + +**Scope:** one Synapse homeserver, a **minimal** appservice (or even a scripted provisioner) whose only job is: register a handful of agent MXIDs, create the **fleet presence room**, and carry heartbeats; a minimal `packages/comms` slice that sets presence and heartbeats; native presence surfaced to a human via Element. +**Acceptance criteria:** + +- A1. Single-site Synapse deployed (`infra/matrix/`), reachable over TLS, open registration OFF. +- A2. ≥3 agents enroll (even if semi-manually) and appear in a **fleet presence room** with a live online/away/offline indicator. +- A3. `mosaic.presence` heartbeat implemented; an agent killed hard flips to **offline/dark** within `dark_threshold` deterministically (not dependent on native presence timeout alone). +- A4. A human (Jason) can open Element, join the fleet room, and see fleet liveness at a glance. +- A5. Zero impact to existing tmux + `mos-comms` flows (they still work untouched). + +### P2 — Native appservice + orchestrator auto-enroll / room-provisioning + +**Scope:** full **`apps/matrix-appservice`** registered with Synapse (`hs_token`/`as_token`, namespaces); `mosaic enroll` harness auto-detect; orchestrator-owned room taxonomy; per-agent token minting; introductions. +**Acceptance criteria:** + +- B1. Appservice registered with Synapse via registration file; owns `@mosaic_*` user namespace and room-alias namespace. +- B2. `mosaic enroll` auto-detects all four harnesses (Claude/Codex/Pi/Goose) and self-registers on spin, idempotently. +- B3. On spin, an agent is provisioned an MXID, minted its **own** access token, joined to the correct mission/team/fleet rooms, and posts a `mosaic.introduction`. +- B4. Orchestrator can create/destroy mission & team rooms with correct power levels via the appservice. +- B5. Enroll bootstrap is authenticated (a rogue local process cannot enroll a rogue agent). +- B6. Appservice + Synapse traced into OTEL/Jaeger. + +### P3 — MACP v1 spec ratified + +**Scope:** freeze the standard (§4): envelope, event types + JSON Schemas, identity model, presence model, room taxonomy, tmux↔Matrix routing rules. Spec lives in framework (`~/.config/mosaic/spec/macp`), vendored+pinned into `packages/comms` with CI drift-check. +**Acceptance criteria:** + +- C1. MACP v1 document ratified (MS-LEAD sign-off, Jason veto window closed). +- C2. JSON Schemas for all five event types published; `packages/comms` validates outbound/inbound against them. +- C3. CI fails if framework spec and vendored `packages/comms` copy diverge. +- C4. Routing-rule conformance test: SDK provably sends ephemeral+same-host over tmux, everything else over Matrix. +- C5. Unknown-`macp_version` graceful-degrade behavior tested. + +### P4 — Federation + +**Scope:** a second site homeserver + appservice; S2S federation over our DNS/TLS; cross-site fleet room; whitelist-only federation; cross-site escalation. +**Acceptance criteria:** + +- D1. Two sites federate via `.well-known`/SRV over TLS we control; `federation_domain_whitelist` restricts to Mosaic sites (no public federation). +- D2. A cross-site fleet presence room shows agents from both sites; heartbeats propagate as events across federation. +- D3. **Homelab-dark test:** killing site-A's coordinator is observed from site-B within `dark_threshold`, and the escalation/fallback fires cross-site (§5). +- D4. Cross-site gate action: a site-B agent's signed review is verified by a site-A verifier. +- D5. Defederation runbook proven (a site can be cut off cleanly). + +### P5 — Buzz-hardening + signed-authorship + Hermes retired + +**Scope:** Ed25519 signed-authorship enforced on gate actions; security hardening pass; complete the strangler and retire Hermes at parity. +**Acceptance criteria:** + +- E1. Every merge/approve/review gate action is signed; unsigned or bad-sig gate events are rejected by the appservice watcher and by consuming agents. +- E2. Token/key rotation runbooks executed at least once in anger (rotate `as_token`, rotate a per-agent key). +- E3. Security review complete: homeserver hardening flags, token custody, federation TLS/whitelist all verified. +- E4. **Parity checklist (§9) fully green + soaked.** +- E5. Hermes MCP bridge retired as backbone (optionally re-attached as an edge bridge only). + +--- + +## 11. Open questions for Jason / Mos (need a human/coordinator ruling) + +1. **DNS / domains per site.** What domain(s) do we own and want to use per site for homeserver names and `.well-known` (e.g. `site-a.mosaicstack.dev`)? Federation identity is permanent-ish once agents mint MXIDs against it — this needs a ruling **before P1 hardens** because MXIDs bake in the domain. +2. **Secret-management choice.** Is it Vault, or whatever KBN-101 lands? The appservice `hs_token`/`as_token` and enroll bootstrap secret custody depend on this (§8). CLAUDE.md signals secrets work is in flight — need the authoritative target. +3. **N-minute escalation threshold.** What is `dark_threshold_min` for a coordinator, and the second threshold before HIL is paged (§5)? Default proposed: 10 min → fallback, +5 min → HIL. Jason/Mos to confirm per role. +4. **HIL client: Element vs custom.** Do humans use off-the-shelf **Element** (fast, free, P1-ready) or do we invest in a custom HIL client? Proposed: **Element for P1–P4**, revisit custom only if HIL ergonomics demand it. +5. **Ephemeral vs persistent agent keys.** For signed-authorship, do we mint Ed25519 keys per-spin (simplest custody, no long-term private key at rest) or issue durable per-agent keys (stable identity across spins, but key-at-rest custody problem)? Proposed: **per-spin**, pubkey retained for audit. +6. **Federation topology / trust.** Full mesh between all sites, or hub-and-spoke-with-redundancy? Full mesh maximizes no-SPOF but grows O(n²); needs a call once site count is known. +7. **Fallback-coordinator assignment authority.** Who assigns each Team Lead's fallback coordinator, and is it always cross-site? (Design invariant §5 wants the fallback off the monitored host — confirm this is acceptable operationally.) +8. **Retention / compliance.** How long do we retain the Matrix event log (the unified audit trail)? Affects Synapse storage sizing and any purge policy. + +--- + +## Appendix A — Real Matrix concepts this RFC leans on (quick reference) + +- **Application Service (AS) API** — a privileged service registered with the homeserver via a registration YAML declaring `id`, `url`, `as_token`, `hs_token`, and `namespaces` (users/aliases/rooms regexes). The homeserver pushes events to the AS in transactions; the AS can register/act-as users in its namespace. _(This is our appservice backbone.)_ +- **`hs_token` / `as_token`** — `hs_token`: homeserver→AS authentication on pushed transactions; `as_token`: AS→homeserver authentication, grants acting-as any namespaced user. Both are high-value secrets (§8). +- **Masquerade (`user_id` query param)** — the AS may act as a namespaced user on C-S calls using `as_token` + `?user_id=`. We prefer per-agent tokens for blast-radius; masquerade for AS-internal bulk ops. +- **Custom event types** — timeline events via `m.room.message` with a custom `msgtype` (keeps a human-visible `body` fallback) and/or fully custom `type` (dotted, e.g. `mosaic.workflow.step`); **state events** for last-writer-wins current status readable from room state without history replay. +- **Presence EDUs** — native online/unavailable/offline signals; coarse and lossy over federation, so MACP adds an explicit heartbeat event as the authoritative liveness source (§4.5). +- **Federation (S2S API)** — server-to-server over TLS; discovery via `/.well-known/matrix/server` and/or `_matrix._tcp` SRV; restrictable with `federation_domain_whitelist`. +- **Synapse config knobs cited** — `enable_registration`, `federation_domain_whitelist`, appservice registration file, rate-limiting, admin API binding. **[VERIFY]** exact flags/paths against the deployed Synapse version at implementation time. + +_All Matrix mechanics above are cited from architecture knowledge and MUST be re-verified against the actual deployed Synapse version during P1 — every **[VERIFY]** in this document is a checkpoint, not an assumption._ diff --git a/docs/rfcs/RFC-002-INSTALL-CONFIG-TOPOLOGY.md b/docs/rfcs/RFC-002-INSTALL-CONFIG-TOPOLOGY.md new file mode 100644 index 00000000..5d472bfe --- /dev/null +++ b/docs/rfcs/RFC-002-INSTALL-CONFIG-TOPOLOGY.md @@ -0,0 +1,622 @@ +# RFC-002 — Install, Configuration & Topology for the Mosaic Matrix/MACP Comms System + +- **Status:** DRAFT — for Team Lead → Orchestrator staffing +- **Author:** MS-LEAD (reviewer identity `ms-lead-reviewer`) +- **Sponsor / veto:** Jason (human lead) +- **Date:** 2026-07-24 +- **Program:** Mosaic Stack comms-evolution +- **Companion to:** RFC-001 — _MACP: A Mosaic-Native, Matrix-Native Comms Layer_. RFC-001 is the architecture (self-hosted Synapse + Mosaic appservice backbone + `packages/comms` SDK + MACP standard + per-site federation). **RFC-002 is the config substrate the whole thing installs and runs on.** +- **Audience:** Team Leads, the Mosaic orchestrator, infra, harness maintainers, and — critically — **strangers who install this open-source product on hardware we will never see.** + +> This is a **design document**. No code ships from this RFC. It is written to be decomposed into missions with per-phase acceptance criteria, and it slots under RFC-001's P1→P5. Every uncertain or must-live-validate claim is flagged **[VERIFY]**. + +> **The one framing that governs every decision below:** this is an **open-source product**. Someone we have never met will `git clone` it and run it on their own domains, their own DNS, their own certs, their own hardware. **NOTHING may hardcode our fleet's topology.** There is no `woltje.com` in the code, no assumption that DNS exists, no assumption that a second site exists. Every topology fact is **user-supplied config**. Where this doc uses `mosaic.woltje.com` / `mosaic.uscllc.com`, those are **illustrative operator values** (Jason's real installs), never defaults and never literals in the product. + +--- + +## 0. TL;DR + +The comms system installs against a **user-supplied topology**, never a baked-in one. At install the operator declares exactly one of **three topology modes**: **(A) split-domain** (identity `server_name` ≠ homeserver host, wired via Matrix delegation — this is Jason's `mosaic.woltje.com` identity + `matrix.woltje.com` host setup), **(B) single-domain** (`server_name` == homeserver host), or **(C) IP-only standalone** (no DNS, no federation, fully supported for local/airgapped). **The PRIMARY/home instance is ALWAYS configured; federation is OPTIONAL.** A single standalone instance MUST work with zero federation. + +**Federation is a hard-gated capability: it REQUIRES DNS + valid certificates. IP-only federation is not possible and is not supported.** IP-only means standalone-only, forever, until the operator acquires DNS + certs. + +Certificates are **one ACME integration** with a user-chosen **directory URL**: either **step-ca** (self-hosted private ACME CA, for total control and private/internal domains public CAs can't issue for) or **Let's Encrypt** (public ACME, ease-of-use). The operator also picks a challenge type (HTTP-01 / DNS-01 / TLS-ALPN-01); **DNS-01 is the answer for private/split-horizon domains.** + +Secrets go through a **pluggable `SecretBackend` interface** — no forced paid dependency. Ships with a **Vault** implementation and a **Vaultwarden** implementation; the operator picks at install. The Vaultwarden model (org + orchestrator enrolled as authority + per-agent scoped access) is designed-for, with an honest **[VERIFY]** on how far Vaultwarden's machine-account coverage has matured. + +Config is **DB-backed with sane defaults and install-time overrides**. Precedence: **install-time → DB override → default.** Config is split into **install-time-immutable** (e.g. `server_name`, which is baked into every MXID and cannot change without re-homing every identity) and **runtime-tunable** (e.g. dark-threshold). + +RFC-002 is the substrate; **RFC-001's P1 (presence) needs only Mode A/B single-instance clean-domain and does NOT require federation, IP-only, or the secret-backend rotation story resolved.** + +--- + +## 1. Goals / Non-Goals + +### 1.1 Goals + +- **G1 — Installable by a stranger.** A person with no relationship to our fleet can install, configure, and run the comms system from published artifacts and a guided installer, on their own hardware and domains, with no edits to product code. +- **G2 — Zero hardcoded topology.** Every topology fact — `server_name`, homeserver host/IP, delegation method, federation peers, cert mode, secret backend — is **user-supplied config**, validated at install, stored in the product DB. No fleet-specific literal ships in the product. +- **G3 — Standalone MUST work.** The PRIMARY instance is always fully functional with **zero federation**, including with **no DNS at all** (Mode C, IP-only). Presence, rooms, MACP, HIL-via-Element all work single-instance. +- **G4 — Federation is optional but honestly gated.** Federation is opt-in and, when opted into, **requires DNS + valid certificates as a hard precondition.** The installer must refuse to _claim_ federation is working when the DNS/cert preconditions aren't met. +- **G5 — One ACME integration, two CA choices.** Build a single ACME cert-provisioning path; the operator selects step-ca or Let's Encrypt by supplying an **ACME directory URL** plus a challenge type. No second, bespoke cert path. +- **G6 — No forced paid dependency for secrets.** A pluggable `SecretBackend` with at least Vault and Vaultwarden implementations, chosen at install. Open-source ethos: the free/self-hostable path must be first-class. +- **G7 — Defaults that just work, overrides where they matter.** DB-backed config with sane defaults so most operators change little; install-time overrides for the topology-critical values; a clear immutable-vs-tunable boundary so operators can't foot-gun `server_name`. +- **G8 — A clean upgrade path.** An operator who starts standalone can later turn on federation with a documented, honest procedure (including the real cost if they started IP-only and must now acquire a stable `server_name`). + +### 1.2 Non-Goals + +- **NG1 — Not hosting a managed service.** This RFC is about _self-install_. We are not building multi-tenant SaaS provisioning; each operator runs their own instance(s). +- **NG2 — Not a new cert stack.** We do not write our own CA, our own ACME client protocol, or a non-ACME cert path. We integrate ACME and let the operator point it at step-ca or Let's Encrypt. (We _may_ bundle/recommend step-ca as the self-hosted CA, but via its standard ACME provisioner, not a fork.) +- **NG3 — Not a new secret manager.** We define an interface and ship adapters. We do not build a secret store; we do not force one. +- **NG4 — Not public-network Matrix federation.** Consistent with RFC-001 NG5: federation is Mosaic-site-to-Mosaic-site over infrastructure the operator controls, allowlisted. No `matrix.org` federation. +- **NG5 — Not making IP-only federate.** We will not ship a hack (self-signed S2S trust bundles, `/etc/hosts` federation) that pretends IP-only can federate. IP-only is standalone. This is a deliberate, honest boundary (§2.4, §7). +- **NG6 — Not re-homing identities silently.** We will not offer a "just change your `server_name`" button that quietly orphans every MXID. Any path that changes `server_name` is a flagged, gated, documented identity re-home (§5.3, §7). + +--- + +## 2. The topology model + +### 2.1 The core split: `server_name` vs homeserver host + +Matrix has exactly the split Jason described, natively: + +- **`server_name`** — the Synapse config value that is the server's **identity domain**. It is the part after the colon in every MXID (`@mosaic_agent:mosaic.woltje.com`) and every room alias (`#mission-KBN-101:mosaic.woltje.com`). It is **baked into every identity the moment that identity is minted.** Changing it re-homes everything. This is `server_name` in Synapse's `homeserver.yaml`. +- **Homeserver host** — the actual network location (hostname:port or IP:port) where the Synapse process answers federation and (optionally proxied) client traffic. It **can differ** from `server_name`. Matrix reconciles the difference through **delegation**: `https:///.well-known/matrix/server` returning `{"m.server": "matrix.woltje.com:443"}`, and/or a `_matrix._tcp.` **SRV** record. **[VERIFY]** `.well-known` vs SRV precedence on the deployed Synapse version (RFC-001 §6 flags the same). + +So Jason's "mosaic._ app-domain + matrix._ homeserver-domain" split maps precisely: **`server_name = mosaic.woltje.com` (identity, in MXIDs), homeserver runs at `matrix.woltje.com` (discovered via delegation).** That is **Mode A**. + +### 2.2 The topology config schema + +One canonical config object, stored in the product DB (§5), populated at install (§6). Illustrative shape (field names decomposition-ready, not frozen): + +```jsonc +{ + "topology": { + "mode": "split-domain | single-domain | ip-only-standalone", // A | B | C — install-time-immutable + + "identity": { + "server_name": "mosaic.woltje.com", // INSTALL-TIME-IMMUTABLE. In MXIDs. Never change without re-home (§5.3). + "server_name_kind": "domain | ip", // "ip" only legal in Mode C + }, + + "homeserver": { + "host": "matrix.woltje.com", // where Synapse actually listens (Mode A: differs from server_name) + "port": 8448, // federation port (default 8448) or 443 if proxied — [VERIFY] per deploy + "client_bind": "https://matrix.woltje.com", // C-S API public URL (proxied) + "bind_ip": null, // Mode C: e.g. "192.168.1.50" ; Modes A/B: null (DNS-resolved) + }, + + "delegation": { + "method": "well-known | srv | none", // Mode A: well-known or srv ; Mode B/C: none + "well_known_server": { "m.server": "matrix.woltje.com:443" }, // if method=well-known + "srv_record": "_matrix._tcp.mosaic.woltje.com. 3600 IN SRV 10 0 443 matrix.woltje.com.", // if method=srv (documented, operator provisions) + }, + + "federation": { + "enabled": true, // OPTIONAL. Mode C forces false. + "domain_whitelist": [ + // Synapse federation_domain_whitelist — allowlist ONLY + "mosaic.woltje.com", + "mosaic.uscllc.com", + ], + "peers": [ + // operator-declared peer sites (for room/fleet wiring) + { "server_name": "mosaic.uscllc.com", "role": "secondary", "fleet_room": true }, + ], + }, + + "tls": { + "acme": { + "directory_url": "https://acme.mosaic.woltje.com/acme/acme/directory", // step-ca OR https://acme-v02.api.letsencrypt.org/directory + "ca_kind": "step-ca | letsencrypt", // informational label; the directory_url is the real switch + "challenge": "dns-01 | http-01 | tls-alpn-01", + "account_email": "ops@woltje.com", // ACME account contact + "eab": { "kid": null, "hmac_key_ref": null }, // External Account Binding if the CA requires it (some step-ca provisioners) — secret via SecretBackend + }, + "client_tls_mode": "acme | self-signed", // Mode C may use self-signed for local C-S TLS (weaker trust, §8) + }, + + "secrets": { + "backend": "vault | vaultwarden", // pluggable, install-time choice (§4) + "connection": { + "address": "https://vault.woltje.com:8200", // or Vaultwarden/Bitwarden server URL + "auth_ref": "…", // how the appservice authenticates to the backend (bootstrap, §4/§8) + "namespace_or_org": "mosaic-fleet", // Vault namespace / mount, OR Vaultwarden org id + }, + }, + }, +} +``` + +### 2.3 The three modes, concretely + +Exactly three supported modes (Jason's ruling — no others): + +**Mode A — split-domain (identity ≠ host, delegated).** _Jason's PRIMARY._ Federation-capable. This is the recommended production shape because it lets identity live on a clean app-domain while the homeserver runs on a separate operational host. + +```jsonc +// Mode A — mosaic.woltje.com identity, matrix.woltje.com host, federated with a second site +{ + "topology": { + "mode": "split-domain", + "identity": { "server_name": "mosaic.woltje.com", "server_name_kind": "domain" }, + "homeserver": { + "host": "matrix.woltje.com", + "port": 443, + "client_bind": "https://matrix.woltje.com", + "bind_ip": null, + }, + "delegation": { + "method": "well-known", + "well_known_server": { "m.server": "matrix.woltje.com:443" }, + }, + "federation": { + "enabled": true, + "domain_whitelist": ["mosaic.woltje.com", "mosaic.uscllc.com"], + "peers": [{ "server_name": "mosaic.uscllc.com", "role": "secondary", "fleet_room": true }], + }, + "tls": { + "acme": { + "directory_url": "https://acme-v02.api.letsencrypt.org/directory", // public LE, or a step-ca directory + "ca_kind": "letsencrypt", + "challenge": "dns-01", + "account_email": "ops@woltje.com", + }, + "client_tls_mode": "acme", + }, + "secrets": { + "backend": "vaultwarden", + "connection": { "address": "https://vw.woltje.com", "namespace_or_org": "mosaic-fleet" }, + }, + }, +} +``` + +MXIDs on this instance: `@mosaic_coordinator-1:mosaic.woltje.com`. A human/agent's homeserver is discovered by resolving `.well-known/matrix/server` on `mosaic.woltje.com` → `matrix.woltje.com:443`. + +**Mode B — single-domain (identity == host).** Simpler; the `server_name` _is_ the host. No delegation needed. Federation-capable (still needs DNS + cert on that one domain). + +```jsonc +// Mode B — one domain does everything +{ + "topology": { + "mode": "single-domain", + "identity": { "server_name": "matrix.example.org", "server_name_kind": "domain" }, + "homeserver": { + "host": "matrix.example.org", + "port": 8448, + "client_bind": "https://matrix.example.org", + "bind_ip": null, + }, + "delegation": { "method": "none" }, + "federation": { "enabled": false, "domain_whitelist": [], "peers": [] }, // optional — off here + "tls": { + "acme": { + "directory_url": "https://acme-v02.api.letsencrypt.org/directory", + "ca_kind": "letsencrypt", + "challenge": "http-01", + "account_email": "admin@example.org", + }, + "client_tls_mode": "acme", + }, + "secrets": { + "backend": "vault", + "connection": { "address": "https://vault.example.org:8200", "namespace_or_org": "mosaic" }, + }, + }, +} +``` + +MXIDs: `@mosaic_coordinator-1:matrix.example.org`. Here `server_name == host`, so `@:matrix.example.org` is both the identity domain and where Synapse actually answers. + +**Mode C — IP-only standalone (no DNS, no federation).** Fully supported for local/airgapped/homelab-without-DNS. `server_name` is an IP:port literal. **Cannot federate — ever — in this mode** (federation requires a real domain + valid CA cert; §2.4). Client TLS may be self-signed (weaker trust, §8). + +```jsonc +// Mode C — airgapped / local, no DNS, standalone only +{ + "topology": { + "mode": "ip-only-standalone", + "identity": { "server_name": "192.168.1.50:8448", "server_name_kind": "ip" }, + "homeserver": { + "host": "192.168.1.50", + "port": 8448, + "client_bind": "https://192.168.1.50:8448", + "bind_ip": "192.168.1.50", + }, + "delegation": { "method": "none" }, + "federation": { "enabled": false, "domain_whitelist": [], "peers": [] }, // FORCED false in Mode C + "tls": { "acme": null, "client_tls_mode": "self-signed" }, // may use a private step-ca or self-signed for C-S TLS + "secrets": { + "backend": "vaultwarden", + "connection": { "address": "http://192.168.1.51:8080", "namespace_or_org": "mosaic-local" }, + }, + }, +} +``` + +MXIDs: `@mosaic_coordinator-1:192.168.1.50:8448`. **Warning surfaced at install:** this `server_name` is an IP literal; if the operator ever wants federation they must move to a domain, which is an **identity re-home** (§5.3, §7). + +> **[VERIFY]** Synapse accepts an `ip:port` `server_name` and mints usable MXIDs against it for local/standalone use. This is believed workable for non-federated operation but must be validated against the deployed Synapse version; some Synapse versions/tools assume a DNS-resolvable `server_name`. If an IP literal is rejected, Mode C falls back to a **fabricated local domain** (e.g. `mosaic.local`) resolved via `/etc/hosts` or a local resolver — still standalone-only, same re-home caveat. + +### 2.4 The hard federation gate (Jason's HARD STOP) + +**Federation REQUIRES DNS + valid certificates. This is a hard stop, enforced by the installer and by the config validator, not a suggestion.** + +| Precondition | Why | Enforced where | +| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `server_name` resolves in public/peer DNS (or delegated target does) | S2S discovery uses `.well-known`/SRV over DNS; peers must resolve you | installer reachability check (§6); config validator rejects `federation.enabled=true` with `server_name_kind=ip` | +| Valid TLS cert on the federation endpoint, chained to a CA the peer trusts | S2S is TLS; a peer validates your cert. Self-signed/untrusted ⇒ peer refuses ⇒ you are defederated | installer cert probe (§6); cert monitor (§3.4) | +| Federation `domain_whitelist` non-empty and mutually consistent with peers | allowlist-only federation (RFC-001 NG5/§6) | config validator | + +**IP-only ⇒ federation is impossible.** There is no valid public/peer CA cert for a bare IP in our trust model (and we will not ship a self-signed S2S trust hack — NG5). Therefore **Mode C is standalone-only by construction**, and the config validator makes `mode=ip-only-standalone ∧ federation.enabled=true` an **illegal state that cannot be persisted.** + +This is the honest, load-bearing boundary of the whole topology model: + +``` + DNS + valid cert? + ┌─────────────┴─────────────┐ + YES NO + │ │ + Mode A or B Mode C (IP-only) + federation OPTIONAL STANDALONE ONLY + (opt-in, allowlisted) (federation impossible) +``` + +--- + +## 3. Certificate provisioning — one ACME integration + +### 3.1 Single integration, directory-URL as the switch + +We build **exactly one** cert-provisioning integration: an **ACME client integration**. Both supported CAs are ACME CAs. The operator does **not** choose between two code paths; they choose an **ACME directory URL** and a **challenge type**. That is the entire surface. + +| CA choice | What it is | ACME directory URL (illustrative) | Why an operator picks it | +| ----------------------- | ------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **step-ca (Smallstep)** | self-hosted **private** ACME CA | `https://acme./acme//directory` | **Total control**; issues for **private/internal/split-horizon domains** a public CA can't (e.g. `mosaic.internal`, RFC-1918 split-horizon); airgap-friendly; you own the root | +| **Let's Encrypt** | public ACME CA | `https://acme-v02.api.letsencrypt.org/directory` (staging: `.../acme-staging-v02...`) | **Ease of use**; universally trusted chain (ISRG Root X1); zero CA to operate; ideal for public domains | + +Because both speak ACME, the same client (account key, order, authorization, challenge, finalize, cert-fetch, renew) drives either. The `ca_kind` label in config is informational for UX; the **`directory_url` is the real determinant**. **[VERIFY]** whether the chosen ACME library requires per-CA quirks (LE rate limits, staging switch; step-ca **External Account Binding** on some provisioners — if EAB is required the `kid`/`hmac_key` come from the SecretBackend, §4). + +### 3.2 Challenge-type matrix (which challenge for which topology) + +The operator picks one challenge type per the domains they're covering. This is the crux for **public vs private/split-horizon**: + +| Challenge | How it proves control | Best for | Cannot / caveat | +| --------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **HTTP-01** | CA GETs `http:///.well-known/acme-challenge/` on port 80 | **Public, single hostname**, port 80 reachable from CA (Mode B, or Mode A's homeserver host) | Needs inbound :80 from the CA; **cannot** do wildcards; **useless for private domains** a public CA can't reach | +| **DNS-01** | CA checks a `_acme-challenge.` **TXT** record you publish | **Private / split-horizon / internal domains**, **wildcards**, and any domain where inbound HTTP from the CA is impossible | Requires **programmatic DNS API** access to publish TXT (or manual for step-ca where you own the resolver). **This is the answer for step-ca on private domains** and for Mode A when the homeserver host isn't publicly HTTP-reachable | +| **TLS-ALPN-01** | CA connects TLS on :443 with ALPN `acme-tls/1` | Public host where **:443 is free** but :80 is blocked | Needs the ACME client to own the :443 TLS handshake briefly; awkward behind some reverse proxies — **[VERIFY]** against our proxy (RFC-001 terminates TLS at a reverse proxy) | + +**Guidance baked into the installer:** + +- **Let's Encrypt + public domain, port 80 open →** HTTP-01 (simplest). Wildcard or no inbound :80 → DNS-01. +- **step-ca + private/internal/split-horizon domain →** **DNS-01** (the private CA can validate against a resolver you control; public HTTP reachability is irrelevant). This is the combination that lets a private homelab domain get real certs. +- **:443-only public host →** TLS-ALPN-01. + +### 3.3 Delegation setup for split-domain (Mode A) + +For Mode A, the cert and the delegation must agree. Concretely, at install for `server_name=mosaic.woltje.com`, host `matrix.woltje.com`: + +1. **Cert(s):** obtain a valid cert for **`matrix.woltje.com`** (the federation/host endpoint — this is where the TLS handshake actually lands). If serving `.well-known` over HTTPS on `mosaic.woltje.com`, that origin **also** needs a valid cert for `mosaic.woltje.com`. So Mode A typically provisions **two** SANs/certs: identity-domain (`mosaic.woltje.com`, serves `.well-known`) and host (`matrix.woltje.com`, serves S2S+C-S). **[VERIFY]** whether a single multi-SAN cert is preferable operationally. +2. **Delegation record**, one of: + - **`.well-known`:** serve `https://mosaic.woltje.com/.well-known/matrix/server` → `{"m.server":"matrix.woltje.com:443"}` (and `.well-known/matrix/client` for C-S discovery so agents/Element find the host). + - **SRV:** `_matrix._tcp.mosaic.woltje.com. IN SRV 10 0 443 matrix.woltje.com.` The installer **documents and validates** the record but the operator provisions it in their DNS (we don't run their DNS). **[VERIFY]** `.well-known` vs SRV precedence on the deployed Synapse. +3. **Validate:** installer fetches the operator's own `.well-known`/SRV and confirms it points at the configured host, and that the host presents a valid cert (§6). Only then does it declare Mode A "federation-ready." + +### 3.4 Renewal & monitoring — a lapsed federation cert silently defederates + +**This is the operational trap and it must alarm.** ACME certs are short-lived (LE = 90 days; step-ca often shorter by policy). A federation cert that lapses does **not** throw a loud error — peers simply **stop trusting the S2S handshake and the site silently drops out of federation.** From inside, everything looks fine; from peers, the site went dark. That is exactly the "homelab went dark and took comms with it" trauma (RFC-001 §5), but caused by a cert, not a host. + +Requirements: + +- **Auto-renew** on the standard ACME schedule (renew at ~⅓ lifetime remaining; LE guidance ~30 days before expiry). The ACME integration owns this loop. +- **Expiry monitoring as a first-class alarm.** Emit cert-days-remaining into OTEL/Jaeger metrics (consistent with RFC-001 §8's "monitor for cert expiry — a cert lapse silently defederates"). Alarm thresholds (e.g. warn <14d, critical <3d) are **runtime-tunable** config (§5). +- **Federation-health probe:** periodically resolve our own delegation and validate our own cert _as a peer would_ (external vantage where possible), so a broken renewal is caught as "we would fail a peer's validation" before a peer notices. +- **Escalation tie-in:** a critical cert-expiry or federation-health failure raises a `mosaic.escalation` (RFC-001 §4.2/§5) into the HIL room. A cert lapse is a fleet-visibility incident, not a silent config drift. + +--- + +## 4. Secret backend interface + +### 4.1 The `SecretBackend` contract + +A single pluggable interface. The appservice and orchestrator depend on the **interface**, never on Vault or Vaultwarden directly. Chosen at install; swappable without touching callers. Illustrative contract (decomposition-ready, not frozen): + +```ts +interface SecretBackend { + // --- static secret CRUD (appservice tokens, ACME EAB, DB creds) --- + get(ref: SecretRef): Promise; + put(ref: SecretRef, value: SecretValue, opts?: { immutable?: boolean }): Promise; + rotate( + ref: SecretRef, + next: SecretValue, + ): Promise<{ previous: SecretVersion; current: SecretVersion }>; + list(prefix: SecretRef): Promise; + delete(ref: SecretRef): Promise; + + // --- agent-credential lifecycle (the fleet-identity part) --- + enrollAgent(input: { + agentSlug: string; + scope: CredentialScope; // which rooms/secrets this agent may read + ttl?: Duration; // ephemeral-by-default per RFC-001 §8 + }): Promise; // wraps the per-agent access_token + optional pubkey record + + revokeAgent(agentSlug: string): Promise; // must be authoritative & immediate + + // --- health / bootstrap --- + health(): Promise; + authenticateSelf(bootstrap: BootstrapAuth): Promise; // how the appservice/orchestrator logs into the backend +} +``` + +Design intent: **`get/put/rotate`** cover the static crown-jewel secrets (appservice `hs_token`/`as_token`, ACME account/EAB keys, DB DSN). **`enrollAgent/revokeAgent`** cover the _fleet-identity_ lifecycle — this is where RFC-001's "mint per-agent token at enroll, discard on teardown" (RFC-001 §4.1, §8) actually lands. + +### 4.2 How appservice / agent tokens map onto it + +RFC-001 defines three tiers of Matrix secret. They map cleanly: + +| RFC-001 secret | Sensitivity | `SecretBackend` treatment | +| ------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`as_token`** (AS→HS, acts as any namespaced user) | crown jewel | `put(immutable-ish)` + `rotate`; **only** the appservice may `get` it; stored under a fleet-admin scope; never handed to an agent | +| **`hs_token`** (HS→AS callback auth) | crown jewel | same as `as_token`; both live only in appservice scope (RFC-001 §8) | +| **enroll bootstrap secret / orchestrator-signed nonce** | high | `get` by orchestrator + appservice only; used to authenticate `POST /enroll` so a rogue process can't enroll a rogue agent (RFC-001 §8, B5) | +| **per-agent `access_token`** | transient runtime | minted via `enrollAgent`, scoped to that agent, **short-TTL / re-mintable**, discarded on teardown; **not** durably persisted (RFC-001 §8 says per-agent tokens are transient) — the backend may hold a short-lived handle or a personal-vault entry for the agent's own lifetime | +| **Ed25519 signed-authorship keypair** | high (private key) | private key generated **agent-side**, only pubkey leaves the agent (RFC-001 §4.4/§8); the SecretBackend stores the **pubkey record** for audit; per-spin keys need no at-rest custody | + +The key blast-radius property (RFC-001 §8) is preserved: agents receive **only their own** credential via `enrollAgent`; the `as_token` never leaves appservice scope. + +### 4.3 Vault implementation + +Vault maps naturally: + +- Static secrets → **KV v2** at a mount/namespace (`mosaic-fleet/`), with versioning giving `rotate` semantics for free. +- **`enrollAgent`** → issue a scoped, TTL'd token or use **AppRole** / a scoped policy per agent; Vault's native TTL + revocation is exactly the transient per-agent model. `revokeAgent` → Vault token/lease revoke (authoritative, immediate). +- **`authenticateSelf`** → the appservice authenticates to Vault via AppRole (role_id from config, secret_id injected at deploy) or a platform auth method; consistent with how Gateway/DB secrets are handled today (RFC-001 §8). **[VERIFY]** align with whatever KBN-101 lands for Mosaic secret management (CLAUDE.md flags secrets work in flight). +- **Trade-off (honesty):** Vault is the most capable backend but is **not** the free-and-simple default for a hobbyist stranger; hence it must not be _forced_ (G6). + +### 4.4 Vaultwarden implementation + the org/enroll/revoke agent-account model + +Vaultwarden (self-hostable Bitwarden-compatible server) is the **open-source-ethos default candidate** — free, self-hostable, familiar. Jason's model, mapped onto Bitwarden/Vaultwarden's org primitives: + +1. **Operator creates one or more Bitwarden orgs** at install (e.g. `mosaic-fleet`). +2. **The orchestrator is enrolled into the org and granted authority** to enroll/revoke agent sessions — it is the org's automation principal (admin/manager over an agents **collection**). +3. **Agents get scoped credential access:** each agent gets access to a **collection** (or a personal vault provisioned for its spin) holding exactly the secrets its scope allows. `enrollAgent` = grant the agent principal access to its collection + provision its per-agent Matrix token entry; `revokeAgent` = remove the agent principal / revoke its access, immediately. +4. **User + agents share scoped access:** the human operator and the agents both hold credentials in the same org, scoped by collection — humans and agents on one secret surface, mirroring RFC-001's "humans and agents on one comms surface" pattern. + +Mapping to the interface: + +| Interface op | Vaultwarden mechanism | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `get/put/rotate` (static) | items in an org **collection**; rotate = new item version / replace + old-version audit | +| `enrollAgent` | create/attach agent principal to its **collection**; provision per-agent token item, TTL enforced by our teardown (Vaultwarden itself is not TTL-native — see [VERIFY]) | +| `revokeAgent` | revoke the agent principal's org membership / collection access | +| `authenticateSelf` | orchestrator authenticates as the org automation principal | + +**HONEST MATURITY FLAG — [VERIFY]:** Bitwarden's clean **machine-account / service-account** primitive lives in **Bitwarden Secrets Manager**, and **Vaultwarden's coverage of Secrets Manager / machine accounts is partial and evolving.** What is known to work today on Vaultwarden: **orgs, collections, per-user (incl. a per-agent "user") vaults, and collection-scoped sharing.** What **may not** be fully there: the polished **machine-account API**, native short-TTL service credentials, and fine-grained programmatic access-token issuance equivalent to hosted Bitwarden Secrets Manager. **[VERIFY]** current Vaultwarden version's Secrets Manager / machine-account support before P-level commitment. + +**Why this is not a blocker:** the interface is designed so **either backend is viable**. If Vaultwarden's machine-account API isn't ready, the Vaultwarden adapter implements `enrollAgent` via the **personal-vault-per-agent + org-collection** model that works _today_ (create an agent principal, share the scoped collection, we enforce TTL via orchestrator teardown rather than backend-native TTL). If an operator needs backend-native short-TTL machine credentials now, they choose the **Vault** adapter. **We are not blocked on Vaultwarden maturing**, because the `SecretBackend` abstraction lets the polished-machine-account behavior land later without changing any caller. + +--- + +## 5. Config system + +### 5.1 Storage & precedence + +Config is **DB-backed** (Postgres, per the stack — CLAUDE.md/RFC-001), with sane defaults compiled into the product and install-time overrides. **Precedence, highest wins:** + +``` +install-time value > DB override (runtime) > compiled default +``` + +- **Compiled default** — ships in the product; what a stranger gets with zero config for every non-topology-critical key. +- **Install-time value** — captured by the installer (§6), written to DB, and for **install-time-immutable** keys, **locked** (marked non-overridable). +- **DB override** — runtime tuning via admin surface, allowed **only** for keys classified runtime-tunable. + +> Nuance: "install-time > DB override" applies to **immutable** keys — the install-time value is frozen and a DB override of it is rejected. For **tunable** keys, the DB override is the live value and the install-time value is just the initial seed. The classification (§5.3) is what makes the precedence unambiguous per key. + +### 5.2 DB schema shape + +Illustrative (Drizzle/Postgres, per stack conventions): + +```sql +-- one row per config key +CREATE TABLE comms_config ( + key text PRIMARY KEY, -- e.g. 'topology.identity.server_name' + value jsonb NOT NULL, -- current effective value + source text NOT NULL, -- 'install' | 'db-override' | 'default' + mutability text NOT NULL, -- 'install-immutable' | 'runtime-tunable' + set_by text, -- operator/agent/system that set it + set_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT immutable_not_overridable + CHECK (NOT (mutability = 'install-immutable' AND source = 'db-override')) +); + +-- append-only audit of every change (esp. attempted immutable changes) +CREATE TABLE comms_config_audit ( + id bigserial PRIMARY KEY, + key text NOT NULL, + old_value jsonb, + new_value jsonb, + actor text NOT NULL, + action text NOT NULL, -- 'set' | 'override' | 'rejected-immutable' + at timestamptz NOT NULL DEFAULT now() +); +``` + +The DB `CHECK` is a belt-and-braces backstop; the application-layer config service enforces mutability and records rejected immutable-change attempts in the audit table. Secrets are **referenced** here (a `SecretRef`), never stored inline — actual secret values live in the `SecretBackend` (§4). + +### 5.3 Install-time-immutable vs runtime-tunable — the key table + +The single most important classification: **what can never change after install** vs **what an operator tunes anytime.** Getting `server_name` on the wrong side of this line is a foot-gun that orphans every identity. + +| Config key | Mutability | Rationale / cost of change | +| ------------------------------------------------ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `topology.mode` (A/B/C) | **install-immutable** | changing mode changes identity/federation semantics; a mode change is effectively a reinstall/re-home | +| `topology.identity.server_name` | **install-immutable** | **baked into every MXID and room alias.** Changing it re-homes every identity — see re-home note below. **This is THE immutable value.** | +| `topology.identity.server_name_kind` | **install-immutable** | domain↔ip change is a re-home (Mode C→A/B) | +| `topology.homeserver.host` | install-immutable (**delegation-tunable**) | in Mode A you _can_ move the host if you update delegation to match — the identity is unchanged; treat as immutable-with-migration, not free | +| `topology.homeserver.bind_ip` / `port` | runtime-tunable (ops) | operational network binding; no identity impact | +| `topology.delegation.method` / records | tunable-with-care | can switch well-known↔SRV as long as both still resolve to the same host; validated on change | +| `topology.federation.enabled` | **runtime-tunable (gated)** | can flip **on** only if DNS+cert preconditions pass (§2.4); flipping standalone→federated is the §7 upgrade path | +| `topology.federation.domain_whitelist` / `peers` | runtime-tunable | add/remove peers over time; each add re-validated | +| `tls.acme.directory_url` / `ca_kind` | runtime-tunable | can switch CA (e.g. LE→step-ca); triggers re-issue; monitor for trust-chain change | +| `tls.acme.challenge` | runtime-tunable | switch challenge type if DNS/HTTP reachability changes | +| `tls.client_tls_mode` | tunable-with-care | self-signed→acme is fine; acme→self-signed weakens trust (§8) | +| `secrets.backend` | install-immutable (**migration-only**) | switching Vault↔Vaultwarden requires a secret migration; not a live flip | +| `secrets.connection.*` | runtime-tunable | rotate backend address/auth without changing which backend | +| `presence.heartbeat_interval_ms` | runtime-tunable | RFC-001 §4.5 default ~30s; pure tuning | +| `presence.miss_tolerance` | runtime-tunable | RFC-001 §4.5 default 2 | +| `escalation.dark_threshold_min` | runtime-tunable | RFC-001 §5/§11 — default 10min → fallback | +| `escalation.hil_threshold_min` | runtime-tunable | RFC-001 §5 — default +5min → HIL | +| `cert.expiry_warn_days` / `expiry_critical_days` | runtime-tunable | §3.4 alarm thresholds (default 14 / 3) | +| `federation.health_probe_interval` | runtime-tunable | §3.4 | + +**Identity re-home note (the cost of changing `server_name`):** because every MXID (`@mosaic_*:server_name`) and room alias (`#…:server_name`) embeds `server_name`, changing it means: every agent gets a **new identity**, all rooms must be **recreated/re-aliased**, signed-authorship pubkey records re-published, and federation peers re-pointed. There is **no in-place rename** in Matrix. Hence `server_name` is install-immutable and the installer gates it behind an explicit warning (§6.7). Changing it is a **migration/reinstall**, honestly (§7). + +### 5.4 How the appservice / homeserver read config + +- **Synapse (homeserver)** reads a _rendered_ `homeserver.yaml`. The config service **renders** Synapse's config (server_name, listeners, `federation_domain_whitelist`, `enable_registration: false`, appservice registration path, TLS/delegation) from the DB-backed config at deploy/reconfigure time. Synapse itself is not DB-config-aware; the source of truth is the product DB, and Synapse config is a **generated artifact**. Certain Synapse values (notably `server_name`) require a **Synapse restart** and are exactly the immutable ones — reinforcing §5.3. +- **The appservice** reads config **live** from the DB config service for runtime-tunable values (thresholds, whitelist changes, cert alarm thresholds) and from the `SecretBackend` for secrets. Immutable topology values are read once at boot (they can't change under it). +- **`packages/comms` SDK** receives the values it needs (homeserver client URL, presence intervals) from the appservice at enroll (RFC-001 §4.1 returns `{mxid, access_token, homeserver, rooms[]}`), so agents never read the config DB directly. +- **Delegation artifacts** (`.well-known/matrix/server`, `.well-known/matrix/client`) are likewise **rendered** from config and served by the reverse proxy / homeserver. + +--- + +## 6. Installer UX flow + +A guided installer (`mosaic comms install` or equivalent) that captures topology, provisions certs, wires secrets, and **validates before declaring success.** It must never report success it hasn't proven. Steps: + +**6.1 — Preflight & detection.** Detect existing DNS records for a candidate domain, existing certs, an existing reachable Synapse, an existing Vault/Vaultwarden. Offer detected values as suggestions (never as silent defaults). Detect whether the host has public inbound :80/:443 (informs challenge-type guidance, §3.2). + +**6.2 — Primary instance (ALWAYS).** The PRIMARY/home instance is always configured — there is no "skip primary." Prompt for its identity. This is non-optional and is what makes standalone work out of the box. + +**6.3 — Pick topology mode (A/B/C).** Ask the shape: + +- Do you have a domain? **No →** Mode C (IP-only standalone); warn federation is impossible here (§2.4) and that `server_name` will be an IP (re-home cost if they later want federation). +- Yes, and identity domain differs from the homeserver host? **Yes →** Mode A (split-domain); capture `server_name` + host + delegation method. +- Yes, one domain does everything? **→** Mode B (single-domain). + +**6.4 — Pick CA (ACME directory).** step-ca vs Let's Encrypt → capture `directory_url`, account email, and challenge type with the §3.2 guidance surfaced (e.g. "private/internal domain? → DNS-01"). Capture EAB if the CA requires it (→ SecretBackend). For step-ca, offer to point at an existing step-ca or document standing one up. + +**6.5 — Pick secret backend.** Vault vs Vaultwarden → capture connection (address, org/namespace, bootstrap auth). If Vaultwarden, walk the org/collection setup (§4.4) and **surface the machine-account maturity [VERIFY]** honestly so the operator chooses eyes-open. + +**6.6 — Federation (OPTIONAL).** Only offered if Mode A/B. Ask whether to enable federation now; if yes, capture peer `server_name`s and build the `domain_whitelist`. If Mode C, federation is not offered (greyed out with the explanation). Federation-off is a first-class, fully-supported outcome. + +**6.7 — The "what can't be changed later" warning gate.** Before writing immutable config, present an explicit confirmation: + +``` +⚠ IMMUTABLE CHOICES — read before confirming + server_name = "mosaic.woltje.com" + This becomes part of every agent identity (e.g. @mosaic_coordinator-1:mosaic.woltje.com) + and every room alias. It CANNOT be changed later without re-homing every identity + (new MXIDs for all agents, recreating all rooms). There is no in-place rename in Matrix. + topology.mode = "split-domain" — changing modes later is a reinstall. + secrets.backend = "vaultwarden" — switching backends later requires a secret migration. + Type the server_name to confirm you understand it is permanent: ____________ +``` + +The operator must **retype `server_name`** to proceed — a deliberate friction gate on the one truly permanent value. + +**6.8 — Provision & validate (no success claim until proven).** The installer then: + +1. Renders Synapse config + delegation artifacts; brings up Synapse with `enable_registration: false`. +2. Runs the ACME flow; obtains cert(s); verifies they're valid and installed. +3. Authenticates to the SecretBackend; stores `hs_token`/`as_token`, enroll bootstrap; runs `health()`. +4. **Reachability & cert validation** appropriate to mode: + - Mode A: fetch our own `.well-known`/SRV, confirm it points at host; TLS-probe host cert as a peer would; confirm C-S discovery. + - Mode B: TLS-probe the single domain; confirm C-S + (if federation) S2S. + - Mode C: confirm local C-S reachability over the bind IP; confirm (self-signed or private-CA) client TLS; **explicitly report "standalone — federation not available."** + - If federation enabled: validate each peer resolves + presents a peer-trusted cert; confirm `domain_whitelist` mutual consistency. If any peer fails, **federation is reported NOT-ready** — the primary still succeeds standalone. +5. **Only now** declare success, with a per-capability report: `PRIMARY: ✅ | CERT: ✅ (expires in 90d, auto-renew on) | SECRETS: ✅ (vaultwarden) | FEDERATION: ✅ 1 peer / ⚠ not-ready / ⛔ n-a (Mode C)`. + +**6.9 — Post-install.** Emit the cert-expiry monitor + federation-health probe (§3.4) into OTEL; write config to DB with correct mutability flags; print the immutable-values summary again for the record. + +--- + +## 7. Standalone → federated upgrade path + +An operator who started standalone later wants federation. The path depends on **whether they started with a domain**: + +**Case 1 — started Mode A or B (had a domain), federation was just off.** _Cheap, no re-home._ `server_name` is already a real domain and identities are already minted against it. To federate: + +1. Ensure DNS resolves for peers (their `server_name`s and yours) — likely already true. +2. Ensure a **valid, peer-trusted cert** on the federation endpoint (if they were running client-only self-signed, they now need a real ACME cert; if already ACME, done). +3. Set `federation.enabled = true`, populate `domain_whitelist` + `peers` (all runtime-tunable, §5.3). +4. Re-run the installer's **federation validation** (§6.8 step 4) against each peer. On green, federation is live. **No identity change** — existing MXIDs simply become reachable cross-site. This is the intended, low-friction upgrade. + +**Case 2 — started Mode C (IP-only), now wants federation.** _Expensive — an identity re-home, and we say so plainly._ Federation requires DNS + a valid cert (§2.4), which an IP `server_name` can never satisfy. So the operator must: + +1. **Acquire a domain** and DNS, and provision a **valid ACME cert** (LE public, or step-ca if the domain is private — but note a _private_ domain can only federate with peers who trust that private CA root, §8). +2. **Change `server_name` from the IP literal to the domain** — this is the **install-immutable value**, so this is a **re-home, not a config tweak**: + - Every agent identity `@mosaic_*:192.168.1.50:8448` becomes `@mosaic_*:newdomain` — **all new MXIDs.** + - Every room + alias must be **recreated** under the new `server_name`. + - Signed-authorship pubkey records re-published under the new identities. + - Any durable references to old MXIDs (escalation policies, fallback-coordinator targets, RFC-001 §5) must be re-pointed. +3. Effectively: **treat it as a fresh install in Mode A/B with a data migration of rooms/history**, not an in-place flip. Matrix has **no in-place `server_name` rename**; this cost is intrinsic to Matrix, not to our design. + +**Honest guidance the installer gives Mode C operators up front (§6.3):** "If there is _any_ chance you'll want to federate later, start with a domain (Mode A/B) even if you keep federation off — flipping federation on later is free, but changing an IP `server_name` to a domain later is a full identity re-home." This lets an informed operator avoid the expensive path by choosing Mode B-with-federation-off instead of Mode C. + +--- + +## 8. Security + +**8.1 — Cert trust model per CA choice.** + +- **Let's Encrypt (public):** chains to a universally-trusted root (ISRG). Peers, humans' browsers, and Element trust it with no extra distribution. Best for public domains; nothing to distribute. +- **step-ca (private):** chains to a **root you operate**. Nothing trusts it by default. Therefore the **step-ca root must be distributed** to everyone who validates certs: peer homeservers (so cross-site S2S validates — a peer must add your root to its federation trust store, **[VERIFY]** Synapse's mechanism for trusting a custom federation CA), agent hosts, and any human client. This is the price of "total control" and airgap capability. For **federation between two private-CA sites**, both sites must trust each other's roots (or a shared root). Getting this wrong reproduces the silent-defederation failure (§3.4) — a peer that doesn't trust your root silently refuses your S2S. +- **Mode C self-signed client TLS:** weakest — see 8.4. + +**8.2 — Federation whitelist.** `federation_domain_whitelist` is a **hard allowlist** (RFC-001 §6/NG5): only listed Mosaic site domains may federate; no public-network federation. The installer/config validator keeps the whitelist consistent with the declared peer list. Adding a peer is an explicit, audited config change. + +**8.3 — Secret-backend auth.** The appservice/orchestrator authenticate to the `SecretBackend` via a **bootstrap credential injected at deploy** (Vault AppRole secret_id, or Vaultwarden org automation principal), never committed, consistent with existing Gateway/DB secret handling (RFC-001 §8). The `as_token`/`hs_token` live **only** in backend + appservice memory; agents get only their own scoped, re-mintable token (§4.2). Enroll is authenticated (RFC-001 B5) so a rogue local process can't enroll a rogue agent. Backend access is scoped: an agent's credential can read only its collection/policy, never the fleet-admin scope holding the crown jewels. + +**8.4 — Honest note: IP-only standalone with self-signed client TLS is a weaker-trust local mode.** In Mode C, client TLS may be self-signed (or a local private CA). This means: no third party vouches for the endpoint; clients must be told to trust the self-signed cert (TOFU or manual root import); there is no external validation of who's on the other end. This is **acceptable and supported for local/airgapped/homelab** use where the network is already trusted, but it is **explicitly a weaker trust posture** than a real CA. The installer states this plainly at install (§6.8 Mode C). It is one more reason Mode C cannot federate: we will not extend this weaker-trust local posture across sites (NG5). + +**8.5 — Homeserver hardening** (inherited from RFC-001 §8, config-rendered here): `enable_registration: false` always (agents come only via the appservice), rate-limiting on, admin API bound to localhost/behind auth, media repo locked/disabled if unused, TLS terminated at our controlled proxy. These are **rendered from config** (§5.4) so a stranger gets them by default, not by remembering to set them. **[VERIFY]** current recommended Synapse hardening flags at implementation. + +--- + +## 9. How RFC-002 integrates with RFC-001's P1–P5 + +RFC-002 is the **substrate**. Each RFC-001 phase consumes a subset of it. Critically, **P1 does not need the hard parts** — presence ships on a single clean-domain instance with no federation, no IP-only, and no secret-rotation story resolved. + +| RFC-001 phase | RFC-002 pieces it NEEDS | RFC-002 pieces it does NOT need yet | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **P1 — Presence (first shippable slice)** | **Mode A or B, single-instance, clean domain** (§2.3). One ACME cert (LE or step-ca) via the single integration (§3). Config system minimal: `server_name` immutable + presence thresholds tunable (§5.3). Installer path 6.1–6.3, 6.4 (cert), 6.7 (immutable gate), 6.8 (validate single-instance). A **minimal** SecretBackend just holding the appservice token. | **No federation** (§2.4 gate irrelevant — single site). **No IP-only** needed for P1 (P1 wants a clean domain so Element/humans connect cleanly). **No agent-credential rotation** maturity. **No peer/whitelist** config. Vaultwarden machine-account [VERIFY] does **not** block P1. | +| **P2 — Appservice + auto-enroll** | Full `SecretBackend` **`enrollAgent`/`revokeAgent`** (§4.1), `hs_token`/`as_token` custody (§4.2), enroll-bootstrap secret. Config: room taxonomy, per-agent token classification. Chosen backend (Vault or Vaultwarden) real. | Federation, cross-site, IP-only. | +| **P3 — MACP v1 spec** | Config keys for MACP versioning/thresholds as runtime-tunable (§5.3); nothing new topology-wise. | Federation, secret rotation-in-anger. | +| **P4 — Federation** | **The whole federation half of RFC-002:** Mode A delegation (§3.3), the hard DNS+cert gate (§2.4), `domain_whitelist`+peers config, cert monitoring/silent-defederation alarm (§3.4), per-CA trust distribution for cross-site (§8.1). This is where standalone→federated (§7 Case 1) and Jason's `mosaic.woltje.com`↔`mosaic.uscllc.com` shape land. | IP-only (federation excludes it by construction). | +| **P5 — Hardening + signed-authorship + Hermes retired** | Secret **rotation runbooks** executed in anger (§4, RFC-001 E2), pubkey-record custody for Ed25519 (§4.2), cert-rotation runbook (§3.4), full homeserver hardening validated (§8.5), backend auth review (§8.3). | — | + +**One-line integration statement:** P1 rides on the _smallest_ slice of RFC-002 (single clean-domain instance + one cert + minimal config + minimal secret storage); the federation/IP-only/backend-maturity complexity is deferred to exactly the phases that need it (mostly P4/P5). RFC-002 therefore does not gate P1. + +--- + +## 10. Open questions + +Deliberately few — most topology/cert/secret decisions are resolved by Jason's rulings and baked in above. + +1. **[VERIFY] IP-only `server_name` acceptance.** Does the deployed Synapse version accept an `ip:port` `server_name` and mint usable MXIDs for standalone (§2.3)? If not, Mode C uses a fabricated local domain (`mosaic.local` via local resolver) — confirm which, since it affects the re-home wording for Mode C→A/B (§7). +2. **[VERIFY] Vaultwarden machine-account maturity.** Confirm the current Vaultwarden version's Secrets Manager / machine-account coverage (§4.4). Determines whether the Vaultwarden adapter's `enrollAgent` uses native machine accounts or the personal-vault-per-agent + collection model. Does **not** block (interface absorbs either), but sets P2 expectations. +3. **[VERIFY] step-ca root distribution for cross-site federation.** Confirm Synapse's supported mechanism for trusting a **custom federation CA root** (§8.1) so two private-CA sites can federate. If Synapse won't easily trust a private federation CA, private-domain federation may in practice require public certs (LE) on the federation SANs even when internal traffic uses step-ca. +4. **Default secret backend for the published installer.** Given the open-source ethos (G6), should the installer _default-suggest_ Vaultwarden (free, self-hostable) while clearly offering Vault, or present them neutrally? Recommendation: suggest Vaultwarden as the zero-cost path with the maturity caveat surfaced, Vault as the "I need native short-TTL machine creds now" path. Jason to confirm the framing. +5. **Single multi-SAN cert vs two certs in Mode A** (§3.3) — operational preference for identity-domain + host coverage. Minor; validate during P4. +6. **Reconfigure-time Synapse restart policy.** Which rendered-config changes (§5.4) require a Synapse restart vs hot-reload on the deployed version, so the config service knows when a tunable change needs a bounce. **[VERIFY]** at implementation. + +--- + +## Appendix A — Real mechanics this RFC leans on (quick reference) + +- **`server_name`** — Synapse identity domain; the `:suffix` of every MXID/alias; install-immutable; changing it = re-home (no in-place rename). Distinct from where Synapse _listens_. +- **Delegation** — `https:///.well-known/matrix/server` → `{"m.server":"host:port"}` and/or `_matrix._tcp.` **SRV**; how identity-domain ≠ host is reconciled (Mode A). **[VERIFY]** precedence on deployed Synapse. +- **`.well-known/matrix/client`** — C-S discovery so agents/Element find the homeserver host from the identity domain. +- **`federation_domain_whitelist`** — Synapse allowlist; only listed domains federate; our hard no-public-federation boundary. +- **ACME** — single provisioning protocol for both CAs; operator supplies **directory URL** (step-ca vs Let's Encrypt) + **challenge type**. +- **HTTP-01 / DNS-01 / TLS-ALPN-01** — challenge types; **DNS-01 is the one for private/split-horizon/wildcard**; HTTP-01 for public single host with :80; TLS-ALPN-01 for :443-only public. +- **step-ca ACME provisioner** — Smallstep's self-hosted CA exposing an ACME directory; enables private/internal-domain certs and total control; may require **EAB**; root must be distributed to validators. +- **Let's Encrypt** — public ACME CA; universally-trusted chain; 90-day certs; staging endpoint for testing. +- **Bitwarden/Vaultwarden org + collection + machine/service-account** — org holds collections; collections scope access; machine/service accounts (Bitwarden **Secrets Manager**) are the clean automation primitive but **Vaultwarden coverage is partial/evolving [VERIFY]**; personal-vault-per-agent + org-collection works today. +- **Vault KV v2 / AppRole / lease-TTL / revoke** — the capable backend; native versioning=rotate, TTL+revoke=transient per-agent creds. +- **Silent defederation** — a lapsed/renewal-failed federation cert causes peers to stop trusting S2S with no local error; must be monitored + alarmed (§3.4). + +_All Matrix/ACME/secret-backend mechanics above are cited from architecture knowledge and MUST be re-verified against the actually deployed versions during implementation — every **[VERIFY]** is a checkpoint, not an assumption. Every illustrative domain (`mosaic.woltje.com`, `mosaic.uscllc.com`, `matrix.woltje.com`) is an operator-supplied example, never a product default or literal._ diff --git a/docs/scratchpads/1000-rm-61-ci-contract-exemption.md b/docs/scratchpads/1000-rm-61-ci-contract-exemption.md new file mode 100644 index 00000000..16e1a7ab --- /dev/null +++ b/docs/scratchpads/1000-rm-61-ci-contract-exemption.md @@ -0,0 +1,118 @@ +# RM-61 — CI contract exemption for #1000 teardown artifact + +**Tracking:** RM-61 / issue #1000 + +**Branch:** `fix/rm-61-ci-contract-exemption` +**Owner:** `coder-mos1` + +## Objective + +Determine, by red-first provider controls, whether the `ci-postgres` pod-not-found teardown signature discriminates from a real PostgreSQL failure. Only if it discriminates may a named, bounded CI-contract exemption be implemented. The exemption must retire when #1000 is fixed; fixing #1000 is the closure path. + +## Pre-registered kill criterion + +If an injected real `ci-postgres` failure also yields `pods "wp-svc--ci-postgres" not found` as the service's provider-visible failure, the signature does not discriminate. Option B is unsafe; stop exemption implementation and fall to Option A (#1000). + +## Plan + +1. Capture full `-f json` records for the 11 supplied observations and state counts. +2. Run one startup-failure control using the real pgvector/PostgreSQL image with an invalid `initdb` argument. +3. Run one post-readiness crash control using real PostgreSQL, `pg_isready`, and a deliberate postmaster kill while a DB-dependent probe is active. +4. Compare the raw `ci-postgres` service record independently of failures in dependent steps. +5. Investigate runner/time/head clustering only as a hypothesis; never encode incidental correlates or retries into policy. +6. If and only if the controls discriminate, implement and test the exact exemption, document its two-way boundary, and track retirement at #1000. + +## Budget + +No explicit token cap supplied. Working estimate: 20K–30K tokens. Limit provider controls to the two pre-registered runs; no retries or re-roll policy. + +## Initial evidence + +Historical JSON saved locally under `.evidence/rm-61/` (not for commit). Supplied pipelines: 11 total. Child-step counts: five pipelines with 9 children and six with 10 children. Seven contain the `ci-postgres` pod-not-found failure (#2170, #2175, #2180, #2181, #2182, #2187, #2188); four do not (#2158, #2167, #2184, #2186). Every observed workflow reports `agent_id=44`, so the available JSON does not separate clean and artifact runs by runner. This refutes runner identity as a discriminator in the sampled record. + +## Progress + +- [x] Requirements and kill criterion recorded before control implementation. +- [x] Historical full-JSON records captured. +- [x] Startup-failure control observed terminal. +- [x] Post-readiness crash control observed terminal. +- [x] Discrimination verdict recorded: Option B may proceed. +- [x] Conditional exemption implementation. + +## Tests / evidence + +### Control 1 — real startup failure + +- Commit: `3931b0e29eb834914f7b17e4db7e221481d436fa` +- Pipeline: #2189, exact commit match. +- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running. +- `ci-postgres`: `state=failure`, `exit_code=1`, `error=null`, with a five-second execution window. +- `test`: `state=failure`, `exit_code=1` after the readiness budget expired. +- Pipeline/workflow: terminal `failure`. + +This control is red and its service record differs from #1000 (`exit_code=0` plus pod-not-found). It proves the startup-failure direction only. It does not settle the dangerous post-readiness crash/garbage-collection path. + +### Control 2 — real post-readiness crash + +- Commit: `25ac59715a94dd1b52ef42577472eb44ecc4b446` +- Pipeline: #2191, exact commit match. +- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running. +- Service log proves PostgreSQL reached `database system is ready to accept connections`, the test created the arm table, and the service then killed postmaster PID 7. +- Test log proves a successful `SELECT 1` followed by `Connection refused`; it exited the pre-registered control code 61. +- `ci-postgres`: `state=failure`, `exit_code=137`, `error=null`, with a 203-second execution window. +- `test`: `state=failure`, `exit_code=61`. +- Pipeline/workflow: terminal `failure`. + +This is the dangerous post-readiness crash path. Its service record is not pod-not-found and therefore differs from #1000 independently of the dependent test failure. + +### Discrimination verdict + +Both real failures are provider-visible as process exits (`exit_code=1` startup; `exit_code=137` crash) with no pod-not-found error. The seven observed #1000 artifacts are provider reconciliation misses (`exit_code=0` plus the exact pod-not-found error). The declared kill criterion did not fire, so Option B may proceed with a matcher requiring the full conjunction. This evidence does **not** prove every future Kubernetes failure is distinguishable; it proves these two concrete real-failure classes remain blocking and bounds the exemption to the observed reconciliation shape. + +### Unit red-first checkpoint + +The nine-case contract harness was written before the verifier. First execution exited 1 because `verify-terminal-green.py` did not exist; no exemption implementation was live. Cases pre-register ordinary green, the exact artifact, both provider controls, near-miss signatures, an independent failure, and a skipped step. + +### Control 2 setup attempt — invalid, excluded from evidence + +- Commit: `9455cd6a2650b2b7e70f746c07933d96e5cb3d20` +- Pipeline: #2190, exact commit match. +- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running. +- Service log: `/bin/sh: 0: -c requires an argument`. +- Root cause: Woodpecker service `commands` did not become the third `sh -c` argument. PostgreSQL never started, so this run is **not** the post-readiness crash control and provides no discrimination evidence. +- Focused remediation: place the script directly in the third `entrypoint` element and supply `PGPASSWORD` for the marker query. This is a control-fixture correction, not a retry of #1000 and not evidence for either verdict. + +## Implementation evidence + +- `verify-terminal-green.py` consumes only the full JSON/API record; it performs no fetch, retry, or trigger. +- Exact #2188 record: exit 0, 10 children, 9 success + 1 named exemption. +- Historical set: #2158/#2167/#2184/#2186 pass with no exemption; #2170/#2175/#2182/#2187/#2188 pass with one named exemption; #2180/#2181 remain red because independent failures exist. +- Provider controls: #2189 and #2191 both exit 1 under the verifier; neither is exempted. +- Unit harness: initial 9/9 cases passed after the red-first checkpoint; review remediation expands this to 12 cases with expected-head match/missing/mismatch coverage. +- Test-membership guard: PASS, population 45; 26 enumerated, 19 signed exclusions; all 39 surface paths present. +- Python compile: PASS. +- `pnpm typecheck`: PASS, 45/45 tasks. +- `pnpm lint`: PASS, 25/25 tasks. +- `pnpm format:check`: PASS after moving local evidence outside the repository tree. +- `test:framework-shell`: RM-61 and all preceding suites passed, then the pre-existing wake assertion aborted with exit 97 because this host's Bash 5.2.15 reports `BASH_LINENO [3 5]` where that suite requires `[3 4]`. RM-61 does not modify the wake suite; the command is not fully runnable on this host as written and no substitute result is claimed. + +## Independent review + +- Review 67 / comment 20403 at exact head `e7b29219e11efd0a19395156ac0b154bec0c3a73`: **REQUEST CHANGES**. +- Blocker: the verifier echoed the pipeline commit but did not bind it to the current PR head; mutating only #2188's commit still returned terminal-green. +- Remediation: require `--expect-commit `, add a pipeline anomaly on missing/mismatched record commits, emit expected and observed values, wire both CI documentation and the merge-gate baseline to pass provider PR head, and add match/missing/mismatch tests. +- This binding is not prohibited head-based clustering policy: it proves the evidence belongs to the commit under verdict. Runner/node/time/head correlation remains excluded from the teardown signature itself. +- Review 69 later approved the commit-binding remediation at exact head `033b2ffb46674b2c0bcc5197273c109b461f62d9`; pipeline #2193 was 9/9 success. Before merge-gate, an independent adjudicator found that Python treats JSON `false == 0`, allowing a non-integer exit value to match. The prior gate-ready state was withdrawn. The type-strict set distinguishes genuine red-first controls (`false`, `0.0`, which wrongly exempted) from regression guards (`true`, `"0"`, `null`, which already blocked). Remediation requires the decoded type to be exactly `int` and excludes `bool` explicitly. + +## Documentation checklist + +- [x] CI contract documented in the canonical framework CI/CD guide. +- [x] Operator command documented in the Woodpecker tool README. +- [x] Merge-gate baseline points to the deterministic verifier and named retirement. +- [x] Tracking and retirement cite issue #1000. +- [x] Both positive and negative guarantee boundaries are stated. +- [x] No API/auth/schema/user-facing navigation change; OpenAPI, user guide, and sitemap are not applicable. + +## Risks + +The controls establish discrimination for deterministic startup failure and an armed post-readiness postmaster crash on the current Woodpecker Kubernetes provider. They cannot prove that every future Kubernetes failure mode will preserve a non-zero exit before reconciliation. The exact matcher minimizes that residual risk, and issue #1000 remains the mandatory provider-seam closure and retirement trigger. diff --git a/docs/scratchpads/561-python-is-python3.md b/docs/scratchpads/561-python-is-python3.md new file mode 100644 index 00000000..a81db0e7 --- /dev/null +++ b/docs/scratchpads/561-python-is-python3.md @@ -0,0 +1,33 @@ +# Issue #561 — Bare python on agent hosts + +## Objective + +Make the durable bootstrap/provisioning guidance ensure agent hosts provide a bare `python` command that resolves to Python 3. + +## Scope + +- Add Debian/Ubuntu `python-is-python3` to agent-host prerequisites in bootstrap docs. +- Check for actual OS package provisioning scripts and update only if an existing agent-host package install path exists. +- Do not touch live host state. +- Do not update `docs/TASKS.md`; repo guidance says workers read it but never modify it. + +## Recon + +- Issue #561 confirms repeated `python: command not found` failures from fleet agents that emit `python foo.py`. +- `guides/BOOTSTRAP.md` and `packages/mosaic/framework/guides/BOOTSTRAP.md` are the source and packaged framework copies of the bootstrap guide. +- Targeted repo sweep found no agent-host Debian package provisioning script. Existing `apt-get install` hits are CI/test helper paths or unrelated deployment docs. + +## Plan + +1. Add a host prerequisite section to both bootstrap guide copies. +2. Include `python-is-python3` in the Debian/Ubuntu package list with an issue comment. +3. Note the non-Debian equivalent as a `/usr/bin/python -> python3` symlink. +4. Validate markdown/diff, run shell syntax checks where applicable, run required review, commit, queue guard, and push. + +## Validation Log + +- `rg` recon: no existing agent-host Debian package provisioning script; only CI/test helper `apt-get install` paths and unrelated deployment docs. +- `git diff --check`: passed. +- `bash -n packages/mosaic/framework/install.sh tools/install.sh packages/mosaic/framework/tools/bootstrap/init-project.sh packages/mosaic/framework/tools/_scripts/mosaic-bootstrap-repo`: passed. No touched shell scripts. +- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`: approved, 0 findings. +- `pnpm format:check`: initially blocked because `node_modules` was absent and `prettier` was unavailable; `pnpm install --frozen-lockfile` initially hit an invalid `/root` pnpm store path. Reran install with `--store-dir /home/hermes/agent-work/.pnpm-store`, then `pnpm format:check` passed. diff --git a/docs/scratchpads/703-wrapper-interactive-auth.md b/docs/scratchpads/703-wrapper-interactive-auth.md new file mode 100644 index 00000000..cbc37205 --- /dev/null +++ b/docs/scratchpads/703-wrapper-interactive-auth.md @@ -0,0 +1,49 @@ +# #703 Git Wrapper Interactive and Auth Resilience + +## Objective + +Restore the deployed Git wrapper contract: issue-create supports interactive invocation and Gitea mutation behavior tolerates a stale Tea authenticated user by validating current identity and using the existing host-scoped API fallback. + +## Scope + +- `packages/mosaic/framework/tools/git/issue-create.sh` +- `packages/mosaic/framework/tools/git/detect-platform.sh` +- Git wrapper regression harnesses +- This scratchpad + +## Requirements / acceptance evidence + +1. `issue-create -i` and `--interactive` prompt for missing issue fields without exposing credentials. +2. Explicit command-line fields retain precedence and do not trigger prompt input. +3. Gitea wrapper resolves the current user dynamically from the target host and does not rely on the saved Tea user identity. +4. A Tea `GetUserByName` failure falls back to authenticated API creation. +5. Existing body-safety, login-resolution, issue-create, and pr-create paths remain green. +6. Source framework is re-seeded to deployed `~/.config/mosaic`, then deployed wrappers are verified end to end. + +## Plan + +1. Add failing shell regression harness for interactive input and stale Tea user fallback. +2. Implement minimal helper and parser changes. +3. Run wrapper harnesses, syntax checks, and repository baseline checks. +4. Re-seed deployed framework and run live wrapper verification. +5. Commit, queue guard, push, open PR, and stop for independent review. + +## Progress + +- Issue #703 filed before code; issue comment records #536 root cause and stale-login trigger. +- Deployed wrapper `issue-create.sh -i` reproduced: `Unknown option: -i` (exit 1). +- Live Tea mutation did not reproduce `GetUserByName` on this host because the current mosaicstack Tea login is valid. The test harness models the reported stale authenticated-user condition. +- Implemented `-i` / `--interactive` prompt collection and a dynamic Tea `/user` validation. A stale Tea identity now selects the existing host-scoped Gitea API fallback before mutation for both issue and PR creation. +- Re-seeded the framework with `MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash packages/mosaic/framework/install.sh`. Installed and source wrapper SHA-256 values matched. +- Live deployed verification: interactive issue-create opened then closed #704; installed dynamic identity resolved `jason.woltje`. + +## Verification + +- PASS: `packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh` +- PASS: `packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh` +- PASS: `packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh` +- PASS: `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` +- PASS: `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh` +- PASS: `bash packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh` +- PASS: `bash -n packages/mosaic/framework/tools/git/*.sh` +- PASS: Prettier check for this scratchpad diff --git a/docs/scratchpads/747-wsa-dehardcode.md b/docs/scratchpads/747-wsa-dehardcode.md new file mode 100644 index 00000000..655200bb --- /dev/null +++ b/docs/scratchpads/747-wsa-dehardcode.md @@ -0,0 +1,43 @@ +# #747 — De-hardcode orchestrator and interaction agent names + +## Objective + +Replace branded Mos/Tess symbols, filenames, DI tokens, and error prose with role-neutral orchestrator/interaction vocabulary without changing env-driven runtime identity behavior. Add optional roster `alias` and `provider` fields and show aliases in `mosaic fleet ps` with name fallback. + +## Scope and constraints + +- Requirements: `/home/hermes/agent-work/reviews/747-wsa-dehardcode-brief.md`. +- Branch: `feat/747-dehardcode-orchestrator-interaction-names` from `main` at `e72388b2`. +- Keep `MOSAIC_AGENT_NAME` and `MOSAIC_ORCHESTRATOR_AGENT_NAME` unchanged. +- Sample/test data may retain operator display names. +- No behavior change beyond optional roster metadata and alias display. +- Budget: no explicit cap; conservative mechanical-rename scope only. +- TDD: optional and skipped because this is a mechanical rename with existing focused coverage; add focused alias/schema regression coverage before completion. + +## Plan + +1. Rename coordination and durable-session files and symbols using canonical vocabulary. +2. Scrub branded symbol names and error prose in the assigned source trees while preserving allowed sample data. +3. Extend roster schema with optional `alias` and `provider`; update fleet roster typing/rendering and focused tests. +4. Run grep-clean verification, build, typecheck, lint/format, focused coord/durable-session/fleet tests, and roster validation. +5. Commit, queue-guard, push, open a Gitea PR closing #747, and report to the coordinator. + +## Progress + +- 2026-07-13: Task resumed from coordinator brief; repository clean at `e72388b2`. +- Renamed coordination and durable-session files, exports, gateway DI symbols, DTOs, services, repositories, and tests. +- Replaced branded authority/error prose while preserving the existing `/api/coord/mos` compatibility route and env-variable identity inputs. +- Added optional roster `alias`/`provider` support and alias-first `fleet ps` display with canonical-name fallback. + +## Verification + +- `pnpm typecheck`: passed (42 tasks). +- `pnpm build`: passed (23 tasks). +- `pnpm lint`: passed (23 tasks). +- `pnpm format:check`: passed. +- Coordination tests: 7 passed. +- Agent durable-session/runtime tests: 23 passed. +- Gateway coordination/durable-session/integration tests: 20 passed. +- Full `fleet.spec.ts`: 192 passed, including alias/provider parsing and alias display. +- JSON Schema 2020 validation: legacy minimal roster and extended alias/provider roster passed; `alias` and `provider` remain absent from `required`. +- Grep verification: no branded symbol/type/file/DI names or error prose remain in assigned source trees; one allowed `Tess Owner` test-data display name remains. diff --git a/docs/scratchpads/751-native-kanban-canon.md b/docs/scratchpads/751-native-kanban-canon.md new file mode 100644 index 00000000..d041535f --- /dev/null +++ b/docs/scratchpads/751-native-kanban-canon.md @@ -0,0 +1,152 @@ +# Issue #751 — Native Kanban/SOT canonical publication + +## Objective + +Publish the owner-ratified P0–P3 requirements, mission manifest, task decomposition, and frozen shared contracts before feature implementation. + +## Authority and decisions + +- Owner: Jason +- Plan owner/orchestrator: web1 control plane; takeover by mosaic-100 during Claude quota outage +- Tracking: Mosaic Stack issue #751 +- Foundation: current Stack main + Drizzle/PostgreSQL +- Fixed invariants: PostgreSQL sole writable SOT; writes fail closed; exports never import; outage notes become attributable proposals; mechanical Coordinator has no scope/gate/certify/merge authority; Certifier has no merge authority. +- Recovery posture only is configurable through Lite, Standard, and High-assurance profiles. + +## Execution log + +- 2026-07-14: Existing planner-sol canon remediation reviewed from staging. KCR-001–016 claimed resolved; static checks passed. +- 2026-07-14: Independent GPT/Terra re-review dispatched to rev1. +- 2026-07-14: Re-review returned NO-GO: proposal audit-event IDs were not workspace-bound, leaving attribution forgeable; formatter evidence was not reproducible. Focused remediation round 2 routed to planner-sol. +- 2026-07-14: Remediation bound proposal audit links to `task_events(workspace_id,id)`, froze same-transaction semantic validation and negative tests, and made formatter/type/static checks reproducible. +- 2026-07-14: Independent rev1 re-review returned GO with KCR-001–016 closed and no new blocker. Canon copied into the issue #751 publication worktree; feature implementation remains held until merge. +- 2026-07-14: Independent publication validation returned FAIL on formatting/trailing whitespace, stale staging wording, ignored review evidence, and missing worktree dependencies. Bounded publication remediation routed to planner-sol; no runtime source change authorized. +- 2026-07-14: Publication remediation installed locked dependencies outside the repository cache, fixed formatting and wording, and preserved docs-only scope. Independent gaterun revalidation returned PASS across staged scope, formatting, lint, typecheck, strict contract compile, links, rollups, review artifacts, and fixed invariants. +- 2026-07-14: Ultron final gate returned GO with zero BLOCKER/HIGH findings; residual LOW items remain explicit implementation obligations. +- 2026-07-14: First commit attempt was correctly blocked by the lint-staged hook because docs contract `.ts` files were outside TypeScript project-service scope. Added a strict no-emit workstream `tsconfig.json` with exact Drizzle declaration paths; targeted contract TSC, contract ESLint, format, full lint/typecheck, strictness, and docs-only scope independently passed. + +## Verification evidence + +- Initial review: `docs/reports/native-kanban-sot/canon-initial-review-no-go.md` +- Final GO: `docs/reports/native-kanban-sot/canon-final-rereview-go.md` +- Ultron GO: `docs/reports/native-kanban-sot/ultron-final-go.md` +- Pending: PR gates, squash merge, terminal-green CI, and issue closure. + +## Publication remediation session — planner-sol + +**Assignment:** Remediate only issue #751 publication blockers from `/home/hermes/agent-work/reviews/751-kanban-canon-publication-validation.md`; no source, package, lock, provider, CI, build, deploy, commit, or push action. + +**Plan:** + +1. Install the locked pnpm dependencies without modifying package metadata. +2. Replace only stale publication/staging wording while retaining the canon-merge and KBN prerequisite implementation hold. +3. Format all changed candidate Markdown and contract TypeScript; remove trailing whitespace. +4. Run worktree `format:check`, `lint`, `typecheck`, strict no-emit contract compilation, tracked/untracked whitespace checks, and static invariants. +5. Verify package/lock/source paths are unchanged and append exact evidence here. + +**Budget:** No explicit token cap; bounded docs-only remediation, no exploratory/runtime work. +**TDD:** Not applicable—documentation/contract-publication formatting and wording only; strict/static validation is the primary gate. + +## Publication remediation results + +### Changes + +- `docs/native-kanban-sot/INDEX.md`: replaced staging/pending-GO wording with current publication and independent-GO wording; retained the merge hold and dependency-ordered KBN prerequisite hold. +- `docs/native-kanban-sot/TASKS.md`: replaced “Mos using this staging set” with “Mos / publication control plane”; made the post-merge KBN prerequisite hold explicit. +- Formatted all changed candidate Markdown and four contract TypeScript files with current-worktree Prettier 3.8.1. +- Removed trailing whitespace from candidate Markdown, including both linked review reports. +- Preserved both review reports and their links; they remain ignored by `.gitignore:11` for coordinator force-tracking. + +### Dependency installation + +The first target-worktree install attempt used the environment's default root-owned pnpm store and failed without changing package metadata: + +```text +cd /home/hermes/agent-work/stack-kanban-canon && pnpm install --frozen-lockfile +EACCES: permission denied, open '/root/.local/share/pnpm/store/v10/server/server.json' +``` + +Successful locked install using an authorized cache outside the repository: + +```bash +cd /home/hermes/agent-work/stack-kanban-canon +pnpm install --frozen-lockfile --store-dir /home/hermes/agent-work/pnpm-store +``` + +Result: PASS, 1,240 packages installed; lockfile resolution skipped as up to date. `node_modules` remains ignored. Tool versions: pnpm 10.6.2, Prettier 3.8.1, TypeScript 5.9.3, Drizzle ORM 0.45.1, Turbo 2.8.16. + +### Exact quality-gate results + +```text +pnpm format:check +PASS — All matched files use Prettier code style. + +pnpm lint +PASS — 23 successful lint tasks. + +pnpm typecheck +PASS — 42 successful tasks. Turbo invoked configured dependency build prerequisites as part of the repository's exact typecheck graph; no standalone build command was run. +``` + +Candidate formatting commands: + +```bash +pnpm exec prettier --write <3 tracked rollups + 9 native-kanban artifacts + requirements + scratchpad> +pnpm exec prettier --check +pnpm exec prettier --ignore-path /dev/null --write \ + docs/reports/native-kanban-sot/canon-initial-review-no-go.md \ + docs/reports/native-kanban-sot/canon-final-rereview-go.md +pnpm exec prettier --ignore-path /dev/null --check \ + docs/reports/native-kanban-sot/canon-initial-review-no-go.md \ + docs/reports/native-kanban-sot/canon-final-rereview-go.md +``` + +Result: PASS. The explicit `/dev/null` ignore path is required because `docs/reports/` is intentionally ignored pending coordinator force-tracking. + +Tracked and untracked whitespace checks: + +```text +git diff --check +PASS + +git diff --no-index --check /dev/null +PASS for all candidates +``` + +Strict contract compilation initially could not resolve pnpm-isolated `drizzle-orm` from the external docs directory. A temporary, removed dependency-context symlink made current-worktree resolution explicit: + +```bash +LINK=docs/native-kanban-sot/node_modules +ln -s ../../packages/db/node_modules "$LINK" +trap 'unlink "$LINK"' EXIT +pnpm exec tsc \ + --noEmit \ + --strict \ + --skipLibCheck \ + --target ES2022 \ + --module NodeNext \ + --moduleResolution NodeNext \ + docs/native-kanban-sot/contracts/*.ts +``` + +Result: `strict-contract-noemit=PASS`; temporary link removed. + +Static result: + +```text +proposal-audit-links=PASS +kcr-invariant-regression=PASS +publication-wording=PASS +vocabulary-alignment=PASS +``` + +### Scope-integrity evidence + +Baseline and final hashes are identical: + +```text +package.json 93a50eaefc7a0446a56234e427df03f6a2256f8da17c0bede17c22206928c8c0 +pnpm-lock.yaml 8b6448d51ac7797c8f782af52a080c0e38ab8bf364f32624f94e636bf5743229 +``` + +`tracked-package-lock-source-unchanged=PASS`: every tracked/untracked nonignored change remains under `docs/`; no package, lock, application source, plugin source, configuration, CI trigger, standalone build/deploy, container, provider, commit, or push action occurred. diff --git a/docs/scratchpads/753-kbn010-threat-gate.md b/docs/scratchpads/753-kbn010-threat-gate.md new file mode 100644 index 00000000..18be777b --- /dev/null +++ b/docs/scratchpads/753-kbn010-threat-gate.md @@ -0,0 +1,101 @@ +# Issue #753 — KBN-010 threat, authorization, and constraint-impact gate + +## Objective + +Complete the mandatory threat/auth/constraint-impact analysis that gates KBN-100 schema implementation. + +## Scope + +- In: threat matrix, frozen-control mapping, schema/API/test impact inventory, security evidence plan. +- Out: runtime, schema, migration, API, dependency, CI, deployment, and secret changes. +- Canonical requirements: `docs/requirements/native-kanban-sot.md`. +- Frozen contract: `docs/native-kanban-sot/SHARED-CONTRACT.md` and `contracts/*.v1.ts`. +- Tracking: Mosaic Stack issue #753. + +## Plan + +1. Independently inspect current-main implementation and frozen canon. +2. Enumerate tenant, identity, health-proof, approval, fencing, proposal, audit, token, and outage threats. +3. Map every threat to required constraints, command behavior, negative tests, and owning future slice. +4. Surface any unresolved schema impact as a blocker; do not silently amend the frozen contract. +5. Run documentation/static validation and submit for independent SecReview. + +## Execution log + +- 2026-07-14: KBN-000 completed through PR #752 and post-merge pipeline #1798. +- 2026-07-14: KBN-010 issue #753 created; task marked in progress; fresh GPT worker pending dispatch. + +## Verification evidence + +Pending worker validation, independent SecReview, Ultron gate, PR merge, post-merge CI, and issue closure. + +## 2026-07-14 worker analysis checkpoint + +- Inspected issue #753, canonical requirements, all frozen v1 contracts, and actual `origin/main` at `49e8a54` across DB schema, Better Auth scope, project/task/mission/team repositories/controllers, fleet backlog, and `TASKS.md` parsing/writing. +- Confirmed the worker branch has no source/runtime/schema delta from `origin/main`; orchestrator-owned `.mosaic` state remains dirty and untouched. +- Authored the threat, authorization, constraint-impact, negative-test, and requirements-traceability analysis in `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md`. +- Gate decision: **BLOCKED** by `KBN010-SI-001`. `missionsV1` lacks a unique candidate key on `(workspace_id, id)`, while `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` both reference that exact pair. PostgreSQL cannot create the frozen composite foreign keys as declared. +- Decision: do not select or apply a schema fix. Contract authority must version either a `(workspace_id, id)` mission candidate key or project-congruent child keys, then obtain independent re-review before KBN-100. +- Additional risks are controlled by frozen transaction/API behavior but require the exact future negative tests cataloged in the deliverable, especially active membership, service-token revocation, same-workspace semantic evidence checks, no-oracle behavior, and serialized parent/DAG cycle checks. +- OpenBrain startup recall was attempted but unavailable because `/home/hermes/.config/mosaic/credentials.json` is absent; no project state was written to an alternate memory silo. +- TDD: not applicable; this slice changes documentation/test-plan analysis only and implements no runtime behavior. + +## Validation log + +- `pnpm exec prettier --check docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md docs/scratchpads/753-kbn010-threat-gate.md` — PASS. +- Changed-doc link validator — PASS (`relative_links=0`, one external issue link); `curl -fsSIL https://git.mosaicstack.dev/mosaicstack/stack/issues/753` — PASS. +- The first inline link-validator invocation had a Python f-string syntax error; corrected once and rerun successfully without changing the deliverable. +- `pnpm format:check` — PASS. +- `pnpm lint` — PASS (23 tasks successful). +- `pnpm typecheck` — PASS (42 tasks successful). +- `pnpm exec tsc -p docs/native-kanban-sot/tsconfig.json --noEmit` — PASS. +- Scoped diff review — PASS: authored delta is limited to the exclusive deliverable and this scratchpad; no runtime/schema/config/dependency/CI/deployment file is changed, and `docs/native-kanban-sot/TASKS.md` has no worker worktree delta from tracking commit `9b55de0`. +- Independent SecReview remains pending and cannot return PASS until contract authority resolves `KBN010-SI-001`. +- Final formatting/diff/test-ID completeness review — PASS (all catalog prefixes contiguous with no duplicate IDs). +- Remaining: scoped commit, queue guard, and push. + +## 2026-07-14 KBN010-SI-001 contract-authority amendment + +- Authority: `web1:mosaic-100` directed the minimal rc.4 amendment under issue #753: add a non-partial unique candidate key on `missions(workspace_id, id)` while retaining global `missions.id` uniqueness and the project-congruent `(workspace_id, project_id, id)` key. +- Rationale: artifacts and approval decisions are polymorphic exactly-one-target records and do not consistently carry `project_id`; widening both children would broaden frozen v1 semantics without improving tenant safety. +- Plan: amend only the frozen schema contract and shared contract, freeze exact DDL ordering and future migration negatives, run scoped/full validation and independent review, then commit and push without opening/merging a PR or closing #753. +- TDD: not applicable because this is a design-contract amendment with no runtime schema or migration implementation; rc.4 freezes future executable empty/prod/N-1/rollback/foreign-workspace and duplicate-key-feasibility tests. +- Budget: no explicit cap supplied; use a 20K-equivalent soft working cap and one bounded worker lane. +- Read-only #757 boundary: PR #757 adds separate logical-agent connector lease/CAS fencing (`logical_agent_connector_leases.lease_epoch`) in runtime schema and connector contracts. SI-001 changes only the frozen mission candidate key; it does not consume, alter, or reinterpret connector fencing, task fencing, lease authority, or #757 ownership. + +### Amendment verification evidence + +- Frozen schema: added exactly one non-partial `missions_workspace_id_uidx` on `(workspace_id, id)`; retained the global `id` primary key and `missions_workspace_project_id_uidx` on `(workspace_id, project_id, id)`. +- FK reconciliation: targeted static checks prove the candidate key precedes both `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk`; each continues to reference exact ordered columns `(workspace_id, mission_id)` → `missions(workspace_id, id)` with RESTRICT deletion. +- Shared contract: candidate version is `1.0.0-rc.4`; authority, rationale, exact effect/non-effect, candidate-before-FK DDL order, duplicate feasibility, empty/prod/N-1/rollback/foreign-workspace future tests, and unchanged KCR/SOT/tenant/proposal/fencing/no-cascade invariants are explicit. +- Changed-file Prettier, contract ESLint, `pnpm exec tsc -p docs/native-kanban-sot/tsconfig.json`, and targeted SI-001 static checks — PASS. +- Full `pnpm format:check && pnpm lint && pnpm typecheck` — PASS (23 lint tasks and 42 typecheck/build tasks). +- Independent Codex security review — PASS, zero critical/high/medium/low findings; tenant isolation is preserved. +- Independent Codex code review confirmed the candidate key repairs both FK targets and reported no blocker on the authorized delta. Its initial request to rewrite the historical KBN-010 verdict conflicts with the exclusive scope and was resolved by documenting the immutable-evidence boundary in rc.4; its remaining finding concerns pre-existing live `.mosaic` session files, which are untouched and excluded from this commit. +- Scoped diff: only the two frozen contract files and this append-only scratchpad amendment are staged for delivery; no runtime schema, migration, task plan, gate verdict, provider artifact, or #757-owned file is included. + +## 2026-07-14 final rc.4 KBN-010 disposition + +- Control-plane direction authorized final disposition edits only to `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` and this append-only scratchpad; contract author `web1:kbn-contract` remained idle and undisturbed. +- Exact reviewed contract object: commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`, tree `7ebab8fa530a7180036928cea9527f808548aa14`. +- Corroborating review identities: full-index SHA-256 `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`; stable patch-id `058cf98026fcd1043703c866aee047c8bb144740`. A command-rendered patch digest varied by Git rendering command/options and is non-authoritative; commit+tree+exact file content are canonical. +- Independent Homelab non-author schema/security review verdict: **APPROVE**. It confirmed the rc.4 `missions_workspace_id_uidx(workspace_id,id)` repairs both dependent FKs while retaining the global PK and project-congruent key; tenant, polymorphic exactly-one-target, RESTRICT/no-cascade, N-1/rollback semantics remain valid. +- Read-only #757 cross-check: no shared table, index, FK, identity, fence, or authority collision with connector lease/CAS fencing. +- Final KBN-010 gate decision: **PASS / GO** at rc.4 with `UNRESOLVED SCHEMA IMPACTS` equal to exact `none`. Historical SI-001 detection remains in the gate document as evidence that rc.3 was invalid. +- No runtime schema/migration/API/config/dependency/CI/deployment implementation is claimed. KBN-100 must still implement candidate-before-dependent-FK ordering, duplicate feasibility, empty/prod/N-1/rollback evidence, exact FK reconciliation, and separate artifact/approval foreign-workspace negatives (N100-45..50). +- TDD remains not applicable because this continuation changes only documentation/evidence disposition and no runtime behavior. +- Remaining orchestrator-owned sequence: worker validation/commit/push → PR open/update → Ultron review → squash merge → terminal-green post-main CI → close #753 → release KBN-100. KBN-100 is not released earlier. + +### Final worker validation + +- Changed-doc Prettier and link checks — PASS; issue #753 external link returned successfully. +- Static future-test catalog check — PASS: all prefixes are individually enumerated, contiguous, and duplicate-free; N100 now spans N100-01..50. +- rc.4 disposition assertions — PASS: gate status PASS/GO, frozen target rc.4, exact `none` unresolved section, independent APPROVE, review identities, and N100-50 are present. +- Review identity reproduction — PASS: commit tree `7ebab8fa530a7180036928cea9527f808548aa14`, full-index SHA-256 `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`, and stable patch-id `058cf98026fcd1043703c866aee047c8bb144740` match. +- rc.4 frozen-contract static check — PASS: the candidate key is unique in the declaration and precedes both dependent FKs; frozen contract files remain byte-identical to commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`. +- `pnpm exec tsc -p docs/native-kanban-sot/tsconfig.json --noEmit` — PASS. +- `pnpm format:check` — PASS. +- `pnpm lint` — PASS (23 tasks successful). +- `pnpm typecheck` — PASS (42 tasks successful). +- Scoped diff — PASS: only the gate document and this scratchpad are authored changes; contracts, TASKS, requirements, runtime/schema/migration/config/dependency/CI/deployment and #757-owned files are unchanged. Live `.mosaic` session state remains untouched and excluded. +- Remaining worker steps: final formatting/scoped staging, commit `docs(#753): clear KBN-010 schema gate`, queue guard, push, and control-plane notification. diff --git a/docs/scratchpads/755-mos-logical-identity-fencing.md b/docs/scratchpads/755-mos-logical-identity-fencing.md new file mode 100644 index 00000000..7e18c9fe --- /dev/null +++ b/docs/scratchpads/755-mos-logical-identity-fencing.md @@ -0,0 +1,148 @@ +# Issue #755 — Logical Mos identity and connector lease fencing + +- Task: `MOS-PORT-M1-001` +- Branch: `feat/mos-logical-identity-fencing` +- Base: `origin/main` +- Started: 2026-07-14 +- Working budget: 38K tokens (task ledger estimate); one implementation lane, bounded to M1. + +## Objective + +Implement the first runtime-portability security boundary: normalized logical-agent identity plus a PostgreSQL-durable exclusive connector lease and server-validated fencing grants. + +## Scope + +- Normalized identity contract independent of harness/provider-native session IDs. +- DB migration/schema/repository for one lease per tenant/logical-agent/binding. +- CAS acquire/takeover, monotonic epoch, TTL, heartbeat, release, expiry handling. +- Server-derived grants bound to tenant, logical agent, binding, connector, scopes, expiry, and lease epoch. +- Reject and credential-safely audit stale, expired, forged, unauthorized, cross-tenant, and cross-binding grants before adapter side effects. +- Runtime adapter boundary consumes normalized lease context. +- Unit, migration, close/reopen, concurrency, abuse, and gateway integration tests. +- Required developer/operations documentation for schema and security behavior. + +## Explicit exclusions + +No checkpoint/handoff payloads, exactly-once journal/receipts, concrete Claude/Pi/Codex harness adapter, channel cutover, or full cross-harness failover E2E. + +## Reconciliation baseline + +- Prospective remediation handoff: `web1:coder1`; PR [#757](https://git.mosaicstack.dev/mosaicstack/stack/pulls/757), issue [#755](https://git.mosaicstack.dev/mosaicstack/stack/issues/755). +- Before rebase: `dff8ce4f79ef90370c29d925002118a708010091`; required base: `origin/main` at `2e2280070ae67288be45f41743cf67052a8ca5a6`; original branch base: `d0771835542deab048ad8e79f271e3abdb6151f7`. +- Provider metadata: #757 is open, targets `main`, head is `feat/mos-logical-identity-fencing`, prior CI is green, and the provider reports it is not mergeable because of conflicts. +- Affected delivery paths: `apps/gateway/src/agent/agent.module.ts`; connector-lease gateway repository/service and three focused tests; `packages/agent/src/connector-lease.ts` plus test/export; `packages/types/src/agent/connector-lease.dto.ts` plus test/export; `packages/db/src/schema.ts`, migration `0016_salty_morlocks.sql`, Drizzle snapshot/journal; `docs/PRD.md`, `docs/SITEMAP.md`, MOS architecture/operations pages, this scratchpad, and `docs/tess/TASKS.md`. +- Read-only merge-tree inspection found only `docs/PRD.md` and `docs/SITEMAP.md` conflicts. Current-main #756 channel contracts, #758 roster-v2 structural compiler, and Native Kanban SOT use distinct contract domains; no substantive architecture collision was identified before mechanical reconciliation. +- Reconciliation constraints: retain all current-main #752/#756/#758/KBN content; add only nonduplicative #755 references; do not alter semantics or conflate connector leases/grants with Kanban task leases/fences, local Fleet leases, auth sessions, ResetSession generations, or federation grants. + +## Plan (TDD RED → GREEN → REFACTOR) + +1. Map existing contracts, DB/migration conventions, gateway authorization/audit boundaries, and test infrastructure. +2. Add failing contract/repository/concurrency/restart/abuse/gateway tests and capture RED evidence. +3. Implement the smallest normalized contracts, schema/migration/repository, grant validator, audit sink, and gateway service/adapter boundary needed to pass. +4. Refactor for clear invariants and credential-safe observability; rerun focused suites. +5. Run package/repo typecheck, lint, format, and appropriate tests. +6. Run independent code + security review, remediate, and re-review. +7. Inspect the final diff for security/scope drift; commit; queue guard; push; open PR with `Refs #755` and exact verification; stop without merge/issue closure. + +## Constraints and safety notes + +- `docs/tess/TASKS.md` is orchestrator-only and will not be edited. +- Existing dirty `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are launcher/orchestrator state and will not be staged or altered intentionally. +- No client-supplied identity may confer authority. +- No credential, token, or raw grant material may be persisted to audit/log output. +- Existing authorization checks remain intact; fencing is an additional fail-closed layer. + +## Assumptions resolved from existing architecture + +- `ASSUMPTION:` M1 exposes no public lease endpoint. The gateway service is an internal policy surface with deny-all default policy because concrete connector activation/cutover is explicitly deferred. +- `ASSUMPTION:` Fencing epochs use PostgreSQL `bigint` and cross-module decimal strings, preserving JSON portability without JavaScript number precision loss. +- `ASSUMPTION:` Process-local grant provenance intentionally fails closed across restart; durable lease/epoch state survives and fresh grants require current policy + lease validation. + +## TDD evidence + +RED observed before implementation: + +- `corepack pnpm --filter @mosaicstack/types exec vitest run src/agent/connector-lease.dto.spec.ts` → failed to load missing `connector-lease.dto.js`. +- `corepack pnpm --filter @mosaicstack/agent exec vitest run src/connector-lease.test.ts` → failed to load missing `connector-lease.js`. +- Gateway focused tests failed before implementation because the new repository/service boundaries did not exist (workspace dependencies were then built before behavioral GREEN runs). + +GREEN to date: + +- Types contract: 6/6 passed. +- Agent grant/fencing unit suite: 5/5 passed. +- Gateway PGlite repository + policy/side-effect integration: 7/7 passed; 1 real-PostgreSQL test skipped when `DATABASE_URL` absent. +- Real PostgreSQL focused run with configured `DATABASE_URL`: 1/1 passed (credential value not emitted in reports). + +## Documentation checklist + +- [x] `docs/PRD.md` contains current MOS-PORT M1 scope and acceptance criteria. +- [x] Developer architecture: `docs/architecture/mos-runtime-portability-m1.md`. +- [x] Admin/operations guidance: `docs/guides/mos-connector-lease-operations.md`. +- [x] `docs/SITEMAP.md` links both pages. +- [x] No user-guide change: M1 exposes no user-facing flow or channel cutover. +- [x] No OpenAPI/endpoint-index change: M1 adds no HTTP endpoint. +- [x] Migration/restart/rollback safety and credential-safe audit constraints documented. +- [x] Canonical source remains in-repo; no external publishing action is in scope. +- [x] Independent review confirms documentation matches implementation; implementation-specific findings were remediated. + +## Independent review and remediation + +Codex code/security review ran in multiple rounds. Findings and root-cause remediations: + +1. Policy could not inspect requested scope/TTL → policy subject now receives normalized requested scopes and explicit requested TTL. +2. Unbounded authority lifetime → hard defaults cap leases at 5 minutes and grants at 30 seconds; overrides may only tighten; over-limit tests added. +3. Cross-tenant denial could audit under submitted tenant → mismatch audit uses authenticated tenant plus sanitized `untrusted` target metadata; integration assertion added. +4. Malformed forged grant could break the denial/audit path → runtime-safe shape validation with sanitized fallback audit; malformed-input test added. +5. Gateway integration test depended on prior test state → denial test now seeds a unique binding itself; isolated `-t` run passed. +6. Reviewer repeatedly identified launcher-generated `.mosaic/orchestrator/*` state; those files remain unstaged and excluded from the implementation commit. + +Latest independent security review: no critical/high/medium/low findings. Final commit-level code review remains to run after the intended diff is committed without launcher state. + +## Verification evidence + +- Focused contracts/fencing: types 6/6; agent 9/9. +- Gateway focused PGlite repository/policy integration: 7/7; isolated denial test 1/1. +- Real PostgreSQL close/reopen/CAS test: 1/1 with configured `DATABASE_URL`. +- Root `corepack pnpm typecheck`: 42/42 Turbo tasks passed. +- Root `corepack pnpm lint`: 23/23 Turbo tasks passed. +- Root `corepack pnpm format:check`: all matched files passed. +- Root `corepack pnpm test`: 42/42 Turbo tasks passed; gateway 616 passed / 12 environment-gated skipped; DB 19 passed / 7 environment-gated skipped; Mosaic 650 passed. + +## Known residual risks + +- Concrete connector policies and Claude/Pi/Codex adapters are intentionally deferred; production policy defaults deny-all. +- Gateway pre-side-effect validation cannot make an external system exactly-once. Adapters must propagate/enforce the epoch at downstream effect boundaries; receipts/journaling are later #754 scope. +- Migration rollback is additive-only; dropping lease/audit tables is intentionally manual to avoid destroying authority/audit evidence. + +## Commit-level review remediation + +- Commit-level Codex code review found one `should-fix`: heartbeat, release, and grant issuance authorized caller-supplied lease fields before canonical normalization. +- TDD RED: the isolated gateway policy-boundary test showed mixed-case/padded logical agent, binding, connector, scope, and epoch values reaching policy unchanged. +- Remediation: exported the coordinator's canonical lease normalizer and applied it at the gateway boundary before tenant/policy checks and coordinator dispatch for heartbeat, release, and grant issuance. +- GREEN: isolated policy test 1/1; focused types 6/6, agent 9/9, gateway 8/8; root typecheck 42/42, lint 23/23, format check passed, and root tests 42/42 (gateway 617 passed / 12 environment-gated skipped). +- Commit-level security review remained clean: no critical/high/medium/low findings. + +## Durable grant-expiry review remediation + +- Final commit review found a second `should-fix`: grant expiry was capped against submitted lease metadata after current-authority validation, rather than the durable lease row. +- TDD RED: a crafted same-authority lease with a later submitted expiry produced a grant expiring after the durable row. +- Remediation: grant authority fields and expiry now derive from the durable current lease; submitted scopes remain an additional narrowing constraint. +- GREEN: focused agent fencing suite 10/10. + +## Current-main reconciliation (2026-07-14) + +- Rebased the existing PR branch from `dff8ce4f79ef90370c29d925002118a708010091` (old base `d0771835542deab048ad8e79f271e3abdb6151f7`) onto `origin/main` `2e2280070ae67288be45f41743cf67052a8ca5a6`; current uncommitted reconciliation head is `d190732a550918161b91d3eb54640f4ea0e2e499`. +- Resolved only `docs/PRD.md` and `docs/SITEMAP.md`: preserved current-main #752 Native Kanban, #756 official-channel, and #758 FCM material; retained the nonduplicative #755 M1 workstream and placed its two documentation links in the existing Runtime-neutral Mos section. No #755 source semantics changed. +- Compatibility review confirmed separate authority domains: connector lease/epoch/grant remains distinct from KBN task leases/fences, local Fleet roster lifecycle, auth sessions, ResetSession context, and federation grants. No channel cutover, adapter activation, checkpoint/exactly-once behavior, UI convergence, or #754 expansion was added. +- Focused verification after building the required workspace dependencies: types contract 6/6; agent fencing/grant 10/10; gateway repository/PGlite and policy integration 8/8. The focused real-PostgreSQL test was skipped because `DATABASE_URL` was not configured; no credentials were inspected or emitted. +- Generated schema check: `pnpm --filter @mosaicstack/db db:generate` reported no schema changes; migration `0016_salty_morlocks`, snapshot, and journal were unchanged by generation. +- Full verification: `pnpm typecheck` 42/42; `pnpm lint` 23/23; `pnpm format:check` passed; `pnpm test` 42/42 (gateway 627 passed / 12 environment-gated skipped). Scoped diff check and local documentation-link scan passed. +- Pending only: commit this reconciliation record, queue guard, force-with-lease push of the rebased existing branch, then fresh independent DB/code/security review and Ultron. Do not claim merge or issue closure. + +## Durable lifecycle-authority remediation (2026-07-14) + +- Independent review found that heartbeat and release authorized the submitted lease, allowing forged lifecycle scope data to influence policy before the durable row was consulted. +- Remediation: lifecycle operations tenant-check the submitted lease, load the durable current lease, compare tenant, logical agent, binding, lease UUID, connector, epoch, and canonical ordered scopes, audit and deny any absent/mismatched authority, then run policy and coordinator lifecycle calls with the durable lease. Coordinator CAS/fencing checks remain unchanged. +- Adversarial gateway integration coverage proves forged `tool.execute` scopes on a durable `runtime.send` lease deny before policy or mutation, preserve heartbeat/release state, and write a denial audit for both lifecycle actions; canonical heartbeat and release remain accepted. +- Verification: focused types 6/6; agent 10/10; gateway PGlite integration/repository 9/9 with one `DATABASE_URL`-gated PostgreSQL test skipped; Drizzle check passed; root typecheck 42/42; lint 23/23; format check passed; root test 42/42 (gateway 628 passed / 12 environment-gated skipped). +- Pending: commit, queue-guard, force-with-lease push, then independent DB/code/security rereview and CI. Do not merge or close #755. diff --git a/docs/scratchpads/756-official-discord-plugin.md b/docs/scratchpads/756-official-discord-plugin.md new file mode 100644 index 00000000..78b23a08 --- /dev/null +++ b/docs/scratchpads/756-official-discord-plugin.md @@ -0,0 +1,57 @@ +# Scratchpad — #756 Official Discord channel plugin + +- **Task / issue:** Official Discord channel plugin / #756 +- **Branch:** `feat/756-official-discord-plugin` +- **Worktree:** `/home/hermes/agent-work/stack-discord-plugin` +- **Base:** `origin/main` at `49e8a54105eddf41e8e0e44603ded616ee76044f` +- **Objective:** Deliver harness-neutral Discord routing, native mention-to-thread behavior, untagged in-channel interaction, fail-closed channel/user authorization, and transport-neutral contracts for future official channel plugins. +- **Collision boundary:** Do not modify orchestrator-to-Pi migration, logical-agent lease/fencing (#754/#755), runtime provider implementations, or orchestrator-owned `docs/TASKS.md`. +- **Working budget:** 55K tokens for requirements, implementation, tests, documentation, independent review, and delivery. No user-specified hard cap. Reduce optional refactoring before reducing acceptance coverage. + +## Assumptions + +1. **ASSUMPTION:** Every configured Discord channel is intentionally agent-bound, so an authorized human's untagged message is agent input and receives an in-channel response. Rationale: this satisfies the requested no-tag behavior without activating the bot in arbitrary channels. +2. **ASSUMPTION:** Mentioning the bot in a parent channel creates or reuses a public Discord thread; messages already in a thread remain in that thread without repeated mentions. Rationale: Discord does not support nested threads and the request describes tags as the thread-selection signal. +3. **ASSUMPTION:** One bot process may host multiple configured channel-to-logical-agent bindings. Rationale: bindings are already configuration-owned and this avoids per-agent Discord credentials. +4. **ASSUMPTION:** Static guild/channel/user allowlists and paired-user roles remain the administration surface for this slice. Rationale: dynamic admin UI is larger and can be added without changing adapter contracts. +5. **ASSUMPTION:** The stable conversation handle contains logical agent plus Discord channel/thread identity and never a harness/provider identifier. Rationale: runtime re-enrollment and the active lease work can change Claude/Codex/Pi/OpenCode behind the same channel connection. +6. **ASSUMPTION:** Canonical documentation remains in-repository for this slice; no external publishing is performed. + +## Plan + +1. Update `docs/PRD.md` before code with #756 scope, constraints, assumptions, and acceptance criteria. +2. Add failing Discord behavior and authorization tests first. +3. Add transport-neutral channel DTO/contracts in `@mosaicstack/types`. +4. Implement mention-to-thread, untagged in-channel, existing-thread, stable conversation, and adapter health behavior without runtime-specific imports. +5. Update admin/developer/channel protocol docs and documentation checklist. +6. Run focused tests, typecheck, lint, formatting, full applicable baseline, and coverage. +7. Run independent code and security review; remediate and re-review. +8. Commit, queue-guard, push, open PR to `main`, wait for green CI, squash merge, verify merged CI, and close #756. + +## Progress + +- 2026-07-14: Loaded mission state (none active), global/project guidance, current `origin/main`, existing Discord/Tess/fleet connector architecture, issue #709 history, and active portability issues #754/#755. +- 2026-07-14: Created issue #756 through the Mosaic wrapper. +- 2026-07-14: Created isolated worktree/branch from current `origin/main`; the stale root checkout and unrelated QA artifacts remain untouched. + +## TDD decision + +Required and applied. This change modifies authorization-sensitive remote ingress and routing behavior. Failing permission and routing tests will be captured before implementation. + +## Risks / blockers + +- Active logical-agent lease work may later add stronger fencing fields. This slice must expose a clean, harness-neutral seam without duplicating that schema. +- Discord thread creation is an external side effect. Unit tests use a typed fake; live credential smoke testing is out of scope and must not use committed secrets. +- Repository-wide checks may expose unrelated baseline debt; changed-scope evidence and CI remain mandatory. + +## Verification evidence + +- `pnpm format:check` — passed. +- `git diff --check` — passed. +- `pnpm typecheck` — passed (42 Turbo tasks). +- `pnpm lint` — passed (23 Turbo tasks). +- `pnpm build` — passed (23 Turbo tasks). +- `pnpm --filter @mosaicstack/discord-plugin test` — passed: 44 tests; V8 coverage 92.18% statements/lines, 86.55% branches, 100% functions (all configured thresholds ≥85%). +- Focused gateway verification passed: Discord ingress/security, cross-surface, ownership, redaction/concurrency, and agent attachment tests. +- `pnpm test` — changed-scope suites passed; repository baseline remains blocked by `apps/gateway/src/__tests__/cross-user-isolation.test.ts` requiring PostgreSQL at localhost port 5433 (`ECONNREFUSED`). The failure is unrelated to this change. +- Independent code/security re-reviews requested after final remediation; reports are stored under ignored `docs/reports/` evidence paths. diff --git a/docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md b/docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md new file mode 100644 index 00000000..5c6943a1 --- /dev/null +++ b/docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md @@ -0,0 +1,148 @@ +# FCM-M1-002 — Shared role resolution + +- **Task:** `FCM-M1-002` +- **Issue:** `mosaicstack/stack#758` +- **Branch:** `feat/758-shared-role-resolution` +- **Starting head:** `32e75c67b094de443d37fe7d5ff8d25cdfc8b39d` +- **Role:** implementation worker; independent review and merge remain outside this worker + +## Objective + +Reuse the existing baseline-plus-`roles.local` persona resolver as the sole class authority for roster-v2 semantics, profile validation, provisioning, and launch/persona resolution. Add exact approved alias canonicalization, fail-closed semantic validation, immutable canonical-class authority contracts, required baseline roles, and operator documentation without implementing lifecycle, mutation, credentials, certificate workflow, or later FCM cards. + +## Budget + +- Soft budget: **25K tokens**. +- Strategy: inspect once, implement in small TDD units, run focused suites before the full package gate, and avoid unrelated refactors or M1-003/M2/M4 scope. + +## Plan + +1. Map the existing persona resolver, roster-v2 compiler, profile/provision consumers, launch resolution, role library, and focused tests. +2. Write denial/invariant tests first for aliases, canonicalization-before-override, unreadable roles, authority boundaries, policy mismatch, canonical provision output, and resolver parity. +3. Run the focused suites and record the expected red evidence. +4. Implement one shared canonical resolution and authority contract in/through `fleet-personas.ts`; delegate roster semantic validation and profile/provision paths to it. +5. Add baseline `validator`, `team-leader`, and `interaction` role contracts plus `LIBRARY.md` entries while retaining `operator-interaction` compatibility. +6. Add the required role reference, alias migration, customization guide, and roster-v2 semantic handoff documentation. +7. Run focused tests, the full `@mosaicstack/mosaic` suite, typecheck, lint, Prettier, `git diff --check`, situational verification, independent code/security review, and remediation. +8. Commit with the required co-author trailer, run the CI queue guard, push the existing branch, and create/update exactly one PR to `main` with `Refs #758`. + +## TDD evidence + +### Red + +After installing worktree-local dependencies and building `@mosaicstack/db`, the pre-implementation +focused run collected the intended tests and failed as expected: + +```text +2 test files failed; 32 tests failed; 32 tests passed +``` + +Expected failures named the missing `canonicalizeRoleClass`, +`authorityForCanonicalClass`, and `validateRosterV2Semantics` APIs, absent requested/canonical typed +output, and unresolved required canonical role contracts. An earlier run that failed before test +collection on an unresolved `yaml` dependency was treated as environment setup, not TDD evidence. + +### Green + +Focused role-resolution and affected service fixtures: + +```text +6 test files passed; 109 tests passed +``` + +The focused set covers personas, profiles, provision, launch persona contract, roster-v2 semantics, +and the operator-interaction service fixture. The final profile tests also cover readable lead/floor +compatibility and canonical collision denial. + +## Tests and gates + +- Focused suites: pass, **6 files / 109 tests**. +- Full `@mosaicstack/mosaic` suite: pass, **50 files / 713 tests**. Workspace package build outputs + were prepared first because a clean worktree has no dependency `dist` entries. +- `pnpm --filter @mosaicstack/mosaic typecheck`: pass. +- `pnpm --filter @mosaicstack/mosaic lint`: pass. +- Prettier check over every changed file: pass. +- `git diff --check`: pass. +- Runtime/file-boundary evidence: real role library, profile/provision filesystem integration, + launch-time synchronous contract injection, v1 roster parser round-trip, roster-v2 semantic + filesystem checks, and operator-interaction service fixtures all pass without live mutation. +- Independent code review: **APPROVE**, no blocking or non-blocking findings; reviewed complete + tracked/untracked delta including the canonical collision guard. Residual: roster-v2 semantic + validation is an explicit async handoff with production caller wiring owned by later work. +- Independent security review: **APPROVE**, no verified authority/security findings on the final + delta. + +### Post-PR fail-closed remediation + +Independent rereview found resolver fail-open edges that the original green PR head did not cover. The +remediation remained uncommitted until every finding was reproduced red-first and the same reviewer +approved the complete two-file delta. + +Final regression evidence: + +```text +persona resolver: 47/47 +focused affected suites: 6 files / 138 tests +root-container resolver suites: 86/86 +full canonical run: 42/42 Turbo tasks; Mosaic 50 files / 733 tests +``` + +DB migration, typecheck, lint, Prettier, and `git diff --check` also passed. Coverage now proves: + +- unreadable, unscannable, direct-dangling, ancestor-dangling, and literal `..` traversal override paths + fail closed across async, sync, listing, and status APIs; +- genuinely missing override directories still permit baseline fallback; +- cached missing scans are revalidated before fallback; +- marker-defined identity and domain metadata are revalidated on the second read; +- `LIBRARY.md` rows and incidental later markers cannot define, shadow, or advertise personas. + +Exact-head pipeline `1819` passed for rebased head `4d990eee…`, but the independent reviewer-of-record +returned **REQUEST CHANGES** after reproducing three additional edge failures: a canonical filename could +inherit protected authority despite a conflicting explicit first marker, cached `scanned` absence could +miss an override created before baseline fallback, and inherited plain-object names such as `constructor` +could corrupt alias/authority lookup. Merge remained held. + +Each failure was reproduced red-first in the persona suite (4 failing assertions), then remediated without +expanding card scope. Explicit first markers now own identity and filename fallback applies only to +markerless contracts; every second read rejects a newly introduced conflicting marker regardless of +cached classification; cached async resolution re-scans the override layer immediately before every +baseline fallback; alias and authority registries require own-property matches. Current uncommitted +evidence is persona **52/52**, focused affected suites **6 files / 143 tests**, and full Mosaic package +**50 files / 738 tests**, plus typecheck, lint, Prettier, and `git diff --check`. Independent +finding-specific rereview **APPROVED** the complete uncommitted three-file remediation after direct +adversarial reproduction of all three findings and the follow-up markerless TOCTOU. All post-commit +exact-head gates remain required. + +## Risks and boundaries + +- **Security-sensitive authority:** authority must derive only from canonical class, never role prose, aliases, display names, or tool-policy text. +- **Resolver divergence:** no second regex, registry, scanner, or prose parser may be introduced. +- **Alias capture:** aliases must canonicalize before baseline/`roles.local` lookup so local files cannot redefine legacy aliases as separate authority. +- **Readable persona requirement:** semantic success requires a resolved readable persona, not class-set membership. +- **Scope control:** no roster mutation, lifecycle, lease issuance, certificate workflow/storage, credentials, remote reconciliation, provision-v2 conversion, or shipped-example disposition execution. +- **Coordination:** `docs/TASKS.md` is read-only and remains orchestrator-owned. + +## Acceptance-evidence mapping + +| Requirement / criterion | Verification evidence | +| --- | --- | +| `FCM-REQ-02` shared semantic resolver | Async/sync resolver parity; roster-v2 delegates batched scans and resolution; profiles/provision and launch reuse `fleet-personas.ts`; no second scanner or class-marker regex added. | +| `FCM-REQ-07` canonical classes and authority boundaries | Exact alias and non-alias tests; immutable authority invariant tests; all required canonical contracts resolve through the real role library. | +| `AC-FCM-01` structural + semantic roster validation | Synchronous parser/normalizer tests remain intact; async semantic tests cover aliases, custom roles, unreadable/unresolved roles, `LIBRARY`-only rejection, and bidirectional protected policy mismatch. | +| `AC-FCM-07` protected authority invariants | Denial tests prove merge-gate-only merge, validator certificate-only, orchestrator/team-leader/interaction limits, no implicit custom-role authority, and canonical tool-policy matching. | + +## Documentation + +- `docs/fleet/reference/role-classes.md` +- `docs/fleet/migration/legacy-class-aliases.md` +- `docs/fleet/how-to/customize-roles.md` +- `docs/fleet/reference/roster-v2-fields.md` semantic handoff +- Baseline role contracts and `LIBRARY.md` rows for `validator`, `team-leader`, and `interaction` + +## Residual risks + +- Provisioning remains intentionally v1 and does not emit `reports_to`; canonical topology is retained + in its typed seat/summary path only, matching the existing v1 parser boundary. +- Alias support remains for compatibility; new configuration should emit canonical identities. +- This card defines authority metadata and validation only. Enforcement workflows for leases, + certificates, lifecycle, and mutation remain owned by later FCM cards. diff --git a/docs/scratchpads/758-fcm-m1-003-example-profile-dispositions.md b/docs/scratchpads/758-fcm-m1-003-example-profile-dispositions.md new file mode 100644 index 00000000..0ad76545 --- /dev/null +++ b/docs/scratchpads/758-fcm-m1-003-example-profile-dispositions.md @@ -0,0 +1,37 @@ +# FCM-M1-003 — Executable example/profile/service-preset dispositions + +- **Task / issue:** FCM-M1-003 / #758 +- **Branch / base:** `test/758-example-profile-dispositions` from `origin/main` `a5e8e554012f27898e035d2882a8e47e1a02fe97` +- **Objective:** Make every artifact in the M0 legacy disposition inventory executable evidence: it must validate canonically, be explicitly retained as a v1 fixture, or be retired with a replacement/deprecation link. +- **Scope:** Validation and explicit version/retirement metadata only for shipped examples, profiles, and the operator-interaction service preset. Reuse the central resolver and existing v2 roster compiler. +- **Out of scope:** Generated environment boundaries, CRUD, reconciliation/apply, migration, live fleet mutation, and `docs/TASKS.md`. +- **Budget:** 20K card allocation; use focused package tests before full package validation. + +## Plan + +1. Inventory exact shipped artifacts and existing compiler/resolver/profile tests. +2. Add failing behavior tests covering all listed artifacts and their documented disposition. +3. Implement minimal declarative fixture/disposition validation; do not add a role/class resolver. +4. Run focused and package quality gates; obtain independent code and security review. +5. Commit, queue-guard, push, open one `main` PR with `Refs #758`. + +## Progress + +- Intake complete: verified no branch, worktree, or open PR for this card before creating this isolated worktree. +- Requirements read: FCM PRD, FCM-M1-003 task row, M0 disposition inventory, delivery/QA/documentation guides. +- TDD: RED recorded with `pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts` failing because the new module did not exist; GREEN recorded after the minimal guard implementation. The focused suite now has 4 passing tests, including undeclared-artifact and missing-explicit-v1-version denials. +- Independent review: initial code review found the service policy path was hardcoded; remediation iterates declared `canonical-service-policy` artifacts. Exact-head code review approved and exact-head security review found no issues. + +## Risks / decisions + +- The M0 inventory permits unresolved legacy roles only when explicitly v1-versioned or retired. Do not infer aliases beyond the three approved by FCM-M1-002. +- `docs/TASKS.md` is orchestrator-owned and will not be edited. + +## Verification evidence + +- Focused TDD guard: `pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts` — PASS (4 tests). +- Full package suite: `pnpm --filter @mosaicstack/mosaic test` — PASS (51 files, 742 tests). +- Static gates: `pnpm --filter @mosaicstack/mosaic typecheck`, `pnpm --filter @mosaicstack/mosaic lint`, and `pnpm format:check` — PASS. +- Diff gate: `git diff --check` — PASS. +- Exact-head reviews: `codex-code-review.sh --uncommitted` — APPROVE; `codex-security-review.sh --uncommitted` — no findings. +- Delivery: committed as `9a9ad1a`, pushed after `ci-queue-wait.sh --purpose push`, and opened PR [#770](https://git.mosaicstack.dev/mosaicstack/stack/pulls/770) to `main` with `Refs #758`. `pr-ci-wait.sh -n 770` reported terminal-green Woodpecker pipeline [#1823](https://ci.mosaicstack.dev/repos/47/pipeline/1823/1). diff --git a/docs/scratchpads/758-fcm-m2-002-fleet-agent-crud.md b/docs/scratchpads/758-fcm-m2-002-fleet-agent-crud.md new file mode 100644 index 00000000..02201c94 --- /dev/null +++ b/docs/scratchpads/758-fcm-m2-002-fleet-agent-crud.md @@ -0,0 +1,46 @@ +# FCM-M2-002 — Generation-Guarded Fleet Agent CRUD + +- **Task / issue:** FCM-M2-002 / #758 +- **Branch / base:** `feat/758-fleet-agent-crud` from `origin/main` `191efaefeb5c0c6bb218c1292d12ce8e73ace12b` +- **Budget:** 30K card allocation; no deployment or live-fleet actions. + +## Objective + +Provide local roster-owned create, get, update, and delete mutations with a generation precondition, deterministic dry-run plan, complete-state structural/semantic/projection validation before writes, atomic roster persistence, and redacted recovery output on a late projection failure. + +## Scope and exclusions + +- Roster v2 is the sole desired-state authority. Reuse `parseRosterV2`, `renderRosterV2Yaml`, `validateRosterV2Semantics`, and the generated-environment boundary. +- Fresh create defaults to `enabled: true` and `desired_state: stopped`; this card never starts a runtime. +- Excluded: reconcile/apply; lifecycle/session/systemd/tmux actions; migration/canary; remote/connector/gateway mutation; arbitrary commands/channels/secrets; generated files as authority; `docs/TASKS.md` and orchestration ledger changes. + +## Red-first plan + +1. Add failing tests for dry-run non-mutation, stale generation, concurrent writer locking, stopped default create, equivalent idempotency, complete proposed-state semantic/boundary validation, atomic roster write, and injected late projection failure with redacted recovery details. +2. Implement only a roster-v2 CRUD service and file adapter; no legacy `fleet add/remove` behavior expansion. +3. Add operator/reference docs for JSON outcomes, generation retries, recovery, and no-runtime-action boundary. + +## Progress + +- Preflight: clean exact base and no duplicate PR confirmed. +- Intake read: FCM PRD/AC-FCM-03, task row, launch and generated-env boundaries, roster v2/resolver contracts, legacy fleet command behavior, delivery/QA/TypeScript/security/documentation guidance. +- TDD: RED observed for the missing CRUD module. GREEN: focused suite passes 7 tests covering stopped-default create, stale generation, idempotency, dry-run non-mutation, concurrent lock denial, exact stale/absent generated-projection delete cleanup, and redacted late-projection recovery. +- REVIEW-1 remediation: RED observed for absent CLI create/get/update/delete/plan wiring. Added roster-v2-only JSON commands, including safe `get`, read-only planning/dry-run, explicit persisted-start recording (never runtime start), stable handled error codes, and focused CLI coverage. +- REVIEW-2 remediation: RED observed for missing direct fleet-control-plane registration and ambiguous partial late-I/O result. The public surface is now direct `mosaic fleet {create,get,update,delete,plan}` (not root gateway `mosaic agent`); a late filesystem projection failure returns non-zero redacted JSON with `authoritativeRoster: committed` and `projections: incomplete`, proving no rollback/no-op claim. +- REVIEW-3 remediation: RED observed for unnamed update/delete plans and deleted-agent quarantine conflict. `plan [name]` now requires target names only for update/delete; delete validates/removes only exact generated state while retaining local/legacy/quarantine/unrelated files. Actual CLI/filesystem tests cover create/update/delete plans, plan validation/non-mutation, retained artifacts/dry-run bytes, unsafe permission/symlink rejection, and post-roster delete recovery. Added the required operator how-to. +- REVIEW-4 remediation: RED observed that actual Commander create accepted and silently dropped disallowed `command`, `channel`, and `secretRef` keys. The `--agent` object and nested `launch` now use strict own-property allowlists; non-plain/prototype-sensitive shapes and unknown keys fail `invalid-request` before resolver, roster, or projection mutation. Actual Commander plan/create/update tests cover top-level command/channel/secretRef/constructor/prototype/`__proto__` shapes and nested launch unknown fields, byte-identical retained artifacts, non-zero exit, and diagnostics that never echo rejected values. Docs state the same boundary. + +## Risks / assumptions + +- **ASSUMPTION:** M2 mutations operate exclusively on the existing v2 roster contract because generation/lifecycle fields are v2-only; legacy v1 add/remove commands remain unchanged compatibility paths. +- A multi-file roster/projection write cannot be one filesystem rename. The roster is authoritative; a post-roster projection failure returns a redacted recovery plan naming only safe paths/actions, never environment values. + +## Verification evidence + +- `pnpm --filter @mosaicstack/mosaic test -- fleet-agent-crud-command.spec.ts fleet-agent-crud.spec.ts generated-env-boundary.spec.ts` — PASS (48 tests after REVIEW-4 remediation). +- `pnpm --filter @mosaicstack/mosaic test` — PASS (54 files, 794 tests after REVIEW-4 remediation). +- `pnpm --filter @mosaicstack/mosaic lint` — PASS. +- `pnpm --filter @mosaicstack/mosaic typecheck` — PASS. +- `pnpm format:check` and `git diff --check` — PASS. +- Root `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `git diff --check` — PASS after REVIEW-4 remediation; full package test is 54 files / 794 tests. +- REVIEW-4 remediation focused checks are green; fresh full-delta independent review is required before author-green. No commit, push, or PR opened. diff --git a/docs/scratchpads/758-fcm-m3-001-local-reconciler.md b/docs/scratchpads/758-fcm-m3-001-local-reconciler.md new file mode 100644 index 00000000..5d978568 --- /dev/null +++ b/docs/scratchpads/758-fcm-m3-001-local-reconciler.md @@ -0,0 +1,39 @@ +# FCM-M3-001 — Local roster-owned reconciliation and lifecycle + +- **Task / issue:** FCM-M3-001 / #758 +- **Branch / base:** `feat/758-local-reconciler` from `origin/main` `bc5e73629e92c56a80fa6a769ebad17c0177f504` +- **Base tree:** `1b9ebe4fa1a90734b6f81118e120bae5290cd350` +- **Scope:** source, isolated fake-adapter tests, and card documentation only. No live fleet/systemd/tmux action. + +## Objective + +Provide local roster-v2 `apply`/`reconcile` and lifecycle/status contracts. The roster remains desired-state authority; projections and runtime observations are derived state. + +## Red-first evidence + +The initial focused reconciler test failed because `fleet-reconciler.ts` did not exist. The initial new-worktree test invocation also exposed absent dependencies; `pnpm install --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store` restored local workspace dependencies without changing source. + +## Design + +- A new `fleet-reconciler.ts` accepts only typed roster-v2 input plus injected command and projection adapters. +- It targets only exact `mosaic-agent@.service` units and the exact configured tmux socket/session. +- It classifies unowned/unmanaged state and fails mutation closed rather than adopting or killing it. +- It validates the private install-derived holder identity and complete expected tmux global environment before mutating lifecycle state. +- REVIEW-1 remediation: RED review evidence found service-level apply could omit generation and had no mutation lock. Every non-observational command now requires an expected generation; a private exclusive roster-adjacent lock is acquired before effects and released on success or partial failure. Tests cover missing/stale values, concurrent denial, no effects, release, and lock-free observation. +- REVIEW-2 remediation: lock acquisition now validates private real `MOSAIC_HOME`/`fleet` ancestors, rejects symlink or unsafe leaves, distinguishes `EEXIST` concurrency from other I/O, and binds release to the created inode plus random ownership token. A replacement lock is retained and reported, not unlinked. A crash may leave a stale lock for inspection; no stale-lock break is claimed. +- REVIEW-3 remediation: a lock cleanup failure now adds bounded `cleanup` diagnostics to a known successful or partial effect result without replacing its projection/lifecycle/recovery truth. Cleanup is not claimed as complete, and the retained lock requires inspection before retry. +- REVIEW-4 remediation: command JSON with an additive cleanup diagnostic now exits non-zero even where known effects completed; clean effect and observational JSON remain zero-exit. +- REVIEW-5 remediation: mutating operations acquire the private lock before rereading canonical `roster.yaml`; the fenced reread, not a caller snapshot, supplies generation validation, plan, projection, and lifecycle authority. +- `apply` starts only enabled agents whose persisted desired state is `running`; stopped/default agents are never started by reconciliation. +- Observational commands produce JSON classification only. Partial projection or lifecycle effects report explicit recovery without values. + +## Boundaries + +Excluded: live host actions, remote/SSH/connector lifecycle mutation, migrations, canaries, deployment, gateway changes, arbitrary command/channel/secret inputs, `docs/TASKS.md`, and orchestration ledgers. + +## Verification + +- Focused reconciler/Commander/CRUD/fleet tests: 4 files / 229 tests passed. +- Full `@mosaicstack/mosaic` suite: 56 files / 820 tests passed after REVIEW-5 canonical roster fencing remediation. +- Package and root typecheck/lint, root format check, and `git diff --check`: passed. +- Isolated launcher and systemd template harnesses passed; they use fixtures only. No live fleet, systemd, tmux, session, remote, connector, or runtime action occurred. diff --git a/docs/scratchpads/758-fcm-m3-002-reconciler-lifecycle-gates.md b/docs/scratchpads/758-fcm-m3-002-reconciler-lifecycle-gates.md new file mode 100644 index 00000000..51d0bc14 --- /dev/null +++ b/docs/scratchpads/758-fcm-m3-002-reconciler-lifecycle-gates.md @@ -0,0 +1,84 @@ +# FCM-M3-002 — Reconciler lifecycle acceptance gates + +- **Task / issue:** FCM-M3-002 / mosaicstack/stack#758 +- **Branch:** `test/758-reconciler-lifecycle-gates` +- **Required starting head:** `499090508ef1d768660e4d54e7934cbcf13cb1cd` +- **Required starting tree:** `2f1bb7fed48291f3f7ba8b21c2b52491aa14fe2b` +- **Scope:** isolated acceptance coverage and card-required evidence/tracking only; no live fleet, systemd, tmux, session, site, migration, canary, deployment, runtime, connector, or remote action. +- **Budget:** use the task estimate of 25K as the working cap; keep the delta to one coherent acceptance suite plus required task/scratchpad evidence. No production change unless a failing reproducer proves an in-scope defect. + +## Intake evidence + +- Clean exact local branch/head/tree verified before editing. +- `origin/test/758-reconciler-lifecycle-gates` fetched and verified at the same required head. +- The Mosaic PR wrapper reported no open pull requests, so no open-PR branch collision exists. +- Parent issue #758 is open and remains intentionally open through M5. +- Requirements loaded from `docs/PRD.md` FCM requirements and `AC-FCM-05`, `docs/TASKS.md` FCM DAG, the M3 rows in `docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md`, and the FCM-M3-001 implementation scratchpad. + +## Objective + +Add broad, behavior-oriented acceptance evidence around the shipped local reconciler contracts. Exercise only injected fake systemd/tmux adapters and temporary filesystem fixtures. Prove exact ownership/targeting, persisted stopped-state safety, truthful partial-failure recovery, rollback behavior, and stable command JSON/exit outcomes without touching live services or sessions. + +## Acceptance mapping and evidence + +| FCM-M3-002 acceptance concern | Delivered isolated evidence | +| --- | --- | +| Systemd/tmux lifecycle | `fleet-reconciler.acceptance.spec.ts` drives apply, reconcile, stop, restart, status, and recovery reconcile through one stateful injected fake host. The fake models exact systemd effects and tmux session observations; no host commands run. | +| Drift | Canonical roster-v2 YAML drives the Commander `fleet status` boundary and classifies `missing-session`, `unexpected-session`, and `disabled-running`, including combined drift, while asserting observation emits no lifecycle mutation. | +| Exact default/named socket targeting | Canonical v2 requires an explicit non-empty named socket; the parser rejects missing/empty values and the Commander acceptance path asserts exact `-L mosaic-fleet` targeting. A separate canonical legacy-v1 roster loader plus runtime-transport path proves a socket-less compatibility roster targets the literal tmux default server with no `-L`. No unreachable empty-socket v2 fixture is used. | +| Unmanaged-session classification | Stateful fixtures report sorted `coder0-shadow`/`unmanaged` sessions, then prove an exact roster stop leaves both sessions and the near-collision service intact. | +| Crash/partial failure | Injected restart failure is applied after the fake effect to model crash/partial truth: result is `lifecycle: incomplete`, the roster is unchanged, and observed runtime may be active. | +| Rollback/recovery semantics | M3 has no rollback command and explicitly does not claim automatic rollback. The acceptance workflow proves the bounded recovery contract: inspect, then exact reconcile restores the persisted stopped target without a start or fuzzy effect. M4 migration/canary rollback remains outside this card. | +| Stopped-state preservation | Stateful apply and reconcile both stop an initially running observed agent whose persisted target is stopped; failed explicit restart leaves desired state stopped; recovery reconcile restores stopped state. No start call is emitted. | +| Zero fuzzy destructive targeting | Near-collision `coder0-shadow` service/session plus `unmanaged` session remain untouched. The recorded destructive calls contain only exact `mosaic-agent@coder0.service`; no tmux kill action is emitted. | +| Stable JSON/exit behavior | Temporary canonical roster fixture invokes the CLI boundary and asserts exactly one JSON line, exact partial-result shape, and exit code 1. Existing focused command specs continue to cover clean zero-exit and stable error JSON. | +| Redacted truthful recovery | Fake stderr includes `PASSWORD=acceptance-secret`; exact CLI JSON contains only bounded recovery metadata and excludes the key, value, and raw diagnostic. | + +## Plan + +1. Inventory existing reconciler and command specs against the table above; avoid duplicating narrow assertions already present. +2. Add one acceptance-level spec using only fake/injected adapters and temporary files. +3. If a real defect is exposed, preserve the failing reproducer and make only the smallest FCM-M3-002-required fix; otherwise leave production unchanged. +4. Reconcile `docs/TASKS.md` only for delivered M1/M2/M3-001 truth and mark FCM-M3-002 in progress. +5. Run focused tests, full `@mosaicstack/mosaic` tests, package/root typecheck and lint, Prettier/format and diff checks, plus adversarial fake-runner cases. +6. Record exact evidence and leave the tree uncommitted for independent synthetic-tree review. + +## TDD decision + +This card adds acceptance coverage to already-delivered behavior. Test-first applies to any product defect discovered: retain a failing reproducer before an in-scope fix. If the shipped behavior already satisfies the acceptance contract, no production code will be changed and the acceptance suite itself is the deliverable. + +## Progress + +- Intake and immutable baseline verification complete. +- Existing coverage inventory confirmed strong unit coverage but no stateful cross-command lifecycle acceptance harness. +- Added `packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts`: one injected stateful fake systemd/tmux host, temporary canonical v2 and legacy-v1 roster fixtures, and seven acceptance tests. +- Production source is unchanged; no product defect requiring an FCM-M3-002 fix was found. +- `docs/TASKS.md` reconciles only merged M1/M2/M3-001 truth and marks FCM-M3-002 in progress. + +## Verification evidence + +All commands ran from `/home/jarvis/src/mosaic-stack-local-reconciler` and passed unless explicitly noted. + +- `pnpm --filter @mosaicstack/mosaic exec vitest run src/fleet/fleet-reconciler.acceptance.spec.ts` — final remediation run: 1 file, 7 tests passed; canonical v2 named-socket parsing/Commander status, missing/empty v2 rejection, and canonical legacy-v1 default-server runtime targeting are distinct reachable cases. +- Focused reconciler/roster/transport command covering acceptance, reconciler, command, CRUD, v2 parser, and runtime transport specs — 8 files, 304 tests passed. +- `pnpm --filter @mosaicstack/mosaic test` — final remediation run: 57 files, 827 tests passed. +- `pnpm --filter @mosaicstack/mosaic typecheck` — passed. +- `pnpm --filter @mosaicstack/mosaic lint` — passed. +- `pnpm typecheck` — 42/42 Turbo tasks successful. +- `pnpm lint` — 23/23 Turbo tasks successful. +- `pnpm exec prettier --check docs/TASKS.md docs/scratchpads/758-fcm-m3-002-reconciler-lifecycle-gates.md packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts` — passed. +- `pnpm format:check` — all matched files use Prettier style. +- `git diff --check` — passed with no output. +- Initial scoped Prettier check found style drift in the new spec and tracking table; `pnpm exec prettier --write ...` remediated it before all final gates above. +- No live fleet, systemctl, tmux, process, site, migration, canary, deploy, runtime, connector, or remote command was invoked. + +## Review boundary + +This is an author handoff. No self-review is represented as reviewer-of-record. The uncommitted synthetic tree is intended for independent review. + +## Risks / blockers + +- M3 truthfully reports incomplete lifecycle effects and bounded recovery; it does not implement or claim an automatic rollback command. This suite proves stopped-state restoration by the documented exact recovery reconcile. M4 retains migration/canary rollback ownership. +- The fake host models only the public systemd/tmux runner contract and temporary roster filesystem boundary. This is intentional under the no-live-effects hold. +- Parent issue closure, commit, push, PR, merge, deployment, and branch cleanup remain explicit holds. +- No residual implementation blocker. diff --git a/docs/scratchpads/758-fcm-m4-001-v1-v2-migrator.md b/docs/scratchpads/758-fcm-m4-001-v1-v2-migrator.md new file mode 100644 index 00000000..0ae0c3a2 --- /dev/null +++ b/docs/scratchpads/758-fcm-m4-001-v1-v2-migrator.md @@ -0,0 +1,80 @@ +# FCM-M4-001 — v1-to-v2 inventory, preview, and migrator + +- **Task / issue:** FCM-M4-001 / mosaicstack/stack#758 +- **Branch / base:** `feat/758-v1-v2-migrator` from `origin/main` `c1aecfabe97a5dc81a72f44910cd4e626f41863f` +- **Base tree:** `46cdfbcdc1d1ff9c7b8b2b9cf3841086590bf774` +- **Scope:** field-complete inventory, non-mutating preview, canonical v2 migration output, and migration/recovery disposition evidence. All effects use injected fakes or temporary fixtures. +- **Budget:** 35K task estimate is the hard working cap. Keep one card/one PR and prefer focused reuse of the v2 compiler, shared role resolver, generated-env boundary, M1 executable disposition inventory, and reconciler observations. + +## Objective + +Implement preview-first v1 migration that never infers unresolved classes or lifecycle, preserves observed running/stopped state, quarantines forbidden legacy environment inputs with key-name/SHA-256-only diagnostics, inventories remote/connector/schema-only entries without reconciling them, covers every M1-classified shipped artifact, and emits deterministic recovery disposition evidence for the later M4-002 canary/rollback gate. + +## Acceptance mapping + +1. Field-by-field v1 inventory and no-mutation preview. +2. Canonical output compiled by `roster-v2.ts` and semantically validated by the existing baseline-plus-`roles.local` resolver. +3. Only approved deterministic aliases; every other noncanonical class requires an explicit disposition. +4. Observed stopped/running maps explicitly to persisted lifecycle; stopped observations never produce running targets. +5. Generated env is regenerated; strict local data is relocated; forbidden keys are quarantine inputs reported only by key name and SHA-256. +6. Remote/connector/schema-only entries are inventory-only and excluded from local reconciliation output. +7. Every shipped M1 example/profile/service preset has executable migration disposition evidence. +8. Deterministic migration/recovery evidence records source, output, exclusions, quarantine, and restore prerequisites without executing a canary or rollback. + +## Boundaries + +Out of scope: FCM-M4-002 executable canary/rollback and host fixture, #766 communications, #636 commands/channels, live fleet/systemd/tmux/session/migration/deploy/connector/remote/gateway effects, `docs/TASKS.md`, parent issue mutation, commit, push, and PR operations. + +## TDD plan + +Migration rules and redaction are critical data-mutation/security logic, so tests are written red-first for inventory completeness, explicit class disposition, observed-state preservation, quarantine redaction, inventory-only remote/schema entries, compiler/resolver reuse, artifact coverage, and recovery evidence. Production code follows only after the focused tests fail for the missing behavior. + +## Plan + +1. Map existing v1 loader, v2 compiler/resolver, env quarantine, reconciler observation, and M1 disposition guard. +2. Add behavior-oriented failing migration tests with temporary fixtures and injected observation/filesystem adapters only. +3. Implement the narrow migration module and CLI boundary without a second resolver or live command runner. +4. Add scoped M4 migration/recovery documentation and executable shipped-artifact evidence. +5. Run focused tests, full package tests, package/root typecheck and lint, formatting, diff checks, and adversarial redaction/no-effect verification. +6. Run independent code/security review, remediate findings, reconstruct the synthetic tree using a temporary index, and stop uncommitted. + +## Progress + +- Collision checks passed: no local/remote branch, worktree, target path, or open PR owned `feat/758-v1-v2-migrator`. +- Dedicated worktree created at the exact green `origin/main` base. +- Required global/repository guides and FCM requirements/evidence loaded. +- No matching migration skill exists under the configured skill directories; no unrelated skill loaded. +- Added a preview-only CLI and migration module that compile with the existing v2 parser/renderer and validate through the shared persona resolver. +- Added value-free raw-v1 inventory, strict unknown-field/synonym/duplicate detection, inventory-only remote and connector handling, and explicit class/tool-policy decisions. +- Added separate reviewed lifecycle observations with only unambiguous running/stopped mappings. +- Added sanitized, non-mutating environment preflight and recovery evidence explicitly marked non-executable. +- Added executable disposition evidence derived from the exact 13-entry M1 inventory and operator documentation. +- Tightened untrusted decisions/observations to reject unknown keys, invalid types/enums, extra local records, and competing automatic-alias dispositions. +- Remediated independent review findings: v1 runtime/reset defaults are preserved, `~` workdirs expand only at env preflight, malformed/required agent fields fail closed before remote exclusion, and all seven shipped v1 fixtures now execute real previews with explicit evidence. +- Remediated socket and locality authority blockers: socket-only agents stay local; `host == fleetHost` stays local; only `host != fleetHost` is inventory-only; ssh-only, missing reviewed fleet-host identity, and contradictory host/ssh targets block explicitly without lifecycle omission. +- Remediated final exact-tree blockers: a declared v1 root socket cannot be overridden; matching/conflicting socket decisions retain reviewed running evidence; canonical ordering uses a shared locale-independent Unicode code-point comparator; migration evidence preserves all four legacy environment dispositions; backup documentation no longer claims validation that M4-001 does not perform. +- Remediated immutable-review socket-presence blocker: both `socket_name` and `socketName` are field-presence-aware, so explicit empty/default-server declarations remain authoritative and incompatible named decisions block rather than replacing them. +- Remediated the replacement-tree blockers: the shared v2 compiler and reconciler accept an explicit empty socket as literal default-server identity; present-empty holder session, default/agent work directory, runtime reset command, and alias values block rather than defaulting; missing preview inputs emit one stable blocked JSON object with non-zero status. Snake/camel aliases and whitespace-only input have adversarial coverage. +- Remediated the subsequent authority blockers: each present-empty CLI path emits exactly one stable blocked JSON object with exit 1 before file reads, and reconciler `start`/`restart` or desired-state `apply`/`reconcile` fail closed before fixed `mosaic-fleet` systemd services can act on a default-server roster. +- Remediated committed-head review blockers: explicitly declared empty runtime objects use the production v1 `/clear` reset fallback while omitted `pi` retains `/new`; lifecycle observations are sorted by canonical agent name; and bare CLI path flags reach preview validation, emit one stable blocked JSON object with exit 1, and perform zero reads. Built production-CLI subprocess tests cover all three bare flags. +- Remediated late-audit blockers: the documented M4 guard invokes the 13-artifact validator and all seven v1 previews; canonical `~`/`~/...` workdirs remain unchanged in migration evidence and traversal-free forms expand at the shared production projection boundary while ordinary relative and home-relative traversal paths remain rejected; remote inventory and exclusion evidence sort canonically; and every default-server lifecycle-mutating reconciler path fails before observation, projection preparation/application, or fixed-unit effects. Explicit regressions preserve `plan`, `status`, `doctor`, and `verify` as observational default-server commands. +- Remediated exact-tree traversal review: `~/../escape` and `~/src/../../escape` remain unexpanded and fail the unchanged shared `unsafe-path` validation. Both the shared generated-environment boundary and the production v1 environment caller have red-first regressions, preventing earlier caller normalization from bypassing the boundary. + +## Verification evidence + +- Focused migration/compiler/environment/reconciler/CLI: 8 files, 372 tests passed. +- Documented 13-artifact guard: 1 matching test passed and executed all seven v1 previews. +- Full `@mosaicstack/mosaic`: 59 files, 902 tests passed. +- Workspace build: 23 tasks passed. +- Root typecheck: 42 tasks passed. +- Root lint: 23 tasks passed. +- Root format check and `git diff --check`: passed. +- Built production CLI: canonical `~/src` remains in ready roster/YAML evidence; generated projection preflight succeeds with no blockers; a bare path flag emits one blocked JSON object, exit 1, and no stderr. +- Independent high-effort late-audit review: no blocker remained in the four assigned repair surfaces; separate exact-tree security audits found no qualifying newly introduced vulnerability. +- Exact temporary-index synthetic tree includes every tracked changed path; immutable SHA and duplicate reconstruction are recorded in the final handoff. + +## Risks / blockers + +- M4-001 emits rollback prerequisites/evidence only; executable rollback/canary and the managed/unmanaged host fixture remain owned by M4-002. +- Remote/connector entries remain inventory-only; later federation or connector reconciliation requires separately reviewed work. +- Existing environment data is only preflighted. Cutover backup, quarantine write, legacy removal, and generated projection application remain later reviewed effects. diff --git a/docs/scratchpads/771-kbn101-db-role-split.md b/docs/scratchpads/771-kbn101-db-role-split.md new file mode 100644 index 00000000..b5eb58ef --- /dev/null +++ b/docs/scratchpads/771-kbn101-db-role-split.md @@ -0,0 +1,183 @@ +# Scratchpad — KBN-101 DB runtime/migration role split (#771) + +- **Branch:** `docs/771-kbn101-db-role-split` +- **Base:** `main` `e9c4aa3` +- **Scope:** planning/documentation only; authorized files are PRD, Native Kanban task/shared/index docs, sitemap, this scratchpad, and the new KBN-101 contract. +- **Explicit exclusions:** source/runtime/config/deployment/secret/migration/compose/CI/lock/package/KBN-100 branch edits; no production mutation. + +## Objective + +Freeze an implementation-ready PostgreSQL role/connection split so the Gateway uses a non-owner runtime identity and only a dedicated migration phase uses an owner/migrator identity. Make real deployed-role certification—not synthetic role tests—a serial prerequisite of KBN-100 and KBN-105. + +## Intake and current-state evidence + +- Mission MVP is active; W3 Native Kanban/SOT is planning-complete. The task state shows KBN-010 as the predecessor and KBN-100 as the current schema slice. +- Current branch started at `e9c4aa3`; `.mosaic/orchestrator/{mission.json,session.lock}` were already runtime-modified and remain untouched. +- `packages/db/src/client.ts`, `migrate.ts`, and `drizzle.config.ts` resolve one `DATABASE_URL` (with default fallback). `packages/storage/src/adapters/postgres.ts` calls `runMigrations(this.url)`. +- `apps/gateway/src/database/database.module.ts` calls `storageAdapter.migrate()` at startup for PostgreSQL; this is the owner-runtime defect to remove in KBN-101 implementation. +- `packages/config/src/mosaic-config.ts`, installer wizard, local/federated compose, Portainer test stack, and `.woodpecker/ci.yml` currently expose one URL. PGlite has an existing explicit local migration path. +- Current KBN contract requires immutable events/checkpoints/artifacts/evidence, `RESTRICT`, and KBN-100 generated Drizzle consistency. It did not establish a deployable runtime identity split. + +## Frozen decisions + +1. `DATABASE_URL` is the non-owner runtime URL; `DATABASE_MIGRATION_URL` is migration-only. Both are required in their respective PostgreSQL phases; PGlite is the explicit local exception; migration never falls back to runtime/default/config URL. +2. PostgreSQL Gateway runtime never auto-runs migration/DDL. Dedicated migrator uses `pg_try_advisory_lock(hashtext('mosaic-schema-migration-v1'))`; replicas only check exact ordered Drizzle-ledger readiness and fail closed. +3. Roles are non-login `mosaic_platform_database_owner`, non-login `mosaic_schema_owner`, login/noinherit `mosaic_migrator`, non-login `mosaic_runtime_capability`, and login/inherit `mosaic_runtime`. Runtime inherits only its capability role with SET/ADMIN denied, has no owner/migrator membership, no unsafe attributes/ownership/DDL authority, and an explicit trusted search path. +4. Runtime gets mutable DML only as needed, but INSERT/SELECT only on `task_events`, `artifacts`, `task_checkpoints`, `task_checkpoint_artifacts`, and `approval_decision_artifacts`. KBN-100 still enforces RESTRICT/no-cascade. +5. Startup verifies effective role/ownership/attributes/inherited capability/TEMP/function-execute/ledger grants/search path/immutable denials/schema fingerprint without DSN exposure. It also requires authenticated CA/hostname-verified TLS. Stable sanitized errors and redaction rules are required. +6. N-1 retains single runtime URL only as a non-certified compatibility release; staged role provisioning/migration/runtime deployment then enforces the split. Rollback never injects migration URL into Gateway. +7. Vault target paths, rotation, deployment injection, CI, installer, compose, Portainer, and observability are separate one-card/one-PR handoffs. The migration-only file manifest includes `packages/db/drizzle.config.ts`; KBN-101 repairs the known PostgreSQL runner/journal ordering defect and proves a clean database applies every hash once before its foundation certificate. No application migration creates roles/passwords or hardcodes credentials. +8. KBN-101 foundation merges/certifies first. KBN-100 then rebases, restores Drizzle declaration/snapshot/journal consistency, and bounds procedural immutable-table grant/trigger/backfill work to its own slice. Because those immutable relations do not exist until KBN-100, KBN-101’s real deployed-role immutable-operation certificate follows KBN-100 and is the serial gate before KBN-105. + +## Assumptions + +- `standalone` and `federated` are all current PostgreSQL production-like modes; a future PostgreSQL tier inherits this contract unless versioned otherwise. +- Deployment will support a dedicated migration Job/one-shot command. A target that cannot run it cannot receive production/federated KBN certification. +- Canonical Vault target paths require deployment-owner verification before provisioning; the planning document does not claim they already exist. + +## Documentation produced + +- `docs/PRD.md`: bounded KBN-101 requirements and acceptance criteria. +- `docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md`: normative rc.5 implementation, threat, migration/rollback, evidence, and exact file DAG contract. +- `docs/native-kanban-sot/SHARED-CONTRACT.md`: rc.5 amendment preserving rc.4. +- `docs/native-kanban-sot/TASKS.md`: KBN-101 inserted before and blocks KBN-100; KBN-105 held. +- Native Kanban index and root sitemap links. + +## Validation plan + +1. Prettier only for changed Markdown. +2. Markdown link target/check checks scoped to modified docs. +3. Strict contract check with `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` (the frozen TypeScript contracts remain unchanged). +4. Diff allowlist proves only authorized documentation files changed, apart from pre-existing Mosaic runtime state. +5. Independent documentation/security self-review: role escalation, fallback, startup DDL, schema readiness, grants/default privileges, immutable tables, secret leakage, deployment and KBN-100 boundaries. + +## Review corrections + +Independent Codex review found two blockers and security review found two medium defects; all were remediated in the frozen contract: + +1. Split the KBN-101 certificate into a foundation role/schema-boundary certificate (before KBN-100) and real immutable-operation certificate (after KBN-100, before KBN-105). This preserves the requested KBN-100 block without requiring evidence for tables not yet created. +2. Removed the legacy owner-runtime exception. N-1 compatibility preserves variable/config shape only; the KBN-101 runtime refuses owner/migrator identity and current single-URL installs remain on their previous release until role cutover. +3. Introduced `mosaic_platform_database_owner` as a separate non-login platform role. `mosaic_schema_owner` owns application/ledger schemas only, not the database and has no database CREATE/ALTER/extension authority. +4. Replaced blocking `pg_advisory_lock` with `pg_try_advisory_lock` and the deterministic `DATABASE_MIGRATION_LOCKED` failure. +5. Review also flagged active `.mosaic/orchestrator` state. It was pre-existing launcher state and remains unstaged/uncommitted. +6. Second review added `packages/db/drizzle.config.ts` to the migration-only slice, mandates `DATABASE_MIGRATION_URL` with a missing-variable negative, grants runtime only `USAGE` plus `SELECT` on `drizzle.__drizzle_migrations`, and verifies/revokes its ledger writes. +7. Security review added `DATABASE_TLS_CA_CERT_PATH` / `DatabaseTlsConfigDto` with authenticated TLS and hostname/CA verification in production-like modes, explicit database `TEMPORARY` revocation/catalog denial testing, and default-PUBLIC function EXECUTE revocation with SECURITY DEFINER prohibited by default. +8. Final review corrected the runtime login to inherit only its capability role with SET/ADMIN denied, and moved the known hash-complete migration-runner/journal repair into KBN-101-03 before the foundation certificate. +9. Final manifest review added all live runtime DDL paths (`packages/storage/src/tier-detection.ts`, Gateway startup, and `fleet-backlog`) to KBN-101-02, requiring read-only extension probes and no PostgreSQL runtime auto-migration. It also requires KBN-101-04 to stop persisting either DSN into generated `.env`/`mosaic.config.json`, using only Vault/deployment references and injected variables. +10. Provisioning review separated the external privileged platform bootstrap actor from the NOCREATEDB database-owner role and added KBN-101-00. That IaC/bootstrap card owns fresh/existing database role/ownership/grant/Vault transition evidence and is a foundation-certificate dependency. + +## Results + +- `pnpm exec prettier --check` on every authorized Markdown file: PASS. +- Markdown link and whitespace checker on all seven authorized Markdown files: PASS. +- `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json`: PASS (frozen strict contracts unchanged). +- Codex code review iterated through role inheritability, hash-complete migration ordering, all reachable runtime DDL entrypoints, installer DSN persistence, and platform-bootstrap ownership; each finding was incorporated into the final frozen contract/DAG. The last security review found no new KBN-101 vulnerability; its sole low finding is the pre-existing unstaged Mosaic session-lock metadata, which is excluded from this commit. +- Commit: `82ce3252df38a687c50485f8d048b53ca8db5989` (`docs(#771): freeze database runtime role split`). +- Pre-push queue guard: `ci-queue-wait.sh --purpose push -B main` returned `state=unknown` without failure. The push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`: PASS. +- Pushed branch `docs/771-kbn101-db-role-split` at the exact commit above; no PR was opened, merged, or closed. `web1:mosaic-100` received the handoff with head, decisions, DAG, and validation. +- Awaiting independent security/Ultron review. + +## 2026-07-15 — rc.6 exact-head remediation session + +- **Objective / correction:** Replace the prior planning-author handoff and close every finding in the [independent exact-head report](../reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) against `da742ca2da4a2ff466916c818fe275c4f7ffd384`. The report is a verbatim durable copy of the task-supplied review artifact; scope remains documentation-only, `.mosaic` is excluded, and source/config/compose/CI/deployment/secrets/migrations remain untouched. +- **Finding 1 — closed DDL control plane:** rc.6 names `mosaic-db-migrator` as the sole application/CI/test PostgreSQL DDL runner, requires `DATABASE_MIGRATION_URL` before connection/DDL, inventories `runMigrations`, Drizzle config/scripts, `db:push`, storage CLI, adapter/Gateway startup, fleet-backlog, extension probes/bootstrap, direct federated integration DDL, CI, and future scripts, and specifies route/deny/test disposition for each. Tests use runner-prepared disposable PostgreSQL or invoke that runner; `db:push` is local-disposable-only and rejects production-like URLs. +- **Finding 2 — deployable TLS:** rc.6 freezes distinct runtime/migrator URL and CA/server leaf Vault/compose/Swarm secret identifiers, `0400` key and `0600` URL/cert/CA mount requirements, actual compose/Swarm service-DNS SANs, PostgreSQL TLS settings, legacy-client drain/termination plus `hostssl` enforcement, verified-TLS readiness ordering, fresh/existing transition, CA-overlap rotation/TLS-only rollback, and standalone plus federated/Swarm positive and missing/wrong CA/SAN/downgrade negatives. PGlite is explicitly non-PostgreSQL evidence. +- **Finding 3 — manifest/0009:** rc.6 defines manifest v1 canonical UTF-8 serialization and raw SQL-byte SHA-256, logical journal order, manifest ownership/grants, exact one-to-one observed hash tuple mapping, non-normative physical insertion order, safe original-0009 conditions, ambiguous-effect fail-closed recovery, and the full required reconciliation/backup test matrix. It preserves shipped 0009 bytes and forbids manual ledger adoption/insertion. +- **Finding 4 — advisory lock:** replaced `hashtext` with fixed signed-int4-safe `(1297044289,1262636593)` (`MOSA`,`KBN1`), one `max:1` runner session, close-on-crash semantics, and contention/crash/readiness/unrelated-key evidence. +- **Finding 5 — identifiers/search path:** selects `mosaic`, exact `pg_catalog,mosaic` per pooled connection and `SET LOCAL` transactions, plans audited public-object/extension/Drizzle relocation, forbids config-derived identifiers, limits bootstrap quoting to server-side `%I` on fixed allowlist, and requires injection/pool-reset negatives. +- **Finding 6 — safe DAG:** cards 00–07 are inactive prepared capability while owner-runtime remains N-1; KBN-101-08 is the one atomic activation release after platform roles/TLS and compatible code. Mosaic control plane/Jason alone can activate/rollback; no force-on-red, runtime bypass, or temporary compatibility survives the gate. The approved role graph, post-KBN-100 immutable certification, and KBN-105 gate remain unchanged. +- **Review remediation:** Codex review found the legacy plaintext cutover gap, missing URL-secret bindings, historical `public` migration incompatibility, non-reproducible checkout-byte hashing, CONNECT allowlisting regression, and undocumented direct-DDL operator instructions. rc.6 now requires drain/scale-to-zero, residual non-TLS session termination, `hostssl` with no `host` rule, zero plaintext-session proof, TLS-only post-enforcement rollback, distinct named runtime/migrator secret consumers, canonical Git-blob/LF manifest bytes, a runner-only owner-controlled legacy-public bootstrap followed by `mosaic` relocation, explicit CONNECT/TEMP revocation, and KBN-101-07 replacement of direct-DDL documentation. It also required the durable exact-head report link above. Pre-existing `.mosaic` runtime state remains excluded. +- **Validation:** Prettier on all changed Markdown, repository Markdown link/whitespace check, and strict native-kanban contract TypeScript passed before final staging; the final staged diff excludes `.mosaic`. No source-code TDD applies because this is contract-only remediation. + +## 2026-07-15 — rc.7 residual remediation session + +- **Objective / correction:** Close every residual in the independent exact-head rc.6 re-review at `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview-45ba3d6.md` for `45ba3d6ad4d5383f457a303c05bc816144cfa48a`, without changing source, compose, CI, deployment, migration, or secret artifacts. Only the existing authorized planning/documentation paths are eligible; pre-existing `.mosaic` state remains excluded. +- **Source-backed scope confirmed:** the active `federated-pgvector.integration.test.ts` executes `CREATE TEMP TABLE`; tracked `docker/init-db.sql` and `infra/pg-init/01-extensions.sql` both create `vector`; `migrate-tier.ts` advertises raw `CREATE EXTENSION`; and `tools/federation-harness/docker-compose.two-gateways.yml` is current plaintext two-PostgreSQL/two-Gateway topology. Current `schema.ts` has 36 default-schema `pgTable` declarations, 6 default `pgEnum` declarations, and an unqualified `vector` custom type; historical migrations contain `public` references. +- **Plan:** (1) make the finite DDL/static-bypass inventory and `DATABASE_URL`-only denial matrix exact, including the runner-prepared persistent pgvector fixture and migrated two-gateway harness; (2) freeze executable `public`-to-`mosaic` and `mosaic_extensions` transition, Drizzle ownership, object-catalog classes/order, eligibility and rollback tests; (3) bind repository/control-plane ownership, UID/GID validation, exact artifact/mount rules, and both gateway TLS topology; (4) correct PRD acceptance mapping and cross-document rc.7 status; then run formatting, link/contract, source-path, diff, review, commit, queue guard, and push. +- **Independent review closure:** initial Codex review found Gateway-key consumer wording, `CLAUDE.md` omission, final schema-owner set, and placeholder SANs; all are now explicit. Re-review found the legacy `0001` vector-type resolution problem and `docs/federation/SETUP.md` raw-DDL instruction; the legacy runner now uses only its fixed non-writable `pg_catalog,public,mosaic_extensions` history path, while runtime remains `pg_catalog,mosaic`, and the federation setup path is assigned to KBN-101-07/static inventory. Security review final verdict: no confident vulnerability. The review also repeated the pre-existing tracked `.mosaic` session-state concern; it remains deliberately unstaged/excluded by this task. +- **Completion evidence:** changed Markdown is Prettier-formatted; local links and strict native-kanban TypeScript passed; source-path inventory confirmed all current referenced paths (the new `apps/gateway/Dockerfile` is explicitly a planned KBN-101-05 artifact); diff check and authorized-doc allowlist passed. No source-code TDD applies to this documentation-only remediation. + +## 2026-07-15 — rc.8 exact residual remediation session + +- **Objective / correction:** Close all three HIGH findings in the independent rc.7 exact-head re-review at `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview2-0778eba.md` for `0778eba2db3c2dfbaca3af352b12ba0389d3552b`. Scope remains documentation-only: no source, config, Compose, CI, deployment, secret, or migration artifact changed; pre-existing `.mosaic/orchestrator` state remains excluded. +- **Finite authority closure:** KBN-101-06 now classifies exact current source/scripts/package bins, operator docs, and deploy manifests. `packages/db/src/index.ts` has explicit removal/compile-import negative ownership; `docs/fleet/backlog-conventions.md` and `docs/PERFORMANCE.md` now remove first-use/direct-Drizzle/Gateway-startup migration instructions and point to sole runner/readiness. Byte-immutable historical SQL, PGlite-only routines, negative-test literals, vendored/generated artifacts, and labeled historical reports are exact-path/category reviewed allowlists; unknown hits fail. The contract explicitly rejects relying on a naive token scan alone. +- **Executable and exclusive handoff closure:** KBN-101-03 exclusively owns the published `mosaic-db-migrator` bin, `packages/db/src/cli.ts`, private migrator modules, `docker/db-migrator.Dockerfile`, exact `--run|--verify|--help`, environment/argv limits, sanitized exits, and command/order tests. KBN-101-00 exclusively owns `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, and bootstrap tests. KBN-101-05 exclusively owns `tools/db/render-postgres-secrets.ts`, its tests, and deployment declarations, consuming the versioned bootstrap interface without overlap. +- **pgvector owner closure:** `mosaic_extension_owner` is dedicated NOLOGIN, available only to the external bootstrap actor during bootstrap; fresh vector/member ownership remains there. The contract records PostgreSQL's unsupported extension-owner transfer and forbids catalog mutation, ownership adoption, and `DROP CASCADE`. Approved-owner existing extensions use verified `ALTER EXTENSION ... SET SCHEMA`; legacy runtime-owned extensions fail closed to a controlled backup/shadow/runner/copy-evidence/quiesce/final-delta/atomic-switch/read-only-rollback migration. It requires `pg_extension.extowner`, member/schema/version, and runtime/migrator/schema-owner ALTER/DROP/member-update denial tests across clean, approved-owner, legacy shadow, partial/resume/rollback, and N-1. +- **Cross-document state:** PRD, KBN contract, shared contract, task decomposition, index, sitemap, current operator docs, and this scratchpad are rc.8-consistent. The only intended next action is a fresh independent exact-head re-review after validation/push. +- **Validation / review:** Prettier passed for all nine changed Markdown documents; local links passed (9 documents); `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` passed; source-path inventory passed (20 paths: 8 current, 12 explicitly planned); finite-authority requirement checklist and `git diff --check` passed. Manual documentation/security review checked the three requested paths, private-only runner boundary/exit contract, non-overlapping 00/03/05 ownership, extension-owner denial and shadow path, and `.mosaic` exclusion. No source-code TDD applies because this is contract-only remediation. +- **Delivery evidence:** committed `1423c2ad02b5471eab006fb4c878808e5b29c387` as `docs(#771): close role split rc.8 residuals`. Push queue guard returned `state=unknown` without error; push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`, all PASS; branch push succeeded. This final evidence append is committed next, then the exact remote head is verified. The only intended next action is a fresh independent exact-head re-review. + +## 2026-07-15 — rc.9 final residual remediation session + +- **Objective / correction:** Close the three findings in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview3-9cf5d2f.md` against exact head `9cf5d2f6641b14082dc3294e2a84d1fb4ccc019d`: move `mosaic_extensions` schema ownership to `mosaic_extension_owner`; replace all broad/conflicting KBN-101 card ownership with a complete disjoint exact-path/test manifest; and classify the current architecture-plan `db:migrate` instruction with pinned scanner mechanics. Scope remains documentation-only; no source/config/Compose/CI/deployment/secret/migration artifact and no `.mosaic` path may be modified. +- **Plan:** inspect current tracked source topology to name only existing paths; update the normative contract first and synchronize PRD/shared/task/index/sitemap/version language; run Prettier, changed-doc links, strict contract TypeScript, source/path and manifest-overlap checks, diff allowlist, independent documentation/security review; then stage docs only, commit, queue-guard, push, and verify exact remote SHA. No source-code TDD applies because this is contract-only remediation. +- **Closure implemented:** rc.9 makes `mosaic_extension_owner` create and own `mosaic_extensions`, `vector`, and members; the external bootstrap actor alone temporarily `SET ROLE`s for fresh/approved-owner work, while schema owner has only `USAGE` for legacy type resolution and never temporary `CREATE`. Catalog/default-ACL plus direct DDL/member denials now cover runtime, migrator, and schema owner through fresh, relocation, shadow/resume, and rollback evidence. +- **Delivery decomposition:** Replaced broad ownership with complete disjoint 00–09 manifests, exact tests/evidence, producer-before-consumer edges, and an explicit no-intermediate-deploy N-1 activation statement. `packages/storage/src/{cli,migrate-tier}.ts` belongs only to -02; the current tracked init artifacts are retired by -02 as direct-DLL closure; -03 owns all runner/index/migrate/config/schema assets and exact compiled-bin/image mapping; -07 owns docs only; -08/-09 own evidence only. +- **Classifier closure:** -06 has exact scanner/inventory/matrix paths, canonical inventory fields, classes/dispositions, fixed token/rule set, exact allowlist categories/restrictions, and self-test requirements for unknown, duplicate-owner, ownerless, missing-path, and historical masking cases. The architecture plan now marks direct `db:migrate` superseded and uses `mosaic-db-migrator --run`. +- **Validation:** Prettier check, strict native-kanban contract TypeScript, changed-document local-link resolution, `git diff --check`, and an automated manifest-overlap/owner/current-source-path check passed. Targeted documentation/security review verified role ownership/default privileges/search path/preflight/legacy/shadow/rollback consistency, disjoint manifests/DAG/activation, scanner mechanics, exact bin/entrypoint, and no `.mosaic` staging intent. No source-code TDD applies because this is documentation-only remediation. +- **Next:** stage documentation only, commit, queue-guard, push, verify exact remote SHA, then wait for fresh exact-head review. +- **Delivery evidence:** committed `8cbad2bcd9bc7507052f74f35670ef7c8e39e44e` as `docs(#771): close role split rc.9 residuals`; `ci-queue-wait.sh --purpose push -B main` returned `state=unknown` without error; push-hook `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` all passed; push succeeded and `origin/docs/771-kbn101-db-role-split` resolved to that exact SHA. Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains modified but intentionally unstaged/excluded. The only next action is a fresh independent exact-head re-review. + +## 2026-07-15 — rc.10 Ultron NO-GO remediation intake + +- **Objective / scope:** Close only HIGH-1 and HIGH-2 in `/home/hermes/agent-work/reviews/771-kbn101-ultron-11f09a1.md` against exact head `11f09a15e43e72afda6a0374668996b4bda9e536`. Documentation only: preserve approved content; no source/config/Compose/CI/deploy/secret/migration edits and no `.mosaic` staging. +- **Plan:** (1) replace the impossible `NOLOGIN NOSUPERUSER` extension owner with the exact non-login, zero-member `NOLOGIN SUPERUSER` external-control exception and document the non-delegable superuser residual; (2) require the audited external superuser session to `SET ROLE`/`RESET ROLE` for fresh, approved-owner, and shadow extension work, then prove catalog ownership and all service-role denial; (3) assign the active migrate-tier guide exclusively to KBN-101-07, add its active secure route to the -06 inventory/matrix/scanner schema, and freeze `--target-url-file /run/secrets/mosaic_migrate_target_url` plus pre-migrated target/dedicated non-DDL importer requirements; (4) synchronize PRD/shared/tasks/index/sitemap/guide and rc.10 status; (5) validate formatting, links, contracts, source paths, finite operator inventory, diff, review, commit, queue guard, push, and exact remote SHA. +- **Target-image evidence before edits:** local `pgvector/pgvector:pg17` control file reports `default_version = '0.8.2'`, `relocatable = true`, and no `trusted`/`superuser` override (untrusted PostgreSQL extension). An isolated PostgreSQL 17 container proved a `NOLOGIN SUPERUSER` `mosaic_extension_owner` can create `mosaic_extensions` and `vector` under external-superuser `SET ROLE`, returns to the external session after `RESET ROLE`, has `rolcanlogin=false`, `rolsuper=true`, zero role members, exact extension/schema and owner-bearing-member ownership, and denies `SET ROLE`, `ALTER EXTENSION`, `DROP EXTENSION`, and schema ownership changes to runtime, migrator, schema owner, and data importer. Ownerless PostgreSQL catalog member classes (`pg_am`, `pg_cast`) were intentionally not misrepresented as ownable members. +- **TDD decision:** skipped as not applicable: this is a documentation-only contract remediation. Future KBN-101-00/-02/-06 tests are specified as the situational evidence; no source/test artifact is permitted in this task. +- **Final scope correction:** Per control-plane direction, remediation remains bounded to the two Ultron findings. The active guide is explicitly a non-operative KBN-101 contract until its owned implementation/activation lands; no additional design, source, CI, deployment, secret, or test artifact was added. +- **Validation evidence:** target-image/container role proof PASS (pgvector `0.8.2`, `relocatable=true`, trusted absent/untrusted; external-superuser `SET ROLE`/`RESET ROLE`; exact extension/schema/owner-bearing-member ownership; zero membership and service-role denials). Changed-doc Prettier, strict native-kanban contract TypeScript, local-link resolver (8 docs), finite operator-doc inventory (one active KBN-101-07 route with no credential argv in executable blocks), source-path check (6 current paths), and `git diff --check` PASS. Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains intentionally excluded. + +## 2026-07-15 — rc.11 exact-head re-review remediation intake + +- **Objective / scope:** Close only HIGH-1 and HIGH-2 in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview5-f60144e.md` against `f60144eb3eab6234ab01bda592052081c777897e`: target-bind the non-DDL tier importer with a runner-produced signed attestation, and disposition every current non-normative documentation scanner hit, including the active user-guide route and federation historical status. Documentation only; no source/config/Compose/CI/deployment/secret/migration edits and no `.mosaic` staging. +- **Frozen decision:** `mosaic-db-migrator --verify` is trusted only after TLS/identity/manifest/schema verification and signs a credential-free JCS/Ed25519 v1 artifact from a runner-only fixed root-owned private-key file. The artifact binds secret version/exact URL-file digest, canonical TLS/CA/SPKI/server/database/importer/manifest/schema identity, issued/expiry/nonce, and producer build/correlation; importer gets pinned public key plus artifact only. It validates both files and all bindings before target connection, opens/digests/connects from one in-memory URL read, validates server identity before DML, and distinguishes zero connection from zero DML. Key overlap/revocation, secret-rotation invalidation, replay cache, atomic rename, and sanitized errors are mandatory. +- **Ownership:** -03 owns producer/signing DTO/tests; -02 importer interface/verification tests; -05 key/artifact mounts/render tests; -06 inventory/matrix and non-masking scanner tests; -07 operator guide. The exact manifests remain disjoint. +- **Operator correction:** `storage migrate` is schema-wrapper delegation only; legacy `--from hot --to cold` tier-copy guidance is unavailable. Secure tier copy is `migrate-tier` with `--target-url-file` plus `--target-attestation-file`. Federation M1 task language is status-only and adjacent KBN-101 text says it authorizes no current DDL. +- **TDD decision:** skipped as not applicable: this bounded task changes documentation only. Future -02/-03/-05/-06 tests are specified as the required implementation evidence. +- **Validation / review:** Prettier PASS on all 18 changed Markdown/root-doc files; `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` PASS; changed-doc local-link resolver PASS (71 links); full current docs scanner/disposition PASS (10 non-normative paths, 4 normative-contract paths; no unknown active command); legacy `storage migrate --from` and raw target-URL bypass scan PASS; attestation field/interface assertion PASS; exact source ownership/manifest-overlap assertion PASS; `git diff --check`, docs-only scope, and secret-leak scan PASS. Manual documentation/security review verified key isolation, JCS/detached signature, secret-file hash as non-secret evidence, verification ordering/TOCTOU/replay/rotation, no connection vs zero DML, non-masking scanner class, status-only federation history, and no regression to pgvector closure. No source-code TDD applies. +- **Delivery evidence:** committed `6227f076c819bd124383851633b16d4ef9c88a98` as `docs(#771): bind tier importer to verified target`; staged scope was 18 documentation files only and excluded `.mosaic`. Pre-push queue guard returned `state=unknown` without failure. Push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`: PASS. Branch push succeeded. Verify the exact remote SHA after this final delivery-evidence append, then idle for independent exact-head re-review and Ultron reverify. `.mosaic/orchestrator/{mission.json,session.lock}` remains pre-existing and excluded. + +## 2026-07-15 — rc.12 bounded deployable-importer/SETUP remediation plan + +- **Objective / scope:** Close the two HIGH findings in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview6-65663d4.md` against exact head `65663d4f72f2ace5148bce9aeba04b5a8d5beee9`. Documentation/tracking only; preserve every earlier closure; do not modify source, deployment, Compose, CI, migration, Vault, or `.mosaic` artifacts. +- **Plan:** (1) make KBN-101-05 own canonical KV-v2 importer URL/version provenance, separate immutable generation-pinned renderer consumers, importer CA/public-key/attestation mounts, fixed importer/migrator identities, safe-fd lifecycle, isolation/rotation/TOCTOU/error evidence, and -02/-03/-06 handoffs; (2) convert `docs/federation/SETUP.md` to a non-operative N-1 reference with only the required external-bootstrap → TLS/roles → runner `--run` → `--verify` → Gateway-readiness sequence; (3) broaden scanner grammar plus path-specific semantic negatives so indirect first-boot/startup/init/Compose authority cannot be masked by a named, normative, or status record; (4) synchronize PRD/shared/tasks/index/sitemap/federation task state and this scratchpad; (5) run formatting, links, strict contracts, complete-doc scanner/semantic assertions, diff/operator inventory, material/manifest overlap, review, docs-only stage, commit, queue guard, push, and exact remote-SHA verification. +- **TDD decision:** skipped because this bounded change is documentation-only; the affected -02/-03/-05/-06 implementation tests and scanner semantic fixtures are specified as mandatory future evidence. +- **Review correction:** independent Codex review found the initial `10003` producer → immutable `10002` importer artifact handoff impossible. rc.12 now specifies the required privileged deployment handoff controller: after runner success it safe-opens/verifies producer artifact plus generation, exact-byte copies/fsyncs/atomically renames to a distinct `10002:10002` `0400` importer mount, seals it read-only, and starts no importer on partial/wrong-generation/owner/mode failure. It receives only a root-owned non-secret expected-version/URL-digest/generation descriptor plus public verifier key, never URL bytes/private key; producer/importer share no writable file or mount. Dry-run nonce consumption now requires fresh `--verify` and artifact before `--yes`; the active-route schema requires `targetCredentialVersionFile`. Pre-existing `.mosaic` state is confirmed excluded from staging. +- **Validation result:** changed-doc Prettier and `git diff --check` PASS; strict `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` PASS; contract/SETUP material and non-operative semantic assertions PASS. Pending final docs-only stage, commit, queue guard, push, and remote-head verification. + +## 2026-07-15 — rc.13 MILESTONES semantic-scan remediation intake + +- **Objective / scope:** Close only HIGH-1 from `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview7-7365dcf.md` against `7365dcf15c09262a46132b9c011769ad98243641`. Documentation/tracking only: assign `docs/federation/MILESTONES.md` exclusively to KBN-101-07 and its exact former startup-extension wording to the KBN-101-06 semantic fixture/inventory; replace the wording with a non-operative historical/status disposition. No source, deployment, Compose, CI, Vault, migration, provider, or `.mosaic` artifact is authorized. +- **Frozen remediation:** runtime/startup extension provisioning is superseded and forbidden. The sole eligible sequence is external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway readiness. The MILESTONES record authorizes no current DDL, Compose/init, or startup path. The scanner must prove the exact former wording fails before any inventory/status-only mask and rerun its full current-doc operator/deploy-manifest scan outside reports/scratchpads. +- **Plan:** update only MILESTONES plus the exact KBN-101 contract/inventory/manifests and necessary PRD/shared/task/index/sitemap/version/status references; run full lexical+semantic scan, Prettier, links, strict contract TypeScript, diff and manifest-overlap checks; stage docs only (excluding pre-existing `.mosaic`), commit, queue-guard, push, and verify the exact remote SHA. No source-code TDD applies because this bounded task changes documentation only. +- **Remediation result (pre-commit):** `MILESTONES.md` now makes runtime/startup extension provisioning superseded and forbidden, with only external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `--verify` → Gateway readiness; it authorizes no current DDL/Compose/init/startup path. The KBN-101-07 manifest/inventory is exclusive and KBN-101-06 documents the exact former wording as a semantic negative that fails before inventory masking. Full current-doc scan outside reports/scratchpads: 103 Markdown files, 10 lexical-hit paths classified, 2 owned Compose-before-runner references, and zero ownerless indirect/literal routes. Prettier, strict native-kanban TypeScript, local links (64/0), diff check, and 95-path manifest-overlap reconstruction (0 overlaps; MILESTONES only -07) passed. Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains excluded. +- **Delivery checkpoint:** committed remediation as `237bac81c93dc4305470cea23a67e4ced730bd61` (`docs(#771): close MILESTONES startup authority`). Push queue guard returned `state=unknown` without failure; the push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`, all PASS; the remote branch resolved to that exact SHA. This final evidence append is committed next, then the exact remote head is verified and the branch waits for independent exact-head rereview/Ultron reverify. `.mosaic` remains unstaged. + +## 2026-07-15 — rc.13 current-document safety remediation intake + +- **Objective / scope:** Close only HIGH-1 and HIGH-2 in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview8-aeacc70.md` against exact head `aeacc702353740aad0f2f086974cc0670e360d1d`. This is documentation/tracking only. Preserve all prior gates; do not edit source, Compose, deployment artifacts, CI, migrations, Vault data, reports, or `.mosaic`. +- **Source-backed decision:** Current `docker-compose.yml` mounts `infra/pg-init` into PostgreSQL init and that SQL creates `vector`; it cannot be used as a current PostgreSQL start route before KBN-101 bootstrap/runner artifacts exist. The checked-in configuration declares a supported `local` PGlite tier (`DEFAULT_LOCAL_CONFIG` and `tier-detection` both establish in-process PGlite with no external service probe), so docs may retain a local/PGlite route and start only non-PostgreSQL Compose services such as `valkey`. +- **Plan:** (1) replace the README and dev-guide Compose-first PostgreSQL instructions with a PGlite/no-PostgreSQL developer path and an explicit held PostgreSQL/federated future activation sequence; (2) replace the deployment quick-start and bare-metal production procedure with non-operative status, no production `.env`/automatic dotenv/`EnvironmentFile`/credential export-or-argv/restart guidance, and only a non-executable future renderer/Vault generation-pinned process-exec or `LoadCredential` schematic; (3) expand KBN-101-06/-07 semantic fixture/disposition language to fail the exact former README/dev/deployment Compose-first sequences and production credential routes before ownership/status masking; (4) synchronize PRD/shared/tasks/index/sitemap/status/version and this scratchpad; (5) run formatting, links, strict contract TypeScript, full current-doc lexical+semantic scan outside reports/scratchpads, manifest-overlap, review, docs-only stage, commit, queue guard, push, and exact remote-SHA verification. +- **TDD decision:** no source or fixture implementation may be changed in this documentation-only remediation. The -06 future fixture requirements are frozen as acceptance evidence; validation here is static semantic inventory plus documentation quality gates. +- **Correction from independent review:** The initial local-Gateway PGlite wording was unsafe. `apps/gateway/src/main.ts` loads daemon/root/app-local environment files before tier selection; an inherited daemon `DATABASE_URL` can select PostgreSQL, whose current startup reaches extension creation and migrations. No source is authorized in this docs-only task. The remediation therefore holds Gateway/Web local startup, preserves only PGlite data-layer plus selected non-PostgreSQL Compose work, and assigns KBN-101-02 the fail-closed daemon/inherited/root/app-local DSN and non-local-tier rejection before connection/DDL, with a regression proof. The future renderer boundary remains KBN-101-05. +- **Remediation evidence:** Removed active PostgreSQL Compose-first and production credential guidance from README, CLAUDE, dev/deployment, and the residual historical TUI/MCP routes; local documentation now permits only PGlite data-layer/non-PostgreSQL Valkey work while Gateway/Web startup is explicitly held. KBN-101-06/-07 now freeze exact former README/dev/deployment Compose sequences plus production credential patterns as pre-classification semantic negatives. Independent review surfaced the current daemon/root/app dotenv loader as an unsafe source boundary; no source is authorized here, so the docs hold that startup and assign fail-closed removal/regression proof to KBN-101-02. +- **Validation:** Prettier PASS (12 changed docs); local-link resolver PASS (71 links); `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` PASS; full README/CLAUDE/docs inventory PASS (104 documents, 0 active non-normative Compose/init/production-credential violations); manifest reconstruction PASS (10 cards, 95 declared path tokens, 0 overlaps); `git diff --check` PASS. Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains intentionally unstaged. +- **Final route correction:** Held the residual MCP environment/restart and historical TUI smoke-test routes after review showed they could bypass the Gateway local-start hold; no bearer-token-over-HTTP or Gateway restart route remains in this remediation scope. +- **Delivery:** committed `7cc156b777189ee89448e4d569a8b3f69560a240` (`docs(#771): hold unsafe database startup routes`) and `d8f935c20ade835aa3ec03fe5d6961885d8b5f0b` (`docs(#771): record final route correction`). Push queue guard returned `state=unknown` without failure; both pushes completed and the remote matched `d8f935c` before this final delivery-evidence append. `.mosaic/orchestrator/{mission.json,session.lock}` remains pre-existing and unstaged. Await a fresh independent exact-head re-review/Ultron verification. + +## 2026-07-15 — rc.15 exact one-finding runner/legacy-CI remediation intake + +- **Objective / scope:** Close only HIGH-1 in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview9-be0ebfd.md` against `be0ebfdc6a2b32a0ab6989117ebbb12f43854d71`. Documentation/tracking only: no source, Compose, CI, deployment, migration, Vault, reports, or `.mosaic` edit. +- **Plan:** Replace imperative current runner routes in the architecture plan and PERFORMANCE with one explicit non-operative future procedure; make fleet backlog current behavior PGlite-only and PostgreSQL held; classify README's checked-in direct CI `db:migrate` as legacy N-1/uncertified/non-authorizing pending KBN-101-06 removal; then extend the future KBN-101-06 lexical/semantic inventory contract so unqualified runner/current-CI authority fails before masking while only the complete named held procedure passes. Synchronize required contract/PRD/shared/task/index/sitemap state, run full docs scan and document gates, stage docs only, commit, queue-guard, push, and verify remote SHA. +- **TDD decision:** not applicable: the user authorizes documentation only and the named -06 fixture/inventory files do not yet exist; the contract records their required future implementation evidence. +- **Remediation / review closure:** Architecture, PERFORMANCE, federation SETUP/MILESTONES, deployment/dev/migrate-tier, and README now use one Markdown-bounded `Held future procedure` form where needed: non-operative/no-current-command-authority; KBN-101-00/-03/-05; external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. Any runner hit outside that section is a -06 semantic failure. Fleet backlog current behavior is PGlite-only. README accurately records the checked-in direct CI migration as an active, isolated-disposable-database, uncertified legacy N-1 DDL exception that is non-authorizing as an operator route and pending -06 removal; it no longer falsely claims the current CI role lacks DDL capability. Independent Codex review found and this pass closed the readiness-endpoint, standalone-verify, CI-factuality, and scanner-boundary findings. Its only residual finding concerns pre-existing tracked `.mosaic/orchestrator` runtime state, which is explicitly excluded and unstaged by task scope; security review found no vulnerability. +- **Validation:** changed-doc Prettier PASS; `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` PASS; changed-doc local links 72/0; `git diff --check` PASS; full README/CLAUDE/docs lexical+semantic scan outside reports/scratchpads PASS (106 Markdown documents; 7 operator documents with runner tokens; zero unqualified future-runner/current-CI authority routes); KBN-101 manifest check PASS (10 dependency-ordered cards; -07 docs/-06 fixtures disjoint); docs-only allowlist PASS (16 docs, pre-existing `.mosaic` excluded). +- **Delivery evidence:** committed `d857463a8a4658e34a77177737860cf82cc26ac6` (`docs(#771): hold unimplemented runner routes`). Pre-push queue guard returned `state=unknown` without failure; the push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`, all PASS. Push succeeded and `origin/docs/771-kbn101-db-role-split` matched `d857463a8a4658e34a77177737860cf82cc26ac6`. This evidence append is committed and pushed next; `.mosaic/orchestrator/{mission.json,session.lock}` stays pre-existing, unstaged, and excluded. Await exact-head independent re-review/Ultron reverify. + +## 2026-07-15 — rc.16 exact one-finding generic-wrapper remediation intake + +- **Objective / scope:** Close only HIGH-1 in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview10-18e253c.md` against exact head `18e253c8790bdbb5bc30a06c116472213b83b22f`. Documentation/tracking only: no source, Compose, CI, deployment, migration, Vault, report, or `.mosaic` change. +- **Source-backed correction:** `packages/storage/src/cli.ts` currently labels `storage migrate` a thin wrapper for `pnpm --filter @mosaicstack/db db:migrate` and executes that direct Drizzle command with `execSync`; no `mosaic-db-migrator` executable exists. Therefore the README commented form and user-guide executable form must not describe runner delegation or provide current command authority. +- **Plan:** Remove the current wrapper command from README/user-guide command guidance; record it as legacy N-1, uncertified, non-operative, and forbidden pending KBN-101-02/-03/-06/-08 activation. Retain only the held future ordered bootstrap → TLS/roles → runner `--run` → `--verify` → readiness sequence and the separately held secure migrate-tier route. Extend KBN-101-06's future semantic fixture/matrix with both exact former forms (including the README commented code-fence form), requiring their failure before inventory/status masking and a source-consistency assertion that direct Drizzle wrapper source cannot be described as runner delegation. Synchronize status/version references only where required, then run document gates, stage docs only, commit, queue-guard, push, and verify the remote SHA. +- **TDD decision:** not applicable: this bounded task changes documentation only; the required future -06 semantic/source-consistency fixtures are specified as implementation acceptance evidence. +- **Remediation / validation:** README and user-guide remove the generic wrapper from command guidance and state the direct-Drizzle current-source truth, legacy-N-1/uncertified/non-operative MUST-NOT-INVOKE boundary, named -02/-03/-06/-08 activation cards, future external-bootstrap → TLS/roles → runner `--run` → `--verify` → readiness sequence, and separately held secure migrate-tier route. The KBN-101 rc.16 contract records both exact former forms (README commented code fence and user-guide executable code fence), requires failure before inventory/ownership/status masking, and requires the direct-Drizzle/no-runner-bin source-consistency proof. Prettier passed on all nine changed Markdown documents; changed-doc local links passed (72/0); strict native-kanban contract TypeScript and `git diff --check` passed; full README/CLAUDE/docs scan outside reports/scratchpads passed (106 documents, zero non-normative executable generic-wrapper or false runner-delegation route); and manifest validation passed (10 cards, 90 exact tokens, zero overlaps). Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains excluded. diff --git a/docs/scratchpads/791-upgrade-config-protection.md b/docs/scratchpads/791-upgrade-config-protection.md new file mode 100644 index 00000000..9e7199a7 --- /dev/null +++ b/docs/scratchpads/791-upgrade-config-protection.md @@ -0,0 +1,529 @@ +# Scratchpad — #791 Upgrade config protection (ms-791 worker lane) + +**Lane:** web1:ms-791 → reports to MS-LEAD (web1:mosaic-100). Do NOT contact Jason/Mos directly. +**Worktree:** `/home/hermes/agent-work/stack-agents-dir-791`, branch `feat/791-upgrade-config-protection` +off `origin/main` `9745bc3f` (verified exact head). + +## Mission prompt (verbatim intent) +Protect operator-owned config under `~/.config/mosaic` from framework-upgrade wipes. Ratified +combination (Mos-approved, do NOT re-litigate): (b) strict ownership separation [PRIMARY] + (a) +transactional pre-update snapshot [safety net] + (d) regeneration-from-SSOT [recovery]. (c) periodic +timer DEFERRED. HARD GATE: unit test that an upgrade run touches NO path outside the manifest. +Design-first: write design doc, send to MS-LEAD, WAIT for confirmation before impl. + +## Session 1 (2026-07-16) — Phase 1 design + +### Evidence gathered (wipe mechanism, file/line) +- `mosaic update` → `update-checker.ts:509` `buildReseedCommand` → `bash install.sh` + (`MOSAIC_SYNC_ONLY=1`, `MOSAIC_INSTALL_MODE=keep`). +- Wipe = `packages/mosaic/framework/install.sh:199` `rsync -a --delete` + `PRESERVE_PATHS` denylist + (`install.sh:47`). cp-fallback `install.sh:223` `find ... -exec rm -rf`. +- Denylist gaps → WIPED: `agents/*.conf`, `policy/*.md`, `*.local.md`, harvester/SOP, + `tools/_lib/credentials.json`. +- Stale comment `update-checker.ts:492` claims `*.local` preserved — PRESERVE_PATHS has no such entry. +- TS path `file-adapter.ts:157` → `file-ops.ts:66` `syncDirectory` = non-destructive copy-overlay, BUT + its preserve list (`file-adapter.ts:164`) already DRIFTED from install.sh (missing `fleet/backlog`, + `fleet/roles.local`). Evidence for single shared manifest SSOT. +- Existing snapshot (`install.sh:76`) = /tmp, crash-trap only, deleted on success → inadequate; no + `mosaic restore`. +- `fleet-reconciler.ts:93,234` already has `regenerate-projections-from-roster` phase separate from + lifecycle → `mosaic fleet regen` = thin projection-only wrapper (no restart), no FCM-M4/M5 preemption. + +### Design decisions +- **(b)** Invert to allow-list: shared `framework/framework-manifest.json` (framework globs + + operatorReserved carve-outs); resolve per-path, deny-wins; **UNKNOWN ⇒ operator (fail-safe)**. + Mechanism: drop `--delete`; non-deleting bulk copy + explicit manifest-scoped prune pass (iterate + framework globs only → operator/unknown structurally unreachable). Pure prune-planner fn for tests. +- **(a)** Snapshot to `~/.local/state/mosaic/backups/pre-update-/` 0700/0600, retention N=5, + post-sync verify+restore, `mosaic restore --list/--from`. No secret values in output. +- **(d)** `mosaic fleet regen` projection-only, preview-first, never restart. +- HARD GATE test includes a deliberately-unanticipated operator path to prove fail-safe default. +- **PR split:** PR1 manifest+guard (root fix, ships alone) → PR2 snapshot/restore (secrev) → PR3 + regen+docs. PR2/PR3 depend on PR1. + +### Status +Design doc written: `docs/design/791-upgrade-config-protection.md`. Sent to MS-LEAD. + +## Session 1 (cont.) — MS-LEAD CONFIRMED → Phase 2 GO +All 4 asks approved. Binding conditions: +- TDD tests-first, red-first proof per PR; ≥85% new-code; co-located `*.spec.ts`; never `--no-verify`. +- HARD GATE test (§2.4, unanticipated sentinel survives byte-identical + mtime unchanged) = MERGE-BLOCKING for PR1. +- Manifest-completeness test (§6.2) required. +- Bash+TS read ONE shared `framework-manifest.json`; parity test (§6.1) required (closes #631 drift class). +- UNKNOWN⇒operator (rule 3) non-negotiable. Keep prune-planner PURE. +- `fleet regen`: NEVER restart; dry-run default, `--write` to apply; "never issues restart" test mandatory. +- Independent review every PR; PR2 dedicated secrev. +- One PR at a time through DAG. Report PR1 exact head + red→green evidence for review commission. + +### Now: implementing PR1 (manifest + resolver + non-deleting sync + scoped prune + guard tests). + +## Session 2 (2026-07-16) — PR1 built, tests-first, red→green proven + +Deviation noted to MS-LEAD in PR: manifest is `framework-manifest.txt` (line-oriented), NOT `.json`. +Rationale: keep the bash installer free of a python3/jq dependency. The "ONE shared file, parity- +tested" requirement is honored — `manifest-parity.spec.ts` drives the bash resolver as a subprocess +and asserts byte-identical ownership vs the TS resolver over 34 probe paths spanning every class. + +### PR1 artifacts +- SSOT: `packages/mosaic/framework/framework-manifest.txt` ([framework]/[operator], deny-wins, fail-safe). +- TS resolver: `src/framework/manifest.ts` (pure: parse/matchGlob/resolveOwnership/frameworkSubtreeRoots/ + planPrune) + `manifest.spec.ts` (18 tests incl. planPrune property test + §6.2 completeness). +- Bash resolver: `framework/tools/_lib/manifest.sh` (compiled globs → fork-free `manifest_is_framework`; + CLI `resolve|subtree-roots|classify`). Sourced by install.sh. +- HARD GATE (§2.4): `framework/tools/quality/scripts/test-upgrade-manifest-guard.sh` — keep-mode reseed, + 10 operator sentinels (incl. unanticipated `unknown-operator-dir/x`, `harvester/sop.md`, + `fleet/my-fleet.yaml`) survive byte-identical + mtime-unchanged; retired framework file pruned; + secret value absent from output. RED=31 fail (orig install.sh) → GREEN=48 pass (fixed). +- install.sh: keep mode now manifest-driven (`sync_framework_keep`, no `--delete`); overwrite unchanged. + PRESERVE_PATHS denylist deleted. +- TS sync: `file-ops.syncDirectory` gains `isOperatorOwned` guard; `file-adapter.syncFramework` derives + it from `loadManifest` — hardcoded (drifted) preservePaths deleted. Fixture uses the REAL manifest. +- Parity: `manifest-parity.spec.ts` (§6.1) — bash↔TS agree on 34 paths + subtree roots. +- Migration matrix `test-install-migration.sh`: F6 flipped — `my-fleet.yaml` now MUST survive (fail-safe). +- CI: new merge-blocking `upgrade-guard` step (`.woodpecker/ci.yml`) runs both bash suites (adds rsync). +- update-checker.ts reseed comment corrected to the manifest model. + +### Gates (all green) +- `pnpm typecheck` ✓ · `pnpm lint` ✓ · `pnpm format:check` ✓ +- Full mosaic vitest: 1062 passed (cli-smoke needs `pnpm build` first — build-artifact dep, not this change). +- HARD GATE 48/48 · migration 21/21 · parity 3/3 · manifest 18/18 · file-adapter 8/8. + +### PR opened + reported (2026-07-16) +- **PR #802** http://git.mosaicstack.dev/mosaicstack/stack/pulls/802 — base `main`@`9745bc3f`, + head `34e55d4a` (commit `feat(mosaic): manifest-owned upgrade guard…`). 15 files, +1160/-142. +- Reported PR head + red→green evidence to MS-LEAD (web1:mosaic-100); queued (lead busy). + Standing by for the independent-review commission at head `34e55d4a`. +- **TWO items flagged to MS-LEAD for decision (awaiting reply):** + 1. Deviation `.txt` vs `.json` — confirm accept (parity-tested) or convert to `.json`+jq. + 2. `pr-create -i 791` appended `Fixes #791` → would auto-close the tracking issue on PR1 merge + while PR2/PR3 remain. Recommended edit to `Part of #791`; awaiting go-ahead to patch PR body. +- DO NOT start PR2/PR3 until PR1 merges (DAG; one PR at a time). + +### MS-LEAD ruling → #797 ledger-survival sentinel folded into PR1 (2026-07-16) +MS-LEAD ruled both my decisions: (1) `.txt` format ACCEPTED (parity must be strict/merge-blocking incl. +format edge cases + negative probe); (2) trailer `Fixes #791`→`Part of #791` APPROVED (patched PR #802 +body via Gitea API — tracking issue no longer auto-closes on PR1 merge). Plus Mos-ELEVATED merge-blocker +(spec `~/agent-work/planning/epic-796/791-ledger-survival-sentinel-SPEC.md`): #797 Runtime Session Ledger +must survive upgrade. Two coupled deliverables landed in PR1: +- (i) Carve-out: `fleet/run/**` was ALREADY an explicit `[operator]` entry — glob matches the spec's + pinned `fleet/run/**` EXACTLY, so NO divergence to route back to planner-opus. Strengthened its comment + to name the ledger (`fleet/run/sessions/` events.ndjson + ledger.json) so it is unmistakably load-bearing. +- (ii) HARD-GATE sentinel: seeded populated ledger (events.ndjson 3 events + ledger.json node+edge+gen, + 0600 under 0700) into test-upgrade-manifest-guard.sh sentinels; asserts byte-identical + mtime-unchanged + + dir-perms unchanged. Negative control (retired framework file IS pruned) relabeled explicitly. + HARD GATE now 58/58 (was 48). +- Decision-1 parity hardening: format-edge fixtures (comments/blanks/whitespace, duplicate+overlapping + globs deny-wins, section/glob-ordering independence) + explicit UNKNOWN→operator negative probe, driven + through BOTH resolvers via MANIFEST_FILE override. Parity 7/7 (was 3). +- RED-FIRST honesty note: the bash ledger sentinel stays GREEN even against the pre-fix installer (the + ledger was incidentally safe from the rsync --delete bug; overall pre-fix run 30/58 as expected). The + carve-out's TRUE load-bearing value (deny-wins if framework ownership ever broadens to `fleet/**`) is + isolated by a dedicated resolver-seam red→green in manifest.spec.ts: WITHOUT `fleet/run/**` operator + entry + hypothetical `fleet/**` framework → ledger resolves framework and planPrune DELETES it (RED); + WITH the carve-out → deny-wins → operator, unprunable (GREEN). manifest.spec.ts 21/21 (was 18). +- Gates all green: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1069 passed · HARD GATE 58/58 + · migration 21/21. Committing FORWARD on the branch (NOT rebasing 34e55d4a out from under review). + +### MS-LEAD REQUEST CHANGES @ 0a5e703a → B1/B2/B3 fixed red-first (2026-07-16) +MS-LEAD returned REQUEST CHANGES (routed merge-blockers satisfied; 2 CRITICAL reliability defects from +the commissioned independent review). Fixed forward on the branch, red-first: +- **B1 (CRITICAL) — dead ERR trap.** install.sh had `set -euo pipefail` (no `-E`), so the + `trap restore_snapshot ERR` never fired for a failure inside sync_framework_keep() (function body) — + a mid-sync abort left a half-written target with NO rollback. Fix: `set -Eeuo pipefail` (errtrace) + + disarm the trap at the top of restore_snapshot() to prevent re-entrancy. New gate + `test-upgrade-rollback.sh`: injects a mid-sync `cp` EACCES (read-only divergent framework file); + Part A asserts the shipped installer rolls back (restore message fires AND target byte-identical to + pre-upgrade); Part B control strips `-E` and asserts the rollback message does NOT fire (dead trap) — + self-verifying red→green. 7/7. +- **B2/B3 (CRITICAL) — empty/unreadable/malformed manifest divergence.** Pre-fix: TS `parseManifest('')` + returned `{framework:[],operator:[]}` (NO throw) → silent no-op "Installation complete"; bash aborted + fragilely (the `_manifest_compile` `"${MANIFEST_OPERATOR[@]:-}"` artifact returned 1 with no message) + AND the CLI dispatch swallowed manifest_load's rc (no `|| exit`) so `resolve` exited 0 resolving + everything operator. Fix (fail-loud + identical both langs): + * TS `parseManifest`: throw on zero framework entries; `loadManifest`: wrap read error → + "Cannot read framework manifest …". + * bash `manifest_load`: explicit unreadable guard (`[[ ! -r ]]`) + zero-`[framework]` guard, both loud + stderr + return 1; `_manifest_compile` gets explicit `return 0` (kills the empty-array artifact); + CLI dispatch `manifest_load … || exit 1`. + * `finalize.ts`: wrap syncFramework → `spin.stop('Framework sync aborted …')` + rethrow (never falls + through to "Installation complete"). + Tests: manifest.spec.ts +5 fail-closed (empty/comment-only/operator-only/empty-section/missing); + manifest-parity.spec.ts +7 failure-mode parity (both reject empty/comment-only/operator-only/ + empty-section/entry-before-header/unknown-header/missing — TS throws, bash CLI exits non-zero+stderr); + HARD GATE +4 end-to-end fail-closed matrices (empty/operator-only/malformed/missing → abort non-zero, + manifest error surfaced, every operator sentinel byte-identical). RED proven by reverting + manifest.ts+manifest.sh to HEAD → 12 new tests fail; restore → 40/40 green. +- **Non-blocking addressed.** MEDIUM install.sh:222 find-empty now warns on a real failure instead of + blanket `|| true`. LOW: corrected the "both destructive paths rsync vs cp" overstatement in the HARD + GATE header + cp-fallback comment + ci.yml (keep mode is a single cp-based path; the rsync-present vs + -absent runs prove rsync-independence). `.pre-constitution.bak` triage: single-shot backup is + intentional (reconcile_framework_files backs up once), no change. +- Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1081 passed (was 1069, +12) · HARD GATE + 118/118 (was 58) · rollback 7/7 (new) · migration 21/21. No --no-verify. Rollback test wired into + ci.yml upgrade-guard. Committing FORWARD (no rebase of 34e55d4a/0a5e703a). + +### Codex round 2 (pre-push self-review) → blockers A/B + should-fix C fixed red-first (2026-07-16) +Before committing round 1 I re-ran codex on the change set; it surfaced two fresh reliability defects +and one messaging defect on the SAME rollback/manifest path. Fixed forward, red-first: +- **Blocker-A (CRITICAL) — signal trap resumed instead of terminating.** A bash INT/TERM handler that + merely `restore_snapshot` (returns) does NOT terminate the script — execution RESUMES past the + interrupt, cleans the snapshot and reports success, leaving a partial post-interrupt update. Fix: + `trap 'restore_snapshot; exit 1' ERR INT TERM` so both the errtrace (ERR) and signal (INT/TERM) paths + exit non-zero. Rollback test Part C: a `cp` shim that `kill -TERM $PPID` mid-sync then succeeds (so + set -e never fires and only the signal path governs) → asserts abort non-zero + restore fires + does + NOT print "file phase complete"; control strips `exit 1` and asserts the buggy resume-to-success. +- **Blocker-B (CRITICAL) — degenerate `[framework]` section resolved everything operator.** A manifest + whose framework entries are all empty / bare-dot (`/`, `./`, `.`, `..`) passed the non-empty guard yet + yielded zero usable globs → nothing is framework → a keep-mode sync silently no-ops (bash resolved + `operator`, exit 0). Fix (both langs, parity): reject when no entry has a char other than `/`/`.` — + TS `isUsableFrameworkGlob` = `/[^/.]/.test(normalizeRel(glob))`, throws `ManifestError`; bash mirror + loops `[[ "$(_manifest_norm "$_g")" =~ [^/.] ]]`, loud stderr + return 1. Tests: manifest.spec.ts + `it.each(['/','./','.','..','/\n./'])` throw; parity +3 `expectBothReject` (root-slash/dot-slash/ + bare-dot). RED: reverting the guard makes `[framework]\n/` resolve `operator` exit 0. +- **Should-fix-C — misleading abort message.** finalize.ts printed one generic "may be partially + applied" for every sync failure. A `ManifestError` is a PRE-sync validation abort (manifest is + validated before any copy) → nothing was written; conflating it with a mid-copy failure misdirects + recovery. Fix: introduce `ManifestError` (exported from manifest.ts, thrown by every fail-closed + parse/load path), and classify in finalize.ts — ManifestError → "no files were changed"; any other → + "may be partially applied". New co-located `finalize-sync-abort.spec.ts` (3 tests) asserts both + branches re-throw the original error + the correct message, and that config writes are never reached. + RED proven by collapsing the classification → the ManifestError test fails. +- Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1094 (was 1081, +3 finalize-abort; + manifest specs already counted) · HARD GATE 193/193 · rollback 14/14 · migration 21/21. + +### Codex round 3 (pre-push self-review) → blockers D1/D2 fixed red-first (2026-07-16) +Re-ran codex again; it found two more rollback-path gaps `set -E` cannot catch. Fixed forward, red-first: +- **Blocker-D1 (CRITICAL) — `find` scan failures swallowed by process substitution.** Both the overlay + copy and the scoped prune consumed `< <(find … -print0)`. Bash does NOT propagate the producer's exit + status to the `while`, so an EACCES/I/O failure mid-scan truncates the file list yet leaves the loop + exiting 0 → a partial upgrade commits and reports success; the ERR/restore trap never fires. Fix: + `_scan_or_die` runs `find … -print0 > "$tmp"` to completion, checks its status, and returns non-zero + (→ ERR trap → restore) on failure; both loops now read from the checked temp file. Rollback test + Part D: a `find` shim that fails every `-print0` scan → shipped installer aborts non-zero + restores + + emits "Could not enumerate framework files" + target byte-identical; control neuters the `# D1-GUARD` + `return 1` → find failure swallowed, upgrade wrongly reports "file phase complete", no rollback. +- **Blocker-D2 (CRITICAL) — silent `set -e` exit on a failed target reset.** restore_snapshot did a bare + `rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"` (trap disarmed, under set -e). If `rm`/`mkdir` fails — + possibly after `rm` deleted part of the target — the script exits immediately, skipping the cp AND the + recovery pointer, leaving a half-removed target and an orphaned snapshot the operator can't locate. + Fix: `if ! rm -rf … || ! mkdir -p …; then fail "Snapshot restore could not reset … preserved at: + $SNAPSHOT_DIR — copy it back …"; return 1; fi` (tested like the cp -a check; snapshot NOT deleted). + Rollback test Part E: cp-poison triggers restore + an `rm` shim fails `rm -rf ` → shipped + emits the recovery pointer, the named snapshot dir survives, secret value never leaked; control deletes + the recovery line → operator gets no pointer. RED: reverting D1+D2 → 7 shipped/control assertions fail. +- Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1094 · HARD GATE 193/193 · + rollback 28/28 (was 14, +14 for D1/D2 with controls) · migration 21/21. shellcheck clean on new lines. + No --no-verify. Committing FORWARD (no rebase of 34e55d4a/0a5e703a). + +## Session 3 (2026-07-16) — PR1 MERGED, starting PR2 (durable snapshot + restore + secrev) +PR1 (#802) squash-merged → main `32a0ffba`; issue #791 stays open (3-PR DAG umbrella). Independent Opus +adversarial/security review APPROVED at head `af627e75` (Gitea RoR cmt 17892); lead ran rollback 28/28 + +HARD GATE 193/193 green; CI #1877 green. PR2 UNBLOCKED. + +PR2 branch: `feat/791-pr2-snapshot-restore` off `origin/main` 32a0ffba. Same treatment applies: +tests-first red-first, independent review + durable Gitea Reviewer-of-Record comment BEFORE MS-LEAD runs +the queue guard/merge. Report PR2 number + exact head when ready. PR body: `Part of #791` (NOT Fixes). + +### PR2 scope (ratified §3/§5 of design doc, Mos-approved — do NOT re-litigate) +- **(a) Durable pre-update snapshot** to `${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-/` + — OUTSIDE ~/.config/mosaic and any repo. Perms dir 0700 / files 0600 (umask 077 + explicit chmod). + Scope = operator-owned surface that EXISTS (operatorReserved paths), not the framework tree. Taken + BEFORE any mutation. Retention N=5 (`MOSAIC_BACKUP_RETENTION`), prune older. +- **Post-sync verify + selective restore**: diff operator surface vs snapshot; (b) should never touch + operator paths, so ANY diff = manifest bug → restore affected paths + warn loudly. (a) catches a (b) miss. +- **`mosaic restore`** (TS CLI): `--list` (default, dry-run) enumerates snapshots by ts; `--from ` + restores over operator surface, confirmation-gated. Counts/paths only. +- **Secret-safety (secrev)**: snapshot/restore NEVER emit file contents; only paths/counts. Tests assert + 0700/0600 AND that a secret value seeded in tools/_lib/credentials.json never appears in any output. + +### PR2 implementation status (2026-07-16, ready-for-review) +All three tasks implemented, red-first proven, unit-green: +- **Task #10 — durable snapshot (install.sh)**: `backup_root()`/`enumerate_operator_files()`/ + `prune_durable_snapshots()`/`make_durable_snapshot()` wired into keep-mode main() after `manifest_load`, + before any mutation. umask 077 + explicit chmod 700/600. UTC ts, collision suffix. FAIL-OPEN (a backup + failure never aborts the upgrade it protects). Retention `MOSAIC_BACKUP_RETENTION` (default 5), in-place + `sort -r -o` prune (no `mv` — stays inside the rsync-absent coreutils whitelist). +- **Task #11 — post-sync verify net (install.sh)**: `verify_operator_surface()` runs after sync (trap + disarmed), `cmp -s` each snapshot file vs target; restores any diverged/missing operator file + warns + loudly (a divergence = manifest bug). VERIFY-NET wired before `cleanup_snapshot`. +- **Task #12 — `mosaic restore` (TS)**: `src/commands/restore.ts` + co-located spec (19 tests). + `--list` default (dry-run enumerate), `--from ` confirmation-gated restore, `--dry-run`, `--yes`/ + `MOSAIC_ASSUME_YES`. Injectable `confirm` for testability (proceed/decline/env-bypass covered). Restored + files forced 0600. Registered in `cli.ts`. Path convention mirrors install.sh `backup_root()`. +- **CI**: `.woodpecker/ci.yml` upgrade-guard runs the new `test-upgrade-durable-snapshot.sh` gate. +- **Gates green**: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1241 (+5) · + durable-snapshot 26/26 · manifest-guard 193/193 · rollback 28/28 · migration 21/21. + Est. new-code coverage ≈93% (only the interactive readline default + process.exit-on-error uncovered). +- Regression fixed: PR2's `date`/`sort`/`mv` broke the rsync-absent manifest-guard PATH whitelist → + made date/sort fail-open, replaced `mv` with in-place `sort -o`, added `date sort` to the test whitelist + + isolated `XDG_STATE_HOME`. All 193 manifest-guard assertions green under restricted PATH. +- Codex code-review + security-review (secrev) run on the uncommitted diff before commit. + +### PR2 review round 1 — findings + remediations (2026-07-16, pre-PR) +Codex code-review returned **request-changes** (1 blocker + 3 should-fix); Codex security-review returned +**high** (1 high + 1 medium). Deduped to 5 distinct defects, ALL legitimate, ALL fixed FORWARD, each with +a red-first regression test whose control neuters exactly the guard under test: + +- **A · BLOCKER — verify net undid the legacy bin/ migration (install.sh).** On a pre-v2 install `bin/**` + is operator-classified, so the durable snapshot captured it; `run_migrations()` deletes bin/ on purpose, + but `verify_operator_surface()` then saw it "missing" and healed it back — the migration would be silently + undone forever once the version stamps. **Fix:** `MIGRATION_REMOVED_PATHS[]` recorded by run_migrations + (`bin`,`rails`) + `is_migration_removed()` skip in the verify loop (`# MIGRATION-SKIP-GUARD`). + **Test:** Part 6 — v1 fixture with bin/; shipped keeps it removed + stamps v3; control (guard stripped) + wrongly restores bin/tool.sh. +- **B · HIGH (CWE-59) — restore/verify wrote secrets THROUGH a symlink (install.sh + restore.ts).** An + attacker swapping an operator path (e.g. tools/_lib/credentials.json) for a symlink after the snapshot + would make `cp`/`copyFileSync` write the snapshot's secret out through the link. **Fix (bash):** refuse a + symlinked ancestor (`has_symlinked_parent`), drop a symlinked leaf before restore + (`# SYMLINK-LEAF-GUARD`). **Fix (TS):** reuse audited `secure-file.ts` — `assertCanonicalContainment` + + `ensureManagedDirectory` on every dst, open the leaf `O_NOFOLLOW|O_CREAT|O_TRUNC` 0600 (ELOOP = + fail-closed). **Tests:** Part 7 (shipped leaves external exfil target untouched, restores a real 0600 + file; control leaks the secret through the link) + restore.spec symlinked-leaf/ancestor cases (red-first). +- **C · MEDIUM/should-fix (CWE-22) — `--from` traversal escaped the backup root (restore.ts).** + `join(root, from)` accepted `../poison`. **Fix:** validate the selector against + `^\d{8}T\d{6}Z(?:-\d+)?$`, build exactly `join(root,'pre-update-'+ts)`, `lstat` (reject symlinked snap + dir). **Test:** restore.spec `it.each` of 6 malformed selectors + `--from ../poison` fail-closed (red-first). +- **D · should-fix — verify `mkdir -p` unguarded under set -e (install.sh).** A parent replaced by a + regular file aborted the installer before the recovery pointer printed. **Fix:** guard `mkdir -p`, warn + + `continue` on failure (keeps healing remaining files). +- **E · should-fix — snapshot `umask 077` leaked process-global (install.sh).** Later sync copies/dirs + inherited 0600/0700. **Fix:** save `old_umask`, restore on EVERY return path (`# UMASK-RESTORE-NORMAL`). + **Test:** Part 8 — synced framework file is 0644 while the secret backup stays 0600; control (restore + stripped) makes the synced file 0600. + +**Full gate suite re-run after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic +vitest **1252** · restore.spec **30** · durable-snapshot **41** · manifest-guard 193 · rollback 28 · +migration 21. shellcheck clean on all new lines; new test markers mirror the existing `# VERIFY-NET` +anchor convention. NOTE: codex self-review does NOT satisfy the independent-review gate — an independent +(author≠reviewer) review + durable Gitea Reviewer-of-Record comment is still required before MS-LEAD merges. + +## Session 4 (2026-07-16) — PR2 MERGED, PR3 built (fleet regen — recovery layer) +PR2 (#811) squash-merged → main `31607a4a`; issue #791 stays open (final PR of the 3-PR DAG). Independent +exact-head RoR at `d12c5f78` APPROVE (Gitea cmt 17904); #1882 green; busybox-portable Part 7 control fix +verified in-Alpine. PR3 UNBLOCKED. + +PR3 branch: `feat/791-pr3-fleet-regen` off `origin/main` 31607a4. Same discipline: tests-first red-first, +independent review + durable Gitea RoR BEFORE MS-LEAD runs the queue guard/merge. PR body `Part of #791`. + +### PR3 scope (ratified §4/§7 of design doc) — `mosaic fleet regen` +Projection-only recovery command: rebuilds each `fleet/agents/.env.generated` from `roster.yaml` +(SSOT). Dry-run default; `--write` applies; `--json` machine output. Structural guarantee: NO code path to +systemd lifecycle — **never restarts an agent**. Single-SSOT: reuses `projectRosterV2AgentGeneratedEnv` +(extracted, shared with the reconciler apply path) so regen and reconcile cannot drift. Secrev: paths + +counts only, never the rendered KEY=value body. + +New files: `commands/fleet-regen-command.ts` (+ `.spec.ts`), guide `docs/guides/upgrade-safety-and-recovery.md` +(three-layer model: PR1 manifest ownership → PR2 snapshot/restore → PR3 regen; do-NOT-restart-before-verify +runbook), regen reference added to `docs/guides/fleet-local-canary.md`. Wired in `commands/fleet.ts`. + +### Independent review (3 reviewers: subagent code-reviewer + codex code-review + codex security) → 4 fixes, red-first +- **A · BLOCKER (codex) — regen mutated/deleted legacy operator env.** `applyPreparedAgentEnvironmentProjection` + also writes `.env.local`/`.env.quarantine` and unlinks legacy `.env`. Violated projection-only contract. + **Fix:** NEW generated-only boundary primitives `prepareGeneratedAgentEnvironmentProjection` + + `applyPreparedGeneratedAgentEnvironmentProjection` (write ONLY `.env.generated`). regen now has no + code path that touches `.env`/`.env.local`/`.env.quarantine`. **Test:** projection-only leaves legacy `.env` + verbatim, no local/quarantine fabricated. +- **B · should-fix (codex + subagent + security) — partial write on mid-loop failure.** Interleaved + prepare/apply left earlier agents written when a later agent failed prepare. **Fix:** PREPARE ALL agents + before writing ANY (mirrors reconciler `defaultPrepareProjections`). **Test:** 2nd agent's projection + pre-seeded 0644 → prepare rejects → coder0 NOT written, exit 1. +- **C · subagent — semantic-validation bypass.** Default readRoster skipped `validateRosterV2Semantics`, so + a tampered protected-class `tool_policy` would be silently projected. **Fix:** default readRoster now runs + `validateRosterV2Semantics` (persona resolution + protected-class match), rolesDir/overrideDir defaults + mirroring the reconciler. **Test:** merge-gate agent w/ tool_policy=code → fails closed, no write. +- **D · MEDIUM (codex security, CWE-362) — concurrent-reconcile race.** regen `--write` wrote without the + reconcile lock. **Fix:** `--write` acquires `acquirePrivateReconcileLock(mosaicHome)` for the whole + read-prepare-apply sequence, released in `finally`; dry-run stays lock-free. **Test:** pre-held lock → + regen fails closed, no write. + +**Gate suite after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest **1265** +(regen spec 13, incl. 4 new red-first regressions). NOTE: codex self-review does NOT satisfy the +independent-review gate — an independent (author≠reviewer) review + durable Gitea RoR is still required +before MS-LEAD merges. STOP at PR-open for MS-LEAD's exact-head review; do NOT self-merge. + +## Session 5 — PR3 review round 2 (finding L + M1/M2/M3), red-first fixes + +Second review pass on the lock-cleanup plumbing surfaced one round-1 residual (L) and three round-2 +findings (M1 blocker, M2/M3 should-fix). All fixed red-first (RED proven per-finding, then GREEN). + +- **L · should-fix (codex r1) — mutation-lock release swallowed unlink failures.** regen's + `acquirePrivateRosterMutationLock` release copied CRUD's `unlink().catch(()=>{})`, hiding a stale + `roster.yaml.mutation.lock`. **Fix:** its release PROPAGATES the unlink fault (finding-J stale-lock + warning then fires for this lock too). **Test:** acquire real lock, `rm` it, assert `release()` rejects. +- **M1 · BLOCKER (codex r2) — replacement-lock race.** The propagating release from L did an + UNCONDITIONAL `unlink(lockPath)` without proving ownership. If the lock is cleared + re-created by + another writer mid-op, regen deletes the STRANGER's live lock → a third writer enters → mutual + exclusion defeated. **Fix (reuse, not reimplement):** generalized the reconciler's ownership-proving + lock body into shared `acquirePrivateManagedRosterLock(mosaicHome, lockLeaf, busyMessage, openLock)`; + `acquirePrivateReconcileLock` delegates to it (behavior-identical: same leaf/codes/messages), and a NEW + hardened `acquirePrivateRosterMutationLock` (now in fleet-reconciler.ts, leaf `roster.yaml.mutation.lock`) + records dev/ino + ownership token and RE-PROVES ownership (`assertLockOwnership`) before unlinking — + fails closed as `lock-cleanup-failed` if replaced. Removed the crud-based export; reverted + `acquireMutationLock` (fleet-agent-crud.ts) to its original inline empty-file/swallowing-release form + (CRUD behavior intentionally unchanged). Compatibility: CRUD empty-file `wx` and regen tokened `wx` + contend on the same path but never co-own (wx winner owns; loser → concurrent-mutation), so the token + is only ever read back by the same regen invocation. **Test:** acquire, `rm`+recreate lock (new inode), + assert `release()` rejects AND the replacement survives (not unlinked). +- **M2 · should-fix (codex r2) — acquire-unwind fault dropped.** The acquire-failure catch discarded + `releaseFleetLocks`' return (a possible fault on the already-held first lock). **Fix:** capture and + augment — `const releaseFault = await releaseFleetLocks(releases); throw augmentWithLockCleanupFault(error, releaseFault);` + (symmetric to finding J). **Test:** mutation lock acquires w/ faulting release + reconcile acquire + throws → thrown error mentions stale/lock, nothing written. +- **M3 · should-fix (codex r2 + subagent REQUEST-CHANGES) — cleanup warning named only reconcile lock.** + Finding L made the mutation-lock release fault reachable, so the `cleanup` marker can originate from + EITHER lock. **Fix:** `formatFleetRegenReport`'s WARNING now names BOTH `roster.yaml.mutation.lock` and + `roster.yaml.reconcile.lock`, matching `augmentWithLockCleanupFault`. **Test:** fault the mutation-lock + release specifically → report names both lock files. + +**Refactor note (no cycle):** neither fleet-reconciler nor fleet-agent-crud imports the other; regen +imports lock acquirers from fleet-reconciler and the projection mapping from fleet-reconciler. The two +reconcile-lock reviewers reconciled: independent reviewer validated acquire-time empty-file compatibility +(preserved), codex flagged RELEASE-time replacement race (closed by ownership proof) — non-contradictory. + +**Gate suite after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest **1275** +(regen spec 23, incl. 7 red-first lock regressions E/F/G/K/L/M1/M2/M3). RED proven per-finding by +temporary revert before re-applying each fix. Independent (author≠reviewer) review of M1/M2/M3 + codex +code/security re-run in flight. STOP at PR-open for MS-LEAD's exact-head review + durable Gitea RoR; do +NOT self-merge; #791 umbrella stays OPEN; PR body `Part of #791`. + +### Round 3 review (after M1/M2/M3) — independent review PASS + codex residual-TOCTOU disposition +Three reviewers on the post-M1/M2/M3 head: +- **Independent (subagent, author≠reviewer) — PASS.** Verified M1/M2/M3 all correctly fixed; "never + restarts" is STRUCTURAL (runner never referenced in executable code); no secrets; no deadlock (only + regen holds both locks); tests meaningful (assert inode preservation + exact lock-file names). Raised: + - **should-fix #1 (fixed, red-first):** generalizing the lock helper left `assertSafeLockLeafIfPresent`/ + `assertLockOwnership` hardcoding "reconciliation lock" in thrown messages → a MUTATION-lock fault + misreported as the reconcile lock, undercutting M3's accurate-diagnosis goal. **Fix:** thread + `lockLabel = fleet/` through both helpers + the generic lock-io messages, so every fault names + the actual lock file. Red-first: strengthened the M1 test to assert `/roster\.yaml\.mutation\.lock/` + (RED: got "reconciliation lock"; GREEN after). Also resolves nit #3 (generic-message drift). + - **nit #2 (fixed):** `FleetRegenResult.cleanup` JSDoc still said "the shared reconcile lock"; now names + both locks (regen holds both). + - **nit #4 (fixed):** removed the redundant duplicate `assertLockOwnership` call before unlink + (pre-existing in merged main; harmless but dead — dropped since the fn was already being touched). +- **Codex security — clean (risk: none).** Validates roster semantics, constrains env values, no shell + eval, no secret output, generated-only writes, serialized against both locks. +- **Codex code — request-changes, 1 "blocker": residual check-then-unlink TOCTOU.** Between the final + `assertLockOwnership` and the path-based `unlink`, an external actor could vacate our inode and a new + writer grab the path, so the unlink deletes the stranger's lock. **Disposition: documented known + limitation, NOT fixed in PR3.** Rationale: (1) byte-identical to the MERGED, shipped reconcile-lock + release on origin/main (fleet-reconciler.ts L654-659) — not introduced here; (2) UNREACHABLE within the + `wx` writer protocol — no Mosaic writer removes a lock it doesn't own (wx fails EEXIST while our inode + exists), so only external interference can vacate our inode in the sub-instruction window; (3) the + ownership guard DOES close the reachable case (stale-lock reaper/operator cleared our lock + another + writer took it BEFORE release began → fail closed, don't delete stranger's lock); (4) the true atomic + fix — fd-held advisory lock (flock/lockf) adopted by ALL fleet writers (CRUD + reconcile + regen) — is + a cross-cutting mechanism change touching merged CRUD + reconciler, out of scope for a projection-only + recovery PR. Documented honestly in the acquirer doc + M1 test comment. **The binding independent + review did NOT treat this as a blocker.** Recommendation to MS-LEAD: proceed to PR-open + spin a + SEPARATE follow-up issue for the fd-advisory-lock migration; MS-LEAD adjudicates scope at exact-head + review (merge authority). + +**Gates after round-3 fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest +**1275** (regen spec 23). Fresh codex code re-run in flight to confirm no NEW issues from the label fix. + +--- + +## Session 6 — Round 4/5 convergence (stranded-lock robustness) + +**Two independent reviewers converged on the SAME should-fix** on the init-failure cleanup path, +strengthening confidence it was real: + +- **Codex code-review-5 — 0 blockers, 1 should-fix.** "Stat failure after lock creation strands the new + lock." When `handle.stat()` ITSELF fails right after the `wx` create (transient EIO/EBADF), `created` + is `undefined`, so `removeOwnedLockLeafBestEffort` had `if (!created) return;` → no cleanup → the + just-created `roster.yaml.mutation.lock`/`reconcile.lock` is stranded, permanently blocking future + regen + CRUD. (Notably NO blocker, and the TOCTOU is no longer flagged in code-review as of r5.) +- **Independent delta reviewer (author≠reviewer, pr-review-toolkit) — no blockers, same should-fix.** + Independently flagged the identical `!created` gap; validated FIX 1 (label threading — no call site + missed, codes unchanged, no test depended on old text) and FIX 2 (dev/ino-guarded cleanup, best-effort, + happy-path release reuses captured dev/ino) as correct. Suggested an unconditional best-effort unlink + in the `!created` branch; I took the **safer** variant below. +- **Codex security-review-5 — 0 crit / 0 high / 1 medium.** The single medium is the SAME residual + check-then-unlink TOCTOU already dispositioned in round 3 (its own remediation = "migrate every writer + to an fd-held advisory lock" = the follow-up issue). No new security finding. No secrets. + +**Fix (red-first, safer than an unconditional unlink):** thread the persisted random `token` into +`removeOwnedLockLeafBestEffort`. Two independent ownership proofs now: primary dev/ino (unchanged), and a +**fallback** when the post-create stat failed — read the leaf and unlink ONLY if its content equals our +`randomUUID()` token. Only OUR lock carries that token, so a CRUD (empty) or differently-tokened +replacement is never deleted. `tokenPersisted` guards passing the token (only after `writeFile` lands). +Doubly-degenerate case (stat fails AND token write never landed) leaves the lock in place rather than +risk deleting a stranger's file — requires two independent fs faults on a just-created fd; documented. + +- **Red-first proof:** new test `does not strand the lock file when the post-create stat itself fails` + injects a real `wx` create + a Proxy handle whose `stat()` rejects (writeFile/close succeed), asserts + `exists(lockPath) === false`. RED before fix (`expected true to be false` — lock stranded); GREEN after. +- **Also fixed (delta nit #3):** `fleet-regen-command.ts` `acquireRosterMutationLock` JSDoc said "CRUD's + private lock"; the default is the reconciler's hardened ownership-proving acquirer for the same + `fleet/roster.yaml.mutation.lock` path. Corrected. +- **PR-description note (delta nit #2):** FIX 1 also collapsed a pre-existing duplicate back-to-back + `assertLockOwnership` call in the release closure (identical args, no intervening logic) into one — a + no-op simplification of merged code, not a behavior change. Called out so a future reader doesn't + wonder if the duplicate had a purpose. + +**Gates after round-4 fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest +**1277** (regen spec now 25: +1 stat-failure stranded-lock regression). Residual TOCTOU still deferred to +the fd-advisory-lock follow-up issue; MS-LEAD adjudicates scope at exact-head review (merge authority). + +--- + +## Session 6 — Round 6 (persona-root wiring) + +**Codex code-review-6 — 0 blockers, 1 should-fix (NEW, distinct from the lock work).** "Forward +configured persona directories to regen." `registerFleetRegenCommand` was registered at +`fleet.ts:2069` with only `{ runner, mosaicHome }`, discarding `deps.reconcileDeps.rolesDir` / +`overrideDir`. The regen command ALREADY has those seams (validates roster semantics via +`validateRosterV2Semantics({ rolesDir, overrideDir })`, defaulting to `/fleet/roles{,.local}`), +but the top-level wiring never forwarded the configured roots. **Impact:** in a deployment with custom +persona roots, `fleet reconcile` (which honors the overrides) would ACCEPT a roster while `fleet regen` +REJECTS the same roster (persona resolution against the wrong default dir) — blocking the recovery +command and violating the documented "resolves personas the SAME way reconcile does" contract. + +**Fix (red-first):** forward `rolesDir`/`overrideDir` from `deps.reconcileDeps` into +`registerFleetRegenCommand` at `fleet.ts:2069`. Red-first test `forwards configured persona roots +(rolesDir/overrideDir) from reconcileDeps into regen`: seeds personas ONLY under a custom root, leaves +the default `/fleet/roles` empty, registers with `reconcileDeps: { rolesDir, overrideDir }`, and +requires `fleet regen` to SUCCEED. RED before fix (`expected 1 not to be 1` — regen validated against the +empty default and exited 1); GREEN after. + +**Codex security-review-6 — 0 crit / 0 high / 1 medium.** Same residual check-then-unlink TOCTOU, now +noted at BOTH the release closure and the init-cleanup path; remediation = fd-held advisory lock across +all writers = the SAME deferred follow-up item. No new security finding, no secrets. + +**Independent confirmation review of the token-fallback fix (Session 6/round 4) — PASS, no findings.** +All 7 verification points confirmed; reviewer mechanically reverted `removeOwnedLockLeafBestEffort` to +the pre-fix `if (!created) return;` and re-ran the new test → RED (`expected true to be false`), +confirming the test genuinely pins the fix; restored after. No lint/type issues; doc-comment accurate. + +**Gates after round-6 fix (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest +**1278** (regen spec now 26: +1 persona-root wiring regression). + +--- + +## Session 6 — Round 7 convergence (review CLOSED for PR-open) + +- **Codex code-review-7 — 0 blockers, 1 should-fix = the residual TOCTOU** (previously a "blocker" in r3, + dropped in r4/r5, now re-surfaced as a should-fix). **Codex security-review-7 — 0 crit / 0 high / + 1 medium = the SAME residual TOCTOU.** Codex has CONVERGED: the only remaining finding across both + streams is that one race, whose own remediation is "fd-held advisory lock shared by all fleet writers" + = the deferred follow-up. No new distinct finding; the wiring fix introduced nothing. +- **Independent confirmation review of the persona-root wiring fix — PASS, no findings.** Reviewer + mechanically reverted the two forwarded lines → RED (`Roster v2 agent "coder0" class "code" does not + resolve to a readable persona` → exit 1), restored → GREEN (26 regen + 204 fleet tests). Confirmed the + optional-chaining fallback preserves default-deployment behavior and no type/lint issue. + +**Review disposition for PR-open:** ALL actionable findings fixed red-first across rounds 3–6 (label +threading, stranded-lock on init failure, stat-failure strand, persona-root wiring). The residual +check-then-unlink TOCTOU is the ONLY open item and is DEFERRED to a follow-up issue (fd-advisory-lock +migration across CRUD + reconcile + regen) — byte-identical to merged origin/main's reconcile-lock +release, unreachable within the `wx` writer protocol (no Mosaic writer removes a lock it doesn't own; +only external `rm`/a stale-lock reaper can vacate the inode mid-release), and its true fix is a +cross-cutting mechanism change out of scope for a projection-only recovery PR. Two independent human-agent +reviews (author≠reviewer) treated it as non-blocking. MS-LEAD adjudicates scope at exact-head review +(merge authority); recommendation = proceed to PR-open + spin the follow-up issue. + +**Final gates (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest **1278** +(regen spec 26). No secret values in any snapshot/projection/report output (counts + paths only). Regen +NEVER issues a lifecycle/restart call (load-bearing recordingRunner gate). STOP at PR-open for MS-LEAD's +exact-head review + durable Reviewer-of-Record before any merge; do NOT self-merge. diff --git a/docs/scratchpads/804-install-unknown-flags.md b/docs/scratchpads/804-install-unknown-flags.md new file mode 100644 index 00000000..d2f9e464 --- /dev/null +++ b/docs/scratchpads/804-install-unknown-flags.md @@ -0,0 +1,86 @@ +# Issue #804 — fail closed on unknown installer arguments + +## Objective + +Implement Part 1 of Gitea issue #804 only: `tools/install.sh` must reject every unrecognized flag or argument with an actionable STDERR error and nonzero exit before installation starts. + +## Scope and constraints + +- Preserve all currently recognized options and behavior, including `-y` and `--ref `. +- No positional arguments are currently accepted by the parser. +- Do not add `--next`, `MOSAIC_NEXT`, prerelease routing, or any Part 2 behavior. +- TDD is mandatory: add and observe a failing process-level regression test before changing `tools/install.sh`. +- Worker lifecycle ends after branch push, PR creation, and coordinator notification; do not merge or close #804. +- Existing launcher-owned changes in `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are out of scope and must not be committed. + +## Requirements and acceptance criteria + +- Unknown input names the offending argument on STDERR. +- STDERR includes a short installer usage hint. +- Exit status is nonzero. +- The installer does not invoke npm or otherwise proceed into installation. +- Existing recognized flags remain unchanged. + +## Plan + +1. Add a process-level Vitest regression using the installer test location under `packages/mosaic/src/commands/`. +2. Run the focused test and record the expected RED failure. +3. Commit the RED test as `test(#804): ...`. +4. Replace the parser catch-all with a fail-closed STDERR error and usage hint. +5. Update concise installer-facing documentation without introducing prerelease behavior. +6. Run focused tests, shell syntax validation, package tests, lint, typecheck, and format checks. +7. Run independent review tooling and remediate findings. +8. Commit as `fix(#804): ...`, queue-guard, push, open a PR containing `Closes #804.`, notify the coordinator, and exit. + +## Budget + +- No explicit token cap supplied. +- Working estimate: 8K tokens; narrow two-file behavior/test change plus concise docs and delivery gates. + +## Progress + +- 2026-07-17: Loaded mission state, issue #804, delivery/QA/documentation rails, and relevant TDD/Vitest/pnpm/Gitea skills. +- 2026-07-17: Confirmed the parser has no legitimate positional arguments and currently drops all unmatched input via `*) shift ;;`. +- 2026-07-17: Installed locked workspace dependencies with a worktree-local pnpm store; no lockfile changes. +- 2026-07-17: Added the process-level unknown-argument regression with an isolated `$HOME` and npm shim. +- 2026-07-17: Replaced the silent catch-all with STDERR error + usage output and exit 2 before preflight or installation. +- 2026-07-17: Initial Codex code review found an unknown option could still be consumed as the `--ref` value. Added a second RED reproducer, then rejected option-shaped/missing `--ref` values without changing valid `--ref ` behavior. The review's launcher-state note is handled by excluding both `.mosaic/orchestrator/` files from commits. +- 2026-07-17: Updated README, user guide, and packaged framework README with the fail-closed argument contract. No API, auth, admin, sitemap/navigation, or publishing surface changed. + +## Verification + +- RED: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/install-arguments.spec.ts` — expected failure: installer exited `0` instead of nonzero at the exit-status assertion; confirms the test reproduces the silent-drop defect before production changes. +- Remediation RED: the added `--cli --ref --bogus` case exited `0`, proving `--ref` could swallow an unknown option before the guard was added. +- GREEN: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/install-arguments.spec.ts src/commands/install-heading.spec.ts` — 2 files, 3 tests passed. +- Situational process check: unknown positional input exited 2, named the input on STDERR, printed usage, and did not call the npm shim. +- `bash -n tools/install.sh` — passed. +- Bare `--ref` process check — exited 2 with `Missing value for --ref` and usage. +- `pnpm --filter @mosaicstack/mosaic test` — 69 files, 1,287 tests passed; framework shell checks passed. The first attempt lacked generated `dist/cli.js`; `pnpm --filter @mosaicstack/mosaic build` restored the required test precondition and the full rerun passed. +- `pnpm lint` — 23/23 tasks passed. +- `pnpm typecheck` — 42/42 tasks passed. +- `pnpm format:check` — passed. +- Codex code re-review against `origin/main` — `approve`, 0 blockers/should-fix/suggestions. +- Codex security re-review against `origin/main` — risk `none`, 0 findings. + +## Acceptance evidence + +| Criterion | Evidence | +| --- | --- | +| Unknown input is named on STDERR | Process-level Vitest assertions for `--bogus`, including after `--ref` | +| Short usage hint is printed on STDERR | Vitest usage regex + manual process output | +| Exit is nonzero | Vitest status assertions and manual exit 2 | +| Installation does not proceed | Isolated npm shim marker remains absent | +| Recognized behavior is preserved | Parser cases are unchanged except validation of malformed `--ref`; full Mosaic package suite passed | +| Part 2 is excluded | No `--next`, `MOSAIC_NEXT`, dist-tag, or prerelease routing changes | + +## Documentation checklist + +- Current canonical `docs/PRD.md` remains unchanged; issue #804 and the coordinator brief supply this bounded defect's acceptance contract. +- Updated installer behavior in root README, user guide, and packaged framework README in the same logical change set. +- API/OpenAPI, auth/permissions, admin operations, developer architecture, sitemap/navigation, and external publishing are not affected. +- Scratchpad remains under `docs/scratchpads/`; no root-hygiene changes. + +## Risks and blockers + +- Part 2 remains owner-gated under #805 and is intentionally excluded. +- No implementation blocker remains. Independent coordinator RoR, CI, merge, and issue closure remain pending after worker handoff. diff --git a/docs/scratchpads/807-glpi-partial-content.md b/docs/scratchpads/807-glpi-partial-content.md new file mode 100644 index 00000000..492eacac --- /dev/null +++ b/docs/scratchpads/807-glpi-partial-content.md @@ -0,0 +1,64 @@ +# Issue #807 — GLPI list wrappers accept HTTP 206 + +- **Branch:** `fix/807-glpi-206` +- **Task:** Gitea issue #807 +- **Role:** Author-only worker reporting to `mosaic-100`; no self-review or merge +- **Started:** 2026-07-16 + +## Objective + +Fix the shipped GLPI ticket, computer, and user list wrappers so ranged responses with HTTP 206 Partial Content render successfully while genuine HTTP failures remain non-zero errors. + +## Scope + +- Modify only the three affected list wrappers and a focused shell regression test. +- Do not touch `session-init.sh`, `ticket-create.sh`, or `docs/TASKS.md`. +- Add task-local delivery evidence here as required by the mission protocol. + +## Plan + +1. Add a deterministic shell harness that copies each wrapper beside stubbed `session-init.sh`, credentials, and `curl` boundaries. +2. Prove RED against the current 200-only gates: 206 must fail before the implementation change. +3. Update all three status gates to accept exactly 200 or 206. +4. Prove GREEN for 206 rendering and genuine 401/500 failures, then run repository quality gates. +5. Commit with co-author attribution, run the push queue guard, push, and open a PR for independent review and merge by the team lead. + +## Budget + +- No explicit token cap supplied. +- Soft estimate: 8K tokens; narrow single-worker execution with no exploratory scope. + +## Progress + +- [x] Mission, task, PRD, QA, documentation, and code-review guidance loaded. +- [x] RED regression evidence captured: `test-list-http-status.sh` exited 1; all three wrappers rejected 206 while retaining 401 failures. +- [x] Implementation complete. +- [x] Relevant tests and repository gates green. +- [ ] Commit pushed and PR opened. + +## Tests and evidence + +- RED (before source fix): `packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → exit 1; ticket/computer/user 206 assertions failed, all 401 assertions passed. +- GREEN: `bash -n packages/mosaic/framework/tools/glpi/{ticket-list.sh,computer-list.sh,user-list.sh,test-list-http-status.sh}` → pass. +- GREEN: `shellcheck packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → pass. +- GREEN: `packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → 6 assertions pass (206 renders and 401 errors for all three wrappers). +- GREEN: `pnpm typecheck` → 42/42 tasks pass. +- GREEN: `pnpm lint` → 23/23 tasks pass. +- GREEN: `pnpm format:check` → all matched files pass. +- Setup note: initial gate attempts could not start because the fresh worktree lacked dependencies; `pnpm install --frozen-lockfile --store-dir /home/hermes/.local/share/pnpm/store` restored the locked workspace dependencies without lockfile changes. + +## Acceptance criteria mapping + +| Criterion | Evidence | +| --- | --- | +| HTTP 206 succeeds and renders each ranged list | Focused test's three 206 render assertions pass | +| Genuine HTTP failures remain non-zero with existing diagnostics | Focused test's three HTTP 401 assertions pass | +| Only affected list wrappers change | Diff contains the three status predicates plus focused test/evidence; session and create wrappers untouched | + +## Documentation decision + +No operator/API documentation change is needed: this restores documented list behavior for a healthy GLPI response without changing command syntax, output, configuration, or public contracts. This task scratchpad records delivery evidence. + +## Risks / blockers + +- Existing dirty `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are runtime-owned and will not be edited or committed. diff --git a/docs/scratchpads/808-agent-send-sender-identity.md b/docs/scratchpads/808-agent-send-sender-identity.md new file mode 100644 index 00000000..04b8bebb --- /dev/null +++ b/docs/scratchpads/808-agent-send-sender-identity.md @@ -0,0 +1,37 @@ +# Issue #808 — agent-send sender identity + +## Objective + +Fix cross-socket `agent-send.sh` preambles so replies route to the real sender rather than a destination-socket holder session. + +## Scope and acceptance criteria + +- Prefer exported `MOSAIC_AGENT_NAME` as the authoritative sender session name. +- If it is unset, query the sender's local/default tmux socket for `#S` without destination `-L` arguments. +- Preserve `?` when sender identity cannot be determined. +- Do not alter destination socket dispatch. +- Add red-first regressions for all three identity paths. + +## Plan + +1. Extend `agent-send.test.sh` with deterministic fake-tmux coverage. +2. Run the test against the unpatched implementation and record RED evidence. +3. Apply the minimal sender lookup fix only. +4. Run the focused suite and repository quality gates. +5. Commit, queue-guard, push, and open an author-only PR for independent review. + +## Constraints and risks + +- Worker lane is author-only: no self-review or merge. +- `docs/TASKS.md` and mission state are orchestrator-owned and will not be modified. +- Pre-existing runtime changes under `.mosaic/orchestrator/` are excluded from this work. +- Budget: no explicit token cap; keep changes limited to the shell tool, sibling regression test, and this scratchpad. + +## Evidence + +- RED: `bash packages/mosaic/framework/tools/tmux/agent-send.test.sh` failed on the unpatched implementation with `PASS=12 FAIL=3`; it selected `destination-holder` instead of both `MOSAIC_AGENT_NAME=authoritative-agent` and local session `local-agent`. The genuinely unavailable sender case already exercised and preserved `?`. +- GREEN: `bash packages/mosaic/framework/tools/tmux/agent-send.test.sh` passed with `PASS=15 FAIL=0`; coverage includes env authority, local/default tmux fallback across a destination `-L`, explicit rejection of the destination holder, and `?` fallback. +- Syntax: `bash -n packages/mosaic/framework/tools/tmux/agent-send.sh packages/mosaic/framework/tools/tmux/agent-send.test.sh` passed. +- Quality gates: `pnpm typecheck` (42/42 tasks), `pnpm lint` (23/23 tasks), and `pnpm format:check` all passed after installing the frozen lockfile dependencies. The first install attempt failed because pnpm's configured store pointed at `/root`; retrying with the existing user-owned store (`--store-dir /home/hermes/.local/share/pnpm/store`) succeeded without changing tracked dependency files. +- Documentation: no public API or operator workflow changed; the source comment, regression-test contract, and this implementation record cover the internal bug fix. +- Independent review: intentionally pending for the reviewer assigned by `mosaic-100`; this author-only lane will not self-review or merge. diff --git a/docs/scratchpads/812-pr-review-comment.md b/docs/scratchpads/812-pr-review-comment.md new file mode 100644 index 00000000..e50e27a3 --- /dev/null +++ b/docs/scratchpads/812-pr-review-comment.md @@ -0,0 +1,58 @@ +# Issue #812 — durable Gitea PR review comments + +- **Lane:** ms-812 +- **Branch:** `fix/812-pr-review-comment` +- **Issue:** mosaicstack/stack#812 +- **Budget:** 15K working estimate; single focused shell-wrapper/test/docs change. + +## Objective + +Make the Gitea `comment` action in `packages/mosaic/framework/tools/git/pr-review.sh` use the supported Gitea comments REST API and report success only after provider read-back verifies the created comment against the intended repository, PR, and exact body. + +## Plan + +1. Add and commit a failing shell regression harness before production changes. +2. Verify RED against the nonexistent `tea pr comment` fallback false-positive. +3. Implement the minimal supported write plus ID-based provider read-back. +4. Document that wrapper write output is not durable provenance until read-back succeeds. +5. Run focused regression tests, touched-package tests, and repository quality gates. +6. Remediate review findings, queue-guard, and push for coordinator-owned independent review. Do not open or merge a PR. + +## Progress checkpoints + +- [x] RED regression committed and reported to mosaic-100 (rebased commit `770e3f57`) +- [x] Initial minimal fix implemented (rebased commit `ea7f8c57`) +- [x] Rebased cleanly onto main `627cf2bb387f7c84a532d88819903a7679ce0d72` +- [x] Codex blocker remediated by replacing unsupported `tea api` with authenticated REST write/read-back +- [x] Focused, package, and repository gates green +- [ ] Coordinator-owned independent review pending after push +- [x] No PR opened; no self-review or self-merge + +## Tests run + +- RED after rebase: the regression harness failed against `origin/main` with status 1 after reproducing the old `tea pr comment` zero-exit fallback and false success echo. +- GREEN at resumed head: the same harness passed with REST POST 201 plus GET 200 read-back. +- All `packages/mosaic/framework/tools/git/test-*.sh` harnesses passed. +- `shellcheck -x` passed for the changed scripts; `bash -n` passed. +- Manifest resolver returned `framework` for `tools/git/test-pr-review-gitea-comment.sh`. +- `pnpm test` passed (43/43 Turbo tasks; Mosaic 75 files/1434 tests; Gateway 56 files/628 tests plus documented skips). +- `pnpm typecheck` passed (42/42 tasks), `pnpm lint` passed (23/23), and `pnpm format:check` passed. +- Firewall checks found no user-home paths or operator identities in changed shipped files; no token value is logged or echoed. + +## Risks / blockers + +- No active implementation blocker. #789 reached terminal merged state and the coordination hold was lifted. +- Review round 1 found one portability blocker: the API base reconstructed `https://$host` and discarded configured schemes/path prefixes. +- Review round 2 found a second subpath portability blocker: clone-derived `get_repo_slug` retained the deployment prefix, duplicating it under `/api/v1/repos/`. +- Round 3 resolves owner/repo relative to the configured Gitea base path for HTTP(S) clones while preserving root-mounted and SSH clone forms. Host matching now compares non-default ports consistently. +- REST transport failures, non-201 writes, malformed/missing created IDs, non-200 read-backs, and read-back mismatches all fail closed. +- Existing approve/request-changes behavior remains covered. +- Independent exact-head re-review remains coordinator-owned. + +## Final verification evidence + +- URL-portability regression was RED before remediation at the new `http://git.mosaicstack.dev` case and GREEN afterward. +- Round-3 genuine subpath regression was RED against round-2 head `1b190201` and GREEN after the fix: `https://git.example/gitea/owner/repo.git` maps to API repository `owner/repo` under configured base `/gitea`. +- Regression coverage verifies POST and read-back GET for root-mounted HTTP(S), path-prefixed HTTP(S), non-default HTTP port, scp-style SSH, and `ssh://` clone forms. +- Focused shell checks, all git-wrapper harnesses, and full repository test/typecheck/lint/format gates passed after remediation. +- Branch will be force-pushed with lease for coordinator re-verification; no PR opened. diff --git a/docs/scratchpads/824-mosaic-skill-cli.md b/docs/scratchpads/824-mosaic-skill-cli.md new file mode 100644 index 00000000..47be56b3 --- /dev/null +++ b/docs/scratchpads/824-mosaic-skill-cli.md @@ -0,0 +1,112 @@ +# Issue #824 — Mosaic skill CLI and Claude bridge auto-sync + +## Objective + +Deliver `mosaic skill register|unregister|list` plus install/upgrade reconciliation of every canonical `~/.config/mosaic/skills/*` entry into `~/.claude/skills/`, without clobbering runtime-owned files or directories. + +## Scope and constraints + +- Issue: mosaicstack/stack#824 +- Branch: `feat/824-mosaic-skill-cli` +- M1 runtime: Claude Code only. +- Pi/Codex parity is documentation-only; no non-Claude bridge implementation. +- Do not author the downstream `mosaic-context-refresh` skill. +- Workers do not modify `docs/TASKS.md`, merge, close #824, or touch `main`. +- TDD is mandatory and red-first; filesystem tests use temporary directories only. +- Budget: no explicit token cap supplied; use a focused single-worker implementation with no new dependencies. + +## Requirements mapping + +1. Register creates the canonical Claude symlink and is idempotent. +2. Names are untrusted: reject empty/escaping/absolute/separator/`..`/leading-dash names before filesystem mutation, with clear CLI stderr and nonzero status. +3. Register repairs only Mosaic-owned dangling symlinks and refuses foreign files, directories, and symlinks. +4. Unregister removes only symlinks pointing inside the canonical Mosaic skills root and is idempotent when absent. +5. List reports registered, dangling, foreign, and canonical-but-unregistered skills. +6. Install and upgrade generically reconcile all canonical skills after framework sync/re-seed, continuing past foreign conflicts without clobbering them. +7. User/developer documentation describes commands, status meanings, security boundaries, and Claude-only M1 scope. + +## Plan + +1. Add co-located failing Vitest coverage for all filesystem behaviors and auto-sync. +2. Run the focused spec and record the expected RED failure. +3. Commit the red contract as `test(#824): ...`. +4. Implement the skill bridge and Commander command registration. +5. Wire reconciliation into wizard finalize and `mosaic update` re-seed, preserving non-clobber behavior. +6. Update canonical docs and sitemap if navigation changes. +7. Run focused tests, package tests, typecheck, lint, and formatting. +8. Commit implementation/docs as `feat(#824): ...`, queue-guard, push, open PR with `Closes #824.`, fire completion event, and notify the coordinator. + +## Progress + +- 2026-07-17: Loaded mission/delivery/TDD/documentation rails, issue #824, active mission state, and relevant installer/update paths. +- 2026-07-17: Confirmed `mosaic update` invokes `framework/install.sh` with `MOSAIC_SYNC_ONLY=1`; that path exits before existing post-install skill linking, leaving newly present canonical skills unregistered. +- 2026-07-17: Coordinator addendum classified the user-supplied skill name and runtime symlink target as a path-traversal/symlink-injection surface. Expanded the initial red contract to reject traversal before mutation, preserve every foreign entry, and unregister Mosaic-owned links only. +- 2026-07-17: Implemented the Commander command group and secure generic bridge; wired wizard finalize and successful framework re-seed reconciliation; updated user/developer/installed/root docs and sitemap. +- 2026-07-17: Focused, package-wide, repository baseline, temp-home situational, and independent review gates completed. Ready for scoped feature commit, queue guard, push, and PR handoff. + +## Tests and evidence + +### TDD evidence + +- RED environment attempt: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/skill.spec.ts` initially could not locate Vitest because this fresh worktree had no dependencies. +- Dependency setup: `pnpm install --frozen-lockfile --store-dir /home/hermes/.local/share/pnpm/store` succeeded. The explicit store was required because machine pnpm config incorrectly resolves the default store under `/root`. +- RED behavior: focused Vitest failed with `Failed to load url ./skill.js ... Does the file exist?`, proving the bridge API was absent. +- RED integration: finalize/update specs failed because no Claude links or `skillSync` result existed. +- RED symlink injection: symlinked Claude/canonical root tests failed because the initial implementation followed ancestor links. +- GREEN after review remediation: `skill.spec.ts` 36/36, `finalize-skills.spec.ts` 6/6, and `update-checker.reseed.spec.ts` 30/30. + +### Baseline gates + +- `pnpm --filter '@mosaicstack/mosaic...' run build` — pass (fresh-worktree dependency outputs built). +- `pnpm --filter @mosaicstack/mosaic run typecheck` — pass. +- `pnpm --filter @mosaicstack/mosaic run lint` — pass. +- `pnpm --filter @mosaicstack/mosaic test` — pass: 69 files, 1,325 Vitest tests plus framework shell suite. +- `pnpm typecheck` — pass: 42/42 Turbo tasks. +- `pnpm lint` — pass: 23/23 Turbo tasks. +- `pnpm format:check` — pass. + +### Situational evidence + +A built-CLI temp-home smoke test (no real `~/.claude` or Mosaic config touched) proved: + +- register creates the exact link and a second run reports `already registered`; +- list reports registered and unregistered canonical skills; +- `../../etc` exits 1 with `Invalid skill name` and creates no escaped path; +- unregister removes the managed link and a second run reports `already unregistered`; +- a fake successful framework re-seed generically registered both `added-after-setup` and `second-skill` from runtime directory enumeration. + +### Review evidence + +- Initial uncommitted Codex code/security review described name validation/clobber protection as strong; its only finding was the harness-owned, unrelated `.mosaic/orchestrator/session.lock`, which is excluded from all commits and the PR. +- Exact branch review then identified two remediations: preserve successful framework re-seed status when bridge-wide reconciliation fails, and reject/escape control-character names to prevent terminal/log injection. +- Both findings were reproduced red-first and remediated. A subsequent exact review identified one finalize failure-isolation blocker; a root-wide bridge error now warns and allows wizard doctor/summary/next-steps completion, with a red-first regression. +- All remediations passed the full package and repository gates. Final exact-head review is rerun after amending the feature commit. + +### Acceptance mapping + +| Acceptance criterion | Evidence | +| --- | --- | +| register/unregister/list, idempotent | `skill.spec.ts` and built-CLI temp-home smoke | +| traversal/symlink-injection protection | invalid-name matrix, foreign file/dir/link tests, symlinked-root tests | +| list flags dangling and foreign entries | deterministic list status test | +| install and upgrade auto-sync every canonical directory | finalize + framework re-seed integration specs; two-skill built-module smoke | +| newly added skill becomes discoverable without manual link | `added-after-setup` auto-sync creates exact Claude link; Claude can rescan with `/reload-skills` or a new session | +| Pi/Codex parity captured as scope note | user guide, developer guide, installed framework README | +| documentation gate | root README, user guide, developer guide, framework README, sitemap | + +## Risks + +- Symlink replacement uses `lstat` semantics so dangling links are detectable without following them. +- Link ownership is determined lexically against the canonical skills root, and existing symlink ancestors in either managed root are rejected before mutation. +- Auto-sync continues across per-skill conflicts while never deleting real files/directories or foreign symlinks. +- Claude Code discovers filesystem skills at session launch/reload boundaries; bridge creation makes a later `/reload-skills` or new session able to discover the skill, but cannot mutate an already-cached in-process registry by itself. +- Pi does not need this Claude bridge because its Mosaic launcher can consume the canonical root. Codex lifecycle parity remains explicitly deferred. +- No deployment surface is affected. + +## PR #826 review remediation + +- 2026-07-17: Exact-head RoR requested changes for two ownership bugs: installer pruning deleted foreign-name links under `MOSAIC_HOME` outside canonical skills, and unregister deleted a same-root link targeting a different skill. It also requested trailing-dot rejection and executable coverage support. +- RED evidence: focused regression run failed 4 tests: register/unregister accepted `safe.`, misdirected unregister did not throw, and the install linker deleted the foreign-name link. +- GREEN evidence: `skill.spec.ts` passes 43/43, including live and dangling foreign-name links in a temp HOME/MOSAIC_HOME and the misdirected unregister invariant. +- Coverage: `vitest run src/commands/skill.spec.ts --coverage` passes configured 85% thresholds for `skill.ts`: 91.05% statements/lines, 86.27% branches, 95.23% functions. +- Full gates: package build passed; package tests passed 69 files / 1,332 tests plus framework shell suite; repository typecheck 42/42, lint 23/23, and format check passed. diff --git a/docs/scratchpads/828-lease-broker.md b/docs/scratchpads/828-lease-broker.md new file mode 100644 index 00000000..6c66923d --- /dev/null +++ b/docs/scratchpads/828-lease-broker.md @@ -0,0 +1,86 @@ +# WI-1 Scratchpad — Authenticated external lease broker + +- **Issue:** Gitea #828 +- **Milestone:** 188 — Compaction-Refresh Mechanism (M1: Claude + Pi) +- **Branch:** `feat/828-lease-broker` +- **Starting HEAD:** `d801d6c4c8a984d6a95033c49714210018d3d9a8` +- **Session role:** Orchestrator coordinating implementation; Mos retains merge authority. + +## Objective + +Implement the ratified WI-1 product lease broker under `packages/mosaic/`: Linux `SO_PEERCRED` identity, broker-minted logical session IDs, `(pid,starttime)` launcher anchors with per-hop `/proc` starttime revalidation, sibling-substitution rejection, same-PID runtime-generation revocation, crypto-RNG single-use token persistence, and protected Unix-socket posture. + +## Authority verification + +Verified before code on session start; all exact SHA-256 values matched: + +- BUILD-BRIEF: `89fdbc27ed0e5050dc7b52f3ef2ddaea691edf17fd89d51b15e26fb5ed47171b` +- SPEC-v5: `a6d07ade835758e8488ca10d3b0631caf0beb93ea3a6733631f151b0c2f01433` +- Ratification: `bac58319c9c4028b5b40e1129e0033cdb5a6b7b02033c25f06f4cb77d7779c67` +- P6 planner ruling: `b7bbb6ea6e8d9a5c3366993642ab4e4f65b961af04936dcac20bfbcdcbaf1a09` +- WI-0 Gate0 evidence: `5d418306fcc597fd514e500bee40d1509f0bf467e46ee13fc5c280ed8274759d` + +## Locked constraints + +- Build against the ratified design; do not re-derive it. +- Product code only in `packages/mosaic`; Gate0 Python probes are reference prototypes and are not shipped. +- Caller-supplied/asserted `session_id` is refused. +- Tokens use the operating-system CSPRNG via Python `secrets`; never `Math.random` or model output. +- Socket parent directory mode `0700`, socket mode `0600` minimum; document distinct-principal deployment as the stronger T-C-closing posture. +- Red-first TDD for six named cases; new-code coverage >=85%. +- No merge. PR must say `closes #828`; exact 40-character head handed to Mos for Opus-SECREV and independent review. + +## Plan + +1. Load security/testing/docs guidance and inspect existing `packages/mosaic` architecture. +2. Write the six required tests first and capture RED evidence. +3. Implement minimal broker modules and CLI/runtime integration necessary for product use. +4. Run focused tests with coverage, package gates, then full repository gates/suite. +5. Run author-side review/remediation, commit `closes #828`, queue guard, push, and open PR through Mosaic wrappers. +6. Send PR number + exact head SHA to `web1:mosaic-100`; stop without merging. + +## Risks / boundaries + +- Same-UID counterfeit socket replacement remains the disclosed T-C residual unless broker runs under a distinct principal; filesystem modes alone are minimum hardening, not a complete authenticity proof. +- `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` were already modified at session start and must not be included in this PR. +- Repository Woodpecker pipelines exist; CI is the canonical build path. No manual image build/deploy is in scope. + +## Progress / evidence + +- 2026-07-18 session start: mandatory mission files and orchestration guides loaded. +- STEP 0: all four authority hashes matched; artifacts read in full. +- Branch/HEAD confirmed; issue #828 open; Gate0 evidence hash confirmed. +- Initial RED: focused Vitest acceptance suite failed 11/11 because the product daemon did not exist; the expected missing-product failure was observed before implementation. +- Review-remediation RED: partial/zero-progress state writes, nested corrupt state, symlink state, canonical starttime, and duplicate-anchor generation behavior failed before their fixes. Real socket RED/GREEN runs were executed by the unrestricted parent harness because the delegated worker sandbox denies `AF_UNIX.bind()`. +- Product implementation added at `packages/mosaic/framework/tools/lease-broker/daemon.py`; Gate0 probe scripts were read as references but not copied or shipped. +- Independent Codex code review round 1 found 2 blockers + 1 should-fix (connection stall/crash, partial writes, packet-dependent framing); all were remediated with tests. +- Independent Codex code review round 2 found 2 blockers + 1 relevant should-fix (half-close contract ambiguity, incomplete persisted-state validation, symlink/non-regular state); all were remediated with tests and documentation. Pre-existing `.mosaic/*` session dirt remains excluded from the PR. +- Unrestricted focused situational suite: `35/35` GREEN. +- New Python product module coverage: `90%` (`356` statements, `36` missed), above the user-required 85%. +- Root typecheck: `42/42` Turbo tasks GREEN. +- Root lint: `23/23` Turbo tasks GREEN. +- Root format check: GREEN. +- Package build + suite: `71/71` files and `1,369/1,369` tests GREEN, including framework shell tests. +- Full root suite: `43/43` Turbo tasks GREEN after the oversized-frame production race fix. +- Focused acceptance suite: `35/35` GREEN in three consecutive unrestricted runs; exact-head instrumented run also `35/35` GREEN. +- Exact-head Python product coverage: `90%` (`365` statements, `37` missed), above the required 85%. +- Review-triggered oversized-frame race was fixed in production by bounded drain-to-EOF; tests were not changed. +- Commits banked in red/green cadence: `d61c5441` (RED contract), `deb11df7` (GREEN implementation/docs), `57770e34` (oversized-frame production fix). +- Final-review blocker remediated: added a 256-token pending-state cap, deletion on consume/generation revocation, pre-open serialized-size enforcement, and request-wide in-memory rollback for every broker mutation/commit failure while retaining the v1 live-token schema. +- Distinct-principal docs now state built-in `0700`/`0600` is same-principal only; WI-1 does not provide the external identity-preserving proxy/ACL/service boundary needed for the stronger deployment. +- Exact Python unit suite: `8/8` GREEN. Unrestricted focused acceptance: `35/35` GREEN. +- Exact-head package build/suite: `71/71` files and `1,369/1,369` tests GREEN. +- Exact-head Python product coverage: `90%` (`376` statements, `36` missed), above required 85%. +- Root typecheck: `42/42` GREEN. Root lint: `23/23` GREEN. Root format check and `git diff --check`: GREEN. +- Final exact-head rereview found two persistence blockers: post-rename directory-fsync uncertainty and acceptance of impossible persisted token records. RED was captured as three invariant failures plus one missing fail-stop error; commits `a94b1220` (RED) and `d05465e5` (GREEN) remediate both without weakening tests. +- Post-remediation evidence: Python unit suite `10/10`, focused real-socket acceptance `35/35`, full root suite `43/43` Turbo tasks, broker coverage `90%` (`395` statements, `38` missed), lint `23/23`, typecheck `42/42`, format check and `git diff --check` GREEN. +- Independent Codex review of remediation commit `d05465e54736c4966294c4af8fbd6a4ad8fe81aa`: APPROVE, confidence `0.94`, zero findings. Reviewer sandbox could not allocate temp directories; unrestricted parent test evidence above is canonical. + +- Remediation session: terra review comment `18072` reproduced a SERIAL-ACCEPT DoS; scope is RED regressions plus bounded concurrent connection handling on PR #836, preserving all existing broker security properties. +- RED evidence against reviewed daemon: four silent peers delayed registration `3920 ms` beyond the `1500 ms` bound; 16 silent peers were not reaped within `2500 ms`. The first bounded implementation then exposed slot exhaustion by rejecting the valid queued caller with `EPIPE`; admission was corrected to wait for a reclaimed bounded slot. GREEN evidence: queued-peer test `211 ms`; strengthened cap/reap/reclaim test `1118 ms`; complete real-socket acceptance `37/37` and Python persistence suite `10/10`. + +## Coordinator handoff requirements + +1. Mandatory Opus-SECREV on the exact PR head; no GPT/terra substitute. +2. Independent exact-head code review and exact-head RoR before Mos-authorized merge. +3. Mos retains merge authority; this WI author stops after PR + full 40-character head handoff. diff --git a/docs/scratchpads/829-mutator-gate.md b/docs/scratchpads/829-mutator-gate.md new file mode 100644 index 00000000..2c9c1f35 --- /dev/null +++ b/docs/scratchpads/829-mutator-gate.md @@ -0,0 +1,130 @@ +# WI-2 Scratchpad — Whole mutator-class gate + +- **Issue:** Gitea #829 +- **Branch:** `feat/829-mutator-gate` +- **Base HEAD:** `8ec67a1126adb0dcd4c3a2bf5525f3e239c0b201` +- **Role:** sol author/build lane only; terra code review and Opus security review are coordinator-owned. + +## Mission prompt + +Implement BUILD-BRIEF Deliverable 2 as a framework-native whole mutator-class gate under `packages/mosaic/`, building against the merged WI-1 lease broker. No consequential mutator may succeed while UNVERIFIED after a compaction observer fires or after TTL. Carry the T-B compromised-tool acceptance criteria. Enforce revoke-first and promote-last structurally. A receipt is only a promotion prerequisite; the mutator-class gate remains the safety mechanism. M1 is Claude + Pi only. + +## Authority verification + +Verified exact SHA-256 before design/code: + +- BUILD-BRIEF: `89fdbc27ed0e5050dc7b52f3ef2ddaea691edf17fd89d51b15e26fb5ed47171b` +- SPEC-v5: `a6d07ade835758e8488ca10d3b0631caf0beb93ea3a6733631f151b0c2f01433` +- Ratification: `bac58319c9c4028b5b40e1129e0033cdb5a6b7b02033c25f06f4cb77d7779c67` +- sol final red-team: `3da326a4ea91767b731e128a93b13194e8002358101e30de3fcb8ca2f8f54faa` + +Carried authority chain also verified/read for the locked T-B gate contract: SPEC-v4 `a5e9c261…`, v4 sol `1e76ee59…`, SPEC-v3 `e0830ba0…`, v3 sol `9f321ade…`. + +## Plan + +1. RED real-socket acceptance tests for default-deny whole classes, T-B raw-tool bypass, observer/TTL revocation, and structural revoke-first/promote-last. +2. Extend the merged WI-1 broker as the sole lease authority; authenticate every transition through existing peercred/ancestry/session logic and consume WI-1 single-use cycle tokens atomically before promotion. +3. Add one broker-backed runtime gate executable and wire it across all Claude `PreToolUse` tools and Pi `tool_call`; unknown/custom tools deny by default. +4. Add proportional protocol/security/operations documentation and requirements-to-evidence mapping. +5. Run focused coverage, package/full suites, lint/typecheck/format, then queue-guard, push, open an unmerged PR with `closes #829`, and hand off the exact head. + +## Risks and bounds + +- Receipt parsing/builders and compaction observers are later WIs; WI-2 exposes the promotion prerequisite boundary but does not treat a receipt as safety authority. +- Broker restart intentionally loses volatile VERIFIED leases and therefore restarts UNVERIFIED; persistent WI-1 identity/token state remains unchanged. +- The gate is whole-class and does not parse shell command strings. T-C extension/hook absence and same-UID broker replacement remain outside the client guarantee and server branch protection remains the backstop. +- Initial lease TTL is capped at ratified 300 seconds; callers may only shorten it. +- Working budget assumption: 35K tokens; reduce documentation/refactor breadth before touching locked scope if pressure rises. + +## Progress and verification + +- RED #1: all 5 initial real-socket contract tests failed on WI-1 with `UNKNOWN_ACTION` or missing adapter behavior. +- RED #2: register-before-exec runtime test failed because `launch-runtime.py` did not exist. +- GREEN: broker-owned volatile lease state, 300-second maximum monotonic TTL, WI-1 token-backed promotion, all-tools runtime gate, Claude/Pi wiring, and register-before-exec launcher delivered without changing WI-1 peercred/ancestry authority. +- Focused broker + gate acceptance: `43/43` GREEN. +- Instrumented Python coverage: `88%` total — daemon `89%`, register/exec launcher `86%`, runtime gate `86%`. +- Full repository suite: `43/43` Turbo tasks GREEN; `@mosaicstack/mosaic` `72/72` files and `1,377/1,377` tests GREEN. +- Root typecheck: `42/42`; lint: `23/23`; format check and `git diff --check`: GREEN. +- No author self-review was run. Exact-head terra CODE and Opus SECREV remain coordinator-owned gates. + +## Acceptance mapping + +| Acceptance criterion | Evidence | +| --- | --- | +| No consequential mutator succeeds while UNVERIFIED after observer revoke or TTL | `observer revocation and monotonic TTL expiry deny the next mutator` real-socket acceptance test | +| T-B compromised-tool bypass is covered by the whole gate | `T-B raw and custom mutator tools are default-denied without shell parsing` across Claude/Pi built-ins, raw Bash class, MCP/custom/unknown tools | +| Revoke-first / promote-last is structural | `revoke-first and promote-last structurally bracket mutator authority`; direct promotion rejected, pending remains denied, token consumption commits before VERIFIED | +| Consume WI-1 auth/lease substrate | All transitions and decisions traverse merged peercred/ancestry authentication; promotion consumes the exact WI-1 CSPRNG cycle token | +| M1 Claude + Pi | Claude `.*` PreToolUse and Pi `tool_call` invoke the same broker gate; register-before-exec test proves broker-minted parent identity reaches runtime descendants | + +## Locked discipline + +- Re-verify and read the four authority artifacts before design or code. +- RED-first tests must cover unverified mutation refusal, T-B compromised-tool refusal, and revoke-first/promote-last ordering. +- Consume WI-1 VERIFIED-lease state; do not re-derive kernel identity, ancestry, sessions, or token authentication. +- Minimum 85% new-code coverage; full suite, lint, typecheck, and format checks green. +- Build only: no self-review and no merge. Open a PR containing `closes #829`, report its exact 40-character head, then exit. + +## Remediation — terra CODE comment 18091 + +- Coordinator correction: terra returned REQUEST CHANGES at head `77b137ccc04b5be035cac5ca21bbbf3df8b94f97`; Opus SECREV was GO and CI green, but no evidence transfers to the remediated head. +- BLOCKER 1 verified: first-class Claude/Pi route through `execLeaseGatedRuntime`, while the supported Claudex path preserves isolation but directly invokes `claude`; it therefore registers no anchor, injects no lease session, and the isolated config has no guaranteed all-tools gate hook. +- BLOCKER 2 accepted: prior 88% was aggregate evidence. Remediation must produce independently measured branch coverage of at least 85% for each new executable (`launch-runtime.py`, `mutator-gate.py`, and daemon delta evidence), including successful exec-boundary collection and validation/error branches. +- Remediation discipline: RED tests first; preserve the reviewed-good broker lock/state-transition ordering; update PR #837 on the same branch; no self-review or merge. + +### Remediation evidence + +- RED commit `046896c6`: both `mosaic claudex` and `mosaic yolo claudex` behavioral probes exited 1 because the direct path supplied neither a broker session nor the isolated all-tools hook; branch-focused Python tests failed on the absent injectable boundaries. Fresh WI-2 daemon-delta instrumentation also failed the ≥85% branch gate at 75%. +- GREEN: Claudex now exposes only `execLeaseGated`, passes the preserved isolated proxy environment through the shared register-before-exec wrapper, and merges the exact `.*` mutator hook into isolated `settings.json` with mode `0600`. Missing broker/identity, malformed or symlinked settings, and an unverified consequential tool all fail closed. The broker transition/lock implementation was not changed. +- Behavioral regression: normal and YOLO Claudex both receive a 64-hex broker session, retain their mode-specific arguments, observe the exact all-tools hook, and receive status 2 for unverified `Bash`. +- Independent branch coverage: `launch-runtime.py` 16/18 = **89%** (statements 98%); `mutator-gate.py` 21/22 = **95%** (statements 99%); `daemon.py` WI-2 delta 35/40 = **88%** (whole-file branch 80%, statements 90%). +- Fresh focused real-socket coverage run: WI-1 + WI-2 acceptance `46/46`; persistence `10/10`; branch unit suite `10/10`. +- Fresh full repository suite: `43/43` Turbo tasks; `@mosaicstack/mosaic` `72/72` files and `1,381/1,381` tests. +- Fresh root gates: typecheck `42/42`; lint `23/23`; format and `git diff --check` GREEN. +- PR #837 remains open and unmerged. Terra CODE and Opus SECREV must both rerun from zero on the exact remediated head before coordinator-owned merge authorization. + +## Remediation round 3 — terra CODE comment 18099 + binding upgrade + +- Locked-good surfaces: Claudex gating and B2 per-executable coverage are verified; do not regress them. Broker state-transition/lock ordering remains untouched. +- Mechanical repository sweep found direct executing Claude entries in PRDY init, PRDY update, QA remediation, and `@mosaicstack/coord` task launch. It also found a direct Claude command rendered into the QA report template and documentation examples. Existing Mosaic CLI Claude/Pi/Claudex, orchestrator session-run, and fleet starts already reach the gated boundary. +- Elevated hard requirements: ship a permanent suite/CI guard that scans production source and fails on any direct Claude/Pi launch; route every executing entry through one common gated wrapper; add real-broker RED/GREEN tests for PRDY init/update and QA; preserve each environment and denial behavior; independently measure all new executable coverage at ≥85%. +- Round-3 plan: first commit RED behavioral and scanner-contract tests; then add one framework `launch-runtime.sh` choke-point over `launch-runtime.py`, make Mosaic CLI and shell launchers use it, make coord route through `mosaic`, and wire the permanent guard into package tests. Update all discovered operator-facing direct-launch examples so the scanner inventory remains complete. + +### Round-3 outcome + +- RED commit `7f3418fa`: PRDY init, PRDY update, and QA remediation all reached the fake Claude binary without a broker session even when the configured socket did not exist; the permanent-guard contract initially failed because its executable was absent, then failed against the five discovered direct entries (four executing plus the QA command template). +- Choke-point decision: a new shell layer was unnecessary. Every executing repository entry now converges directly or through `mosaic`/`execLeaseGatedRuntime` on the existing single `launch-runtime.py` register-then-exec wrapper. PRDY and QA preserve their working directories, prompts, flags, logging pipe, and environment. Coord rewrites direct Claude commands to `mosaic claude` and rejects unknown custom Claude launchers fail-closed. +- Permanent guard: `packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py`, invoked by `packages/mosaic/package.json` `test:framework-shell` and therefore root `pnpm test`/CI. It scans production code under `packages/`, `apps/`, `plugins/`, and `tools/` and rejects literal, absolute-path, process-API, command-array, and dynamic Claude/Pi launch forms. Synthetic bypass tests are permanent at `runtime_launch_guard_unittest.py`. +- Mechanical inventory: **14 gated / 14 total** — coord 2, fleet 1, QA 2, orchestrator 3, PRDY 2, Mosaic Claude/Pi/Claudex adapter/boundary 4. No verification-layer fallback or follow-up issue is needed because the single code-level wrapper was achieved. +- Real-socket behavioral evidence: PRDY init, PRDY update, and QA remediation each fail before runtime execution when the broker is absent; with the broker present they receive a broker-minted 64-hex session and the unverified `Bash` authorization exits 2. Claudex normal/YOLO and the broker state machine remain GREEN. +- Fresh branch coverage: `launch-runtime.py` **18/18 = 100%**; `mutator-gate.py` **22/22 = 100%**; permanent guard **36/38 = 95%**; `daemon.py` WI-2 delta **35/40 = 87.5%**. All attributable executable statement coverage is at least 98%. +- Fresh focused suites: broker + mutator real-socket acceptance `49/49`; persistence `10/10`; launcher/gate branch suite `13/13`; permanent guard suite `7/7`; coord `19/19`. +- Fresh full repository suite: `43/43` Turbo tasks; `@mosaicstack/mosaic` `72/72` files and `1,384/1,384` tests. Root typecheck `42/42`, lint `23/23`, format, and diff checks GREEN. +- PR #837 remains open and unmerged. Terra CODE and Opus SECREV must both rerun from zero on the exact round-3 head before coordinator-owned merge authorization. + +## Remediation round 4 — terra CODE comment 18104 + +- Locked-good surfaces: the 14/14 launch inventory, single `launch-runtime.py` choke-point, real-socket launcher behavior, coverage, Claudex gating, and broker state machine must not change. +- Reproduced RIDER E exactly at head `1792b7934dda7eff64a207b8b0edb9c460d4164b`: a temporary production file containing `exec claude --dangerously-skip-permissions "terra-r3" # launch-runtime.py` made the guard exit 0 and report `1 gated/1 total`. +- Root cause: classification searched the unparsed physical line, and the broad gated regex treated any `launch-runtime.py` substring—including comments and inert arguments—as an invocation before the direct-launch finding was evaluated. +- Round-4 plan: add permanent RED cases for the exact comment evasion plus string-argument, echo, and unrelated-variable marker evasions; tokenize/strip comments by launcher syntax; recognize only command-position wrapper invocations with `--runtime` and the gated command separator; retain 14/14 real inventory; rerun guard coverage and all gates fresh. +- Mos Rider A/B decision: adopt **both** defenses. Command-position parsing remains necessary because a normal `claude -p` launch is consequential even without the dangerous flag. The primitive-location invariant is more mechanically robust for dangerous mode because it does not need to recognize a wrapper marker at all. Move the sole raw `--dangerously-skip-permissions` literal into `launch-runtime.py`; any occurrence in another production file is independently RED. +- Rider-A RED matrix adds heredoc body, backslash continuation, non-first `;`/`&&`/pipe commands, command substitution, `eval`, and variable-execution indirection in addition to the six marker/comment evasions. Before the augmented implementation, primitive ownership, command substitution, `eval`, variable execution, and the preserved 14-site inventory all fail. +- Round-4 GREEN uses both defenses. Quote-aware comment stripping removes shell/Python `#` and JS/TS line/block comments; shell command prefixes are segmented with `shlex`; validated wrappers require `launch-runtime.py` in command position, `--runtime`, and the `--` command separator; multiline TypeScript wrapper calls are validated as complete invocations. Direct command syntax wins over markers, while tracked runtime assignments plus `eval`/variable execution, command substitution, chained commands, heredocs, continuations, and `env`/`command`/`nohup` prefixes are rejected. +- Primitive ownership is independently load-bearing: `launch-runtime.py` is the sole production owner of the raw Claude dangerous flag. Mosaic, Claudex, and PRDY request semantic `--dangerous`; the wrapper validates Claude and injects the primitive immediately before register/exec. This preserves actual YOLO argv behavior while making any raw primitive elsewhere fail without relying on wrapper-name recognition. +- Permanent guard suite now has 10 tests and 31 direct-launch forms, including 18 new round-4 marker/comment/indirection/prefix evasions plus harmless-marker and multiline-wrapper controls. Terra's exact add-ungated source is exercised through the CLI effectiveness test. Repository inventory remains exactly **14 gated / 14 total**. +- Fresh round-4 coverage: guard **97%** branch-aware aggregate (241 statements, 110 branches); `launch-runtime.py` **100%**; `mutator-gate.py` **100%**. The daemon is byte-unchanged from the round-3 head whose WI-2 delta is **87.5%**. +- Fresh round-4 gates: real-socket acceptance **49/49**; persistence **10/10**; launcher/gate **14/14**; guard **10/10**; coord **19/19**; Mosaic **1384/1384**; root **43/43**; typecheck **42/42**; lint **23/23**; format and diff checks green. + +## Remediation round 5 — terra 18116 + Opus 18114 + +- Both independent gates converged on one guard-only completeness gap at round-4 head `1eb77c17f3147d4fa9944f77f1826243135b9cc0`; all round-4 primitive anchoring, command-position parsing, 14/14 inventory, broker ordering, and coverage remain locked-good. +- Reproduced exactly: a temporary production source containing `launcher=claude` followed by `exec "$launcher" -p x` exits 0 with `0 gated / 0 total`. The literal command resolver skips prefixes but cannot resolve a tracked variable; the variable resolver handles only bare/eval references and cannot skip prefixes. +- Round-5 plan: add 10 permanent RED forms (quoted/unquoted `exec`, `command`, `nohup`, and `env` with assignment, each multiline and same-line), then unify shell command-position resolution so literal and tracked-variable terminal tokens traverse the same prefix parser. Retain an independent variable-reference backstop, the 14/14 inventory, and every round-4 regression. +- Mos stopping-criterion augment: command parsing is explicitly best-effort rather than a complete shell interpreter. Add B1 proving a parser-exotic alias launch with the raw dangerous flag is still RED by primitive anchoring, and B2 proving a parser-missed non-dangerous alias launch reaches the global `.*` hook and fails closed with `GATE_UNAVAILABLE` when no lease session exists. Document A (realistic parser matrix) + B (robust residual backstops); fresh reviewers supply criterion C (no new non-overlapping finding). +- RED commit `91a4a983`: all 10 prefix×variable cases failed as expected before the fix—quoted/unquoted `exec`, `command`, `nohup`, and `env A=1`, each in multiline and same-line assignment shapes. +- GREEN structural resolution: `shell_command_tokens()` now owns command-position prefix skipping for both literal and variable callers, including nested `exec`/`command`/`nohup`/`env` ordering. `runtime_variables` is threaded into `is_shell_direct_invocation()` and the same terminal-token resolver backs `executes_runtime_variable()`; exact `$v` and `${v}` references are resolved after `shlex` removes quoting. Same-line runtime assignment delimiters include shell operators. +- Residual backstops: B1 proves alias-indirected dangerous mode is classified `dangerous-primitive` even though the parser does not resolve the alias. B2 proves a non-dangerous alias residual remains parser-missed, then verifies the shipped global `.*` Claude hook and status-2 `GATE_UNAVAILABLE` denial for representative read, mutator, and custom/MCP tools without a lease session. +- Stopping-criterion evidence A+B is committed in tests and architecture docs; C remains the fresh exact-head terra/Opus determination. Repository inventory remains exactly **14 gated / 14 total**. +- Fresh round-5 coverage: permanent guard remains **97%** branch-aware aggregate (251 statements, 112 branches). Locked-good launcher and mutator-gate executables remain unchanged at their round-4 **100% / 100%** evidence. +- Fresh round-5 gates: real-socket acceptance **50/50**; persistence **10/10**; launcher/gate **14/14**; guard **12/12**; coord **19/19**; Mosaic **1385/1385**; root **43/43**; typecheck **42/42**; lint **23/23**; format and diff checks green. diff --git a/docs/scratchpads/830-compaction-revoke.md b/docs/scratchpads/830-compaction-revoke.md new file mode 100644 index 00000000..8ac449ba --- /dev/null +++ b/docs/scratchpads/830-compaction-revoke.md @@ -0,0 +1,95 @@ +# WI-3 Scratchpad — Compaction revocation and runtime-generation rollover + +- **Issue:** Gitea #830 +- **Branch:** `feat/830-compaction-revoke` +- **Base HEAD:** `abd2791f59b3f06f46dd08e55298ced72f6aa7c2` +- **Role:** sol author/build lane only; terra CODE and Opus SECREV are coordinator-owned. + +## Mission prompt + +Implement BUILD-BRIEF Deliverable 3.3 and D4 on merged WI-1/WI-2 under `packages/mosaic/`. Claude `PreCompact` and `SessionStart(matcher=compact)` plus Pi `session_before_compact`/`context` equivalents must revoke the active lease through the existing broker state machine. Any `runtime_generation` bump—including same-PID reload/resume/fork—must auto-revoke the prior incarnation so the new generation inherits no prior lease. M1 is Claude + Pi only. + +Honor amended D2-v5 exactly: hard fail-closure when at least one observer fires or after lease expiry; both observers missing within TTL is an explicitly named bounded residual stale window (maximum 300 seconds, soak-tighten only), with no claim that the mutator gate bounds actions inside that window; total gate-hook miss is T-C. T12b/T30 must report both the within-TTL ALLOWED outcome and after-TTL DENIED outcome. + +## Session start verification + +- Worktree is clean on `feat/830-compaction-revoke` at exact required base `abd2791f59b3f06f46dd08e55298ced72f6aa7c2`; `origin/main` is the same SHA and includes merged WI-2 atop WI-1. +- Authority SHA-256 verified: + - BUILD-BRIEF: `89fdbc27ed0e5050dc7b52f3ef2ddaea691edf17fd89d51b15e26fb5ed47171b` + - SPEC-v5: `a6d07ade835758e8488ca10d3b0631caf0beb93ea3a6733631f151b0c2f01433` + - Ratification: `bac58319c9c4028b5b40e1129e0033cdb5a6b7b02033c25f06f4cb77d7779c67` + - sol red-team: `3da326a4ea91767b731e128a93b13194e8002358101e30de3fcb8ca2f8f54faa` +- WI-0 evidence pack SHA-256 `5d418306fcc597fd514e500bee40d1509f0bf467e46ee13fc5c280ed8274759d` read directly. Probe P3 is **PASS**: real Pi retained the same PID/starttime through reload/fork/new/resume while generations advanced and a prior VERIFIED generation was revoked. +- P6 planner-return ruling SHA-256 `b7bbb6ea6e8d9a5c3366993642ab4e4f65b961af04936dcac20bfbcdcbaf1a09` read directly: feature WI admission is GO with the exact-delivery empirical compatibility fact and disclosed T-C middle-drop residual; no receipt redesign. + +## Plan and budget + +1. RED real-socket acceptance for T12b/T30, each Claude observer, same-PID generation rollover, Claude/Claudex hook wiring, and Pi lifecycle wiring. +2. Add one broker client executable for observer revocation plus a private monotonic generation-file helper shared by launcher, gate, and revoker. +3. Wire Claude `PreCompact`, `SessionStart(compact)`, and resume/clear generation rollover; merge equivalent mandatory hooks into isolated Claudex settings. +4. Wire Pi pre/post compaction observers and reload/new/resume/fork generation rollover with local fail-closed tool blocking if lifecycle revocation fails. +5. Document the D2-v5 bounded stale window without claiming the mutator gate bounds within-TTL actions; update protocol/security/operations/sitemap/checklist. +6. Run focused real tests, independently measured executable coverage ≥85%, full repository gates, commit/push, open an unmerged `closes #830` PR, and hand off for terra CODE + mandatory Opus SECREV. + +Working estimate: **35K tokens**. No explicit hard cap was supplied; reduce refactor breadth before touching locked broker authority/state-machine semantics. + +## RED evidence + +- New T12b/T30 test already reports the inherited primitive honestly: within-TTL **ALLOWED**, after-TTL **DENIED**. The complete AC remains RED because the mandatory threat-contract document is absent. +- Focused real-socket suite is RED with 7 expected failures: missing revoker executable (both Claude observers + generation bump), missing Claude/Pi wiring, missing isolated Claudex observers, and missing D2-v5 disclosure. +- Branch-focused Python suite is RED on the wished generation initializer/resolver interfaces and missing `lease_generation.py` / `revoke-lease.py`. +- Pi lifecycle suite is RED because the wished standalone `lease-lifecycle.ts` observer/generation module does not exist. + +## Locked discipline + +- RED-first T12b/T30 and observer/generation tests; test commit precedes implementation. +- Reuse broker `revoke_lease`; do not fork identity, lease, or transition authority. +- Preserve revoke-first/promote-last and WI-1/WI-2 reviewed state machine. +- ≥85% attributable executable coverage with real tests. +- No author self-review, no merge, no `--no-verify`. + +## Local implementation complete (push held) + +Implemented on the WI-3 base `abd2791f59b3f06f46dd08e55298ced72f6aa7c2` without changing the reviewed broker state machine: + +- Added `revoke-lease.py`, which authenticates through the existing broker session/generation and invokes `revoke_lease`. A fired observer that cannot confirm broker revocation advances the private generation as a local fence before returning non-zero. +- Added `lease_generation.py`: owner/type/mode/size validation, no-follow opens, exclusive bump lock, monotonic `int64` generation, write-all + `fsync`, and fail-closed exhaustion/corruption handling. +- `launch-runtime.py` creates `generation-.state` mode `0600` beside the socket before `exec`; `mutator-gate.py` resolves that current file value on every tool check. +- Claude settings and isolated Claudex settings now preserve/install `PreCompact`, `SessionStart(compact)`, and resume/clear rollover hooks in addition to the global all-tools gate. +- Pi now registers tested `session_before_compact`, `session_compact`→first `context`, and `session_start(reload|new|resume|fork)` handlers. Failed pre-compact revocation cancels compaction; failed post-compact/rollover revocation latches local all-tool denial. +- Added PRD requirements, architecture/security/protocol/operations updates, sitemap entry, and the ignored-by-default documentation checklist (force-add required at commit). + +### Acceptance and coverage evidence + +- Focused acceptance: `19/19`; T12b/T30 prints within-TTL **ALLOWED** and after-TTL **DENIED**. +- Pi lifecycle: `8/8`, with **100% statements/branches/functions/lines** attributable coverage. +- New Python generation/revoker: `24/24`, **99% branch-aware aggregate coverage** (`lease_generation.py` 98%, `revoke-lease.py` 100%). +- Mosaic package: `1399/1399`; framework shell Python `24/24`, launch guard `12/12`, permanent launch inventory `14 gated/14 total`. +- Existing lease-broker real-socket acceptance: `37/37` within the package run. +- Full repository: `43/43` Turbo tasks green; gateway `628 passed / 12 skipped`; Mosaic `1399/1399`. +- Root typecheck: `42/42`; lint: `23/23`; format and `git diff --check` green. +- Initial direct package test without first building the package reproduced the known missing-`dist/cli.js` harness condition; the canonical root Turbo test (which schedules `@mosaicstack/mosaic#build`) and explicit package build+test are green. No test was weakened. + +### Review evidence + +- Codex uncommitted code review: **APPROVE**, confidence `0.88`, zero findings. Its read-only sandbox could not rerun Vitest, but the author-side focused and full suites above were green. +- Codex uncommitted security review: risk **none**, confidence `0.91`, zero findings. +- Coordinator-mandated fresh exact-head terra CODE and Opus SECREV remain pending after rebase/push clearance; these local reviews do not replace that final gate. + +### Hold and residuals + +- **DO NOT PUSH OR OPEN A PR YET.** Coordinator requires flake-fix #838 to land, then WI-3 must rebase onto deterministic-green `main` before push. +- Merge remains gated on #838, #827 Probe 3, and combined GO. +- Named residual retained verbatim: when both observers are entirely missed, within-TTL consequential actions remain allowed; only lease expiry denies after the bounded stale window. No within-window mutator-action bound is claimed. + +## Deterministic-main rebase evidence + +- Fetched and confirmed `origin/main` at `8dfcf1903e385f977121069f798f476eb671fffc` (`#838` bounded broker deadlines, empty-read fail-closure, and de-flaked acceptance client). +- Linear rebase completed. The only content conflict was `packages/mosaic/src/mutator-gate/runtime_tools_unittest.py`; resolution retained #838's `subprocess`/`threading` deadline regressions and WI-3's `stat` generation-state coverage. No authority/state-machine choice was ambiguous. +- `packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts` auto-merged on top of #838's shared `requestBrokerReply` helper. No inline socket/`JSON.parse` client was resurrected. +- Verified WI-3 has zero diff from `origin/main` for #838-owned `daemon.py`, `broker-test-client.ts`, `lease-broker.acceptance.spec.ts`, `vitest.config.ts`, and `packages/mosaic/package.json`; bounded deadlines and the de-flaked harness are preserved byte-for-byte. +- Required verbose acceptance command: **2 files / 56 tests green**. T12b/T30 still prints within-TTL **ALLOWED** and after-TTL **DENIED**. +- Full Mosaic package after explicit build: **74 files / 1408 tests green**; deadline unit `2/2`, runtime tools `25/25`, launch guard `12/12`, inventory `14/14`. +- Full repository: **43/43 Turbo tasks green**. Root typecheck `42/42`, lint `23/23`, format and diff checks green. +- Attributable coverage remains Python **99%** branch-aware and Pi lifecycle **100%** statements/branches/functions/lines. +- Push and PR remain held pending combined GO and all WI-3 merge gates. The coordinator-owned promote-lease-lost-ACK SPEC amendment/backstop is acknowledged as a future merge prerequisite and was not retro-expanded into this core rebase/build. diff --git a/docs/scratchpads/832-receipt-challenge-protocol.md b/docs/scratchpads/832-receipt-challenge-protocol.md new file mode 100644 index 00000000..9bc36483 --- /dev/null +++ b/docs/scratchpads/832-receipt-challenge-protocol.md @@ -0,0 +1,13 @@ +# #832 Receipt-challenge protocol — build scratchpad + +- **Objective:** Deliver WI-5 receipt-challenge protocol ACs T25, T26, T28, and T29 only. +- **Authority:** BUILD-BRIEF, SPEC-v5, ratification, and red-team hashes verified in STEP-0. +- **Base:** `e522b22fa4492861b0fcd4a956a8795c54eb9bfe` (`origin/main`). +- **Constraints:** Byte-build only: no live broker/socket/systemd/tmux mutation. No PR, self-review, or probe fire. T27/T30 are out of scope. +- **Plan:** + 1. Add red-first deterministic T26/T29 in-build tests that call shipped normative construction and broker path. + 2. Add an unexecuted, isolated P5 out-of-process replay harness that drives the shipped daemon and asserts consume-before-promote for T25/T28. + 3. Implement the broker-minted receipt challenge and exact receipt observation/consume/promote path. + 4. Run unit, framework-shell, compile, lint, and type checks; push after the required queue guard; report to `mosaic-100`. +- **Risks:** The standalone harness must drive the real daemon without a divergent fixture. If that is impossible, stop and flag Mos. +- **Evidence:** Initial RED recorded in `/home/hermes/agent-work/reviews/832-wi5-red-receipt-challenge.log`; initial green checks passed. Remediation RED recorded in `/home/hermes/agent-work/reviews/832-wi5-remediation-red.log` before observer/payload implementation; remediation green passed. Remediation-2 RED recorded in `/home/hermes/agent-work/reviews/832-wi5-remediation2-red.log`: each rejected begin restored prior VERIFIED authority. Remediation-2 GREEN: receipt unittest (5: all `INVALID_CONSTRUCTION`, `PAYLOAD_CONSTRUCTION_REFUSED`, and `PAYLOAD_BINDING_MISMATCH` cases preserve UNVERIFIED and deny the next mutator), normative-fragments unittest (5), state-store regression (10), full mutator-gate acceptance (20, including the real begin → observer → consume → promote path), `py_compile`, Mosaic package lint/typecheck, and targeted Prettier check. The P5 harness remains unfired. Coverage tooling remains unavailable (`python3 -m coverage`: module not installed). Push pending. diff --git a/docs/scratchpads/833-constrained-recovery-command.md b/docs/scratchpads/833-constrained-recovery-command.md new file mode 100644 index 00000000..20510cfb --- /dev/null +++ b/docs/scratchpads/833-constrained-recovery-command.md @@ -0,0 +1,15 @@ +# #833 constrained recovery command — build scratchpad + +- **Objective:** Deliver WI-6 plus Mos-ruled B1/B2 and R2 Claude-only literal-argv repair: no shell-active recovery mapping bypass, unchanged Pi gate/B2 observer, AC-1/C4 preservation, and an unfired P6 probe. +- **Authority:** STEP-0 SHA-256 verified 4/4 against the supplied BUILD-BRIEF, SPEC-v5, ratification, and red-team records. +- **Base:** exact `07553ead337a70a9241f826d27571650262b289c`; new branch `feat/833-constrained-recovery-command`; merge-base assertion passed before any commit. +- **Constraints:** No rebase/pull during build; no live install/symlink or live broker/socket/tmux/systemd/model-stream activation; no self-review, PR, merge, push, or P6 fire. `docs/TASKS.md` is orchestrator-owned and will not be modified. +- **Plan:** + 1. Add red-first unit tests against the recovery broker entrypoint for fresh recovery challenge, normal-receipt replay refusal, observable partial-delivery refusal, and the explicit middle-drop negative capability; commit the RED test and preserve its command output. + 2. Implement the recovery command as a thin driver over shared WI-5 broker transitions and the trusted observer seam; it never accepts caller receipt text. + 3. Add the source-resident skill under `packages/mosaic/framework/skills/`, plus a tmp-only #824 bridge projection test. + 4. Build an unfired, default-3-run P6 standalone real-socket driver; it is not added to package scripts and will not be run. + 5. Run targeted broker/mutator/receipt suites, lint, and format check; report head to `mosaic-100` and stop. +- **Risks:** The observer can only represent an exact latest message. Tail-preserving middle-drop is intentionally not claimed receipt-detectable (T-C residual deferred to WI-7 server evidence). +- **Evidence:** Original RED test committed at `3b5513bd6efad0c06b599fc759a66cff4286db04`; expected `UNKNOWN_ACTION` is logged in `/home/hermes/agent-work/reviews/833-wi6-red.log`. B1/B2 repair RED is `f4beedc3e7ac2e142dcbdeefb0e5ee40c20d9b86` in `/home/hermes/agent-work/reviews/833-wi6-repair-red.log`. R2 adversarial RED is committed at `65e2bd71cf360b6f45c86eec0b06ae24494832d8` in `/home/hermes/agent-work/reviews/833-wi6-repair-R2-red.log` before the literal-only gate source: the private real-gate/real-daemon battery covers every argv position (executable, path, phase, each flag, each value) for command substitution, backticks, parameter/arithmetic expansion, brace/tilde, process substitution, glob, redirects, control operations, embedded newline, and quotes. P6 remains rebuilt and unfired. The ordinary package Vitest/lint/typecheck/root-format commands cannot resolve their executables in this intentionally dependency-free fresh worktree (`node_modules` absent); no install/symlink workaround was used. +- **Budget:** No explicit task token cap was supplied; scope is fixed to WI-6 and no unrelated behavior will be added. diff --git a/docs/scratchpads/838-broker-acceptance-flake.md b/docs/scratchpads/838-broker-acceptance-flake.md new file mode 100644 index 00000000..1e52a57a --- /dev/null +++ b/docs/scratchpads/838-broker-acceptance-flake.md @@ -0,0 +1,44 @@ +# Issue #838 — Broker acceptance socket flake + +## Objective + +Eliminate high-contention uncaught JSON parse failures in lease-broker and mutator-gate acceptance helpers without laundering malformed/empty replies into passing assertions. + +## Constraints + +- Branch: `fix/838-broker-acceptance-flake` in `/home/hermes/agent-work/stack-838-flakefix`. +- Red-first TDD with a forced empty/truncated reply path and deterministic rejection or documented retry. +- Read newline-framed replies completely; reject malformed broker replies with byte length/content context. +- Determine RIDER2 branch before finalizing: test-harness-only `(a)` or daemon write truncation `(b)`. +- If product-side, prove the real Claude/Pi adapter read path fails closed; fix any allow-risk. +- Coverage >=85% per changed executable through real tests. +- Full suite and repository gates green; independent exact-head review required. +- Push and open a PR containing `closes #838`; do not merge. + +## Progress + +- 2026-07-17: Reclaimed from #824. Confirmed clean worktree on `fix/838-broker-acceptance-flake` at main base `abd2791f59b3f06f46dd08e55298ced72f6aa7c2`. +- RED evidence: after dependency setup, `broker-test-client.spec.ts` failed to load the intentionally absent shared client module. Its contract forces empty-close retry, repeated truncated-close rejection with byte context, and newline-terminated malformed-reply rejection. +- RIDER2 verdict: branch **(b)**. `daemon.py` starts one connection deadline before request reading, then may spend that budget waiting for `broker_lock`; after handling, `remaining <= 0` returns without writing. A timed `sendall` failure can likewise close after a partial write. A deterministic socketpair probe held the lock past `CONNECTION_DEADLINE_SECONDS` and observed `deadline_probe_reply_length=0`. +- Adapter tripwire: real subprocess executions of `mutator-gate.py` for both `--runtime claude` and `--runtime pi` against actual Unix servers returning empty and truncated replies all exited 2 with `GATE_UNAVAILABLE`. Adapter fail-close is proven; there is no ALLOW risk. +- Residual: production broker reply loss remains an availability-denial path under extreme contention, but cannot grant mutator authority. The acceptance-only client retries early closes and otherwise rejects with response byte length, escaped bytes, and hex; malformed newline-framed replies are never retried or converted to reply objects. +- Shared client now owns newline framing and parsing for both acceptance suites. Focused suites pass 60 tests, including the real adapter tripwire. +- Coverage gate: changed helper is 100% statements/lines/functions and 90% branches; existing `skill.ts` remains above 85% per-file thresholds. + +## Scope corrections and bounded product repair + +- Coordinator correction superseded the initial push/PR and retry language: this lane is BUILD-ONLY, and early-close retries are forbidden because they can mask a committed broker transaction. Nothing may be pushed or opened until explicitly cleared. +- Revised RED evidence: the no-retry empty/truncated tests failed because the first failed exchange was retried into `{ ok: true }`; the daemon regression failed because a slow completed `broker.handle()` produced `b''` instead of a newline-framed reply. +- Client repair: both former duplicated helpers use one shared reader. Empty, truncated, malformed, oversized, timed-out, and socket-error replies reject `BrokerTransportError` with a typed `kind`, attempt count fixed at one, response length, escaped bytes, and hex. No retry and no catch-to-reply conversion exists. +- Product repair: `daemon.py` now has independent bounded read, broker-lock queue, and send budgets. Lock queue exhaustion returns explicit `BROKER_BUSY` before `broker.handle()` can mutate state. Once handling starts it finishes atomically, and its reply always receives a fresh send timeout instead of being skipped because read/lock/fsync consumed a shared deadline. +- Product GREEN evidence: deterministic socketpair tests prove lock saturation returns framed `BROKER_BUSY` without invoking `handle()`, and a handle that completes after the former one-second shared deadline still returns its complete framed reply. +- RIDER2b remains **fail-closed**: real Claude and Pi `mutator-gate.py` subprocesses against empty and truncated Unix-socket replies exit 2 with `GATE_UNAVAILABLE`; no malformed/default ALLOW was observed. +- Residual/tripwire: an unavoidable peer disconnect or send failure can still lose acknowledgement after a valid transaction commits. The affected adapter call fails closed. A valid `promote_lease` may nevertheless remain VERIFIED after its acknowledgement is lost; that is authority-observability divergence requiring WI-3/Opus security review rather than expansion of #838. #838 does not add retries or attempt a protocol redesign. +- Final package evidence: recursive Mosaic dependency build passed; 73 Vitest files / 1,392 tests passed; deadline unit tests 2/2, real runtime tool tests 15/15, launch guard tests 12/12, inventory 14/14, and shell regressions passed. +- Final coverage: `broker-test-client.ts` 99.24% statements/lines, 86.11% branches, 100% functions under per-file >=85% thresholds. Repository typecheck 42/42, lint 23/23, and format check passed. +- Independent Codex exact-head review requested one framing fix: a valid frame followed by trailing bytes in a later socket chunk could resolve before the garbage arrived. Security review also flagged complete token-bearing reply bodies in diagnostic properties/logs. +- Review RED evidence: delayed cross-chunk garbage resolved `{ ok: true }` instead of rejecting, and a truncated `promotion_token` remained in the typed error. The tests use separate timed writes to prevent kernel/event-loop coalescing. +- Review remediation: the shared client now accumulates through EOF, requires exactly one terminal newline, then parses inside a rejecting error boundary. Diagnostic bodies are capped at 256 bytes, sensitive broker fields are fully redacted, and a SHA-256 digest preserves correlation without credential disclosure. +- Post-review coverage: `broker-test-client.ts` 99.35% statements/lines, 86.66% branches, 100% functions. Final full suite is 73 files / 1,394 tests plus all Python/shell gates; recursive build, typecheck, lint, and format are green. +- Fresh exact-head Codex security review: risk `none`, no findings. Fresh code review found only that the real-adapter subprocess proof lacked a timeout; it now has a five-second bound and fails with runtime/wire context while closing the fake server. The focused Python suite remains 15/15 green. +- Mandatory Opus SECREV remains coordinator-owned and pending before any push/PR decision; this build is intentionally local-only. diff --git a/docs/scratchpads/fcm-m2-001-generated-env-boundary.md b/docs/scratchpads/fcm-m2-001-generated-env-boundary.md new file mode 100644 index 00000000..f71c46dd --- /dev/null +++ b/docs/scratchpads/fcm-m2-001-generated-env-boundary.md @@ -0,0 +1,110 @@ +# FCM-M2-001 — Generated Environment Boundary + +- **Issue/card:** #758 / FCM-M2-001 +- **Branch/base:** `feat/758-generated-env-boundary` from `origin/main` `e9c4aa3e8b3780719cd5a43c0ef3f37fc70de666` +- **Budget assumption:** 30K-card budget; implement only the deterministic generated/local environment boundary and its launch-chain/docs/tests. + +## Objective + +Replace the generic fleet agent `.env` authority/merge path with a deterministic roster-derived `.env.generated` projection and strict, data-only `.env.local`. The roster remains the desired-state authority. Reject bad input before the launcher creates a tmux session; never print sensitive or privileged-command values. + +## Scope and non-goals + +- In scope: deterministic render/write, strict generated/local parse rules, legacy `.env` disposition/quarantine, systemd/launcher boundary, permission/path checks, focused fail-closed tests, operator/reference documentation, USC interface evidence. +- Excluded: roster CRUD/mutation, v2 roster schema changes, lifecycle/reconcile/apply behavior, migration/canary rollout, connectors, remote surfaces, live-fleet actions, and M2-002. + +## Plan + +1. Add red tests for generated-key shadowing, malformed/duplicate/unknown/command/sensitive input, no-value diagnostics, deterministic/idempotent projection, secure file modes, and legacy disposition. +2. Implement a pure strict environment contract plus atomic projection/quarantine helper. +3. Replace the generic `.env` writer/merge path and systemd reference with `.env.generated` + `.env.local` ownership. +4. Make the shell launcher parse the files without `source`/`eval`, reject unsafe input before tmux creation, and construct only the roster-derived runtime command. +5. Add operator/reference documentation with the requested USC M1 interface evidence and M2–M4 gate statement. +6. Run focused/package/root gates and audit the USC interface packet. Per continuation scope, stop before review, commit, push, PR, or live mutation. + +## Initial evidence + +- No existing owner: target worktree path absent; no target local/remote branch; `pr-list.sh -s open` returned no open PRs. +- M1 compiler/API/docs and executable disposition evidence are present at the assigned base. +- Existing launch chain writes `fleet/agents/.env`, preserves arbitrary legacy lines via `mergeAgentEnv`, sources `MOSAIC_AGENT_COMMAND`, and executes it through `bash -c`; all are M2 remediation targets. +- `~/.config/mosaic/guides/SECURITY.md` is absent. Read the available security-review role contract and the vault/secrets guide instead. + +## Verification log + +### Continuation (2026-07-14) + +- Preserved the inherited 14-file delta; no reset, stash, rebase, roster mutation, lifecycle action, + live-fleet action, commit, push, or PR action was performed. +- Focused gates passed: + - `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` — 1 file, 10 tests passed. + - `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` — passed. + - `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — passed. + - `pnpm --dir packages/mosaic test -- src/commands/fleet.spec.ts` — 1 file, 192 tests passed. +- Package gates passed before final documentation/format follow-up: + - `pnpm --dir packages/mosaic typecheck` — passed. + - `pnpm --dir packages/mosaic lint` — passed. + - `pnpm --dir packages/mosaic test` — 52 files, 752 tests passed. +- `pnpm format:check` initially failed only for the new boundary reference and generated-boundary + TypeScript files; targeted Prettier normalization was applied. A final `pnpm format:check` passed. +- USC packet audit: the M1 structural compiler is `parseRosterV2` with roster `version: 2`; the + semantic resolver is `validateRosterV2Semantics`; disposition artifacts retain `version: 1` fixture + evidence. `docs/TASKS.md` records M1-001 done, M1-002 in-progress, and M1-003 not-started; no + product release version is claimed. The packet now distinguishes these statuses from checkout + artifact presence and states the M2 → M3 → M4 downstream gates. + +## Review remediation (2026-07-14) + +- **Blocker 1 red-first:** Added a launcher reproducer with a `0777` `fleet/agents` parent and a private generated file. Before implementation, `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` failed: `FAIL: generated file under a world-writable parent was accepted`. The failure occurred after the launch path reached fake tmux, proving the parent was not validated. +- **Blocker 2 red-first:** Added a `symlink()` projection-directory reproducer that preloads generated/local/quarantine/legacy target files and asserts no target mutation. Before implementation, `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` failed the new test because the existing writer followed the `agentEnvDir` symlink and parsed its target legacy input (`expected /unsafe-directory/i`, received `code=malformed-line`). The initial test-only missing `mkdir` import was corrected before recording this behavior failure. +- **Blocker 3 red-first:** Added fresh/stale/absent native-heartbeat regression coverage. Before implementation, an isolated fake-tmux launcher reproducer with a fresh `.hb.native` marker failed `FAIL: fresh native heartbeat was overwritten`; the existing sidecar immediately replaced native `status=busy`/`model` content. +- **Remediation result:** The launcher now rejects a group/world-accessible or symlinked `fleet/agents` parent before an environment read or tmux call. The projection writer uses `lstat` before chmod/write processing and rejects a symlinked directory without creating generated/local/quarantine files or deleting legacy input. The heartbeat sidecar defers to a fresh non-symlink native marker and falls back when stale/absent. Focused green evidence before independent review: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` and `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` (11 tests) passed. +- **Independent-review follow-up red-first:** Codex code review returned one blocker and security review one medium CWE-732 finding: the writer repaired an already `0777` directory with `chmod` before trusting its contents. Added a reproducer with a safe local file beneath an existing `0777` directory. Before the follow-up fix, `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` failed because the promise resolved and wrote `coder0.env.generated` instead of rejecting. +- **Independent-review remediation:** Existing directories now pass non-following private-directory validation before any read or chmod; only a directory created in this call is normalized to `0700`. The fleet-add test fixture now creates its simulated trusted `fleet/agents` boundary at `0700`; this corrects fixture setup to match the new required contract rather than weakening the rejection assertion. Focused reruns passed: generated-boundary 12 tests, launcher boundary suite, and fleet suite 192 tests. +- **Final verification before re-review:** Launcher + systemd suites passed; package suite passed (52 files, 754 tests); package lint/typecheck, root typecheck (42 tasks), format check, and diff check passed. The rerun code review still reports a tmux command-arity blocker, and the security rerun reports systemd `EnvironmentFile` pre-validation injection findings for both agent units. These were discovered after the specified three-remediation scope; no additional source changes were made. Independent review therefore remains `REQUEST CHANGES` despite the requested three fixes passing their behavioral suites. + +## Systemd pre-validation remediation (2026-07-14) + +- **Red-first:** Updated the fleet unit contract to reject any `EnvironmentFile=` projection preload, require a cleared bootstrap environment, and require a validated exact-stop path. Before implementation, `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` failed: `FAIL: agent units must not preload projections before strict parsing`. +- **Red-first parser/stop coverage:** Added interaction-wrapper and exact-stop cases to the launcher boundary suite. Before implementation, `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` failed: `FAIL: interaction did not use shared strict parser first`, because the interaction wrapper consumed inherited environment before projection validation. +- **Focused green:** Both unit templates now use `env -i` with fixed `HOME`, agent instance, and PATH; neither has `Environment=`/`EnvironmentFile=`. The interaction wrapper delegates to `start-agent-session.sh --interaction`, so strict generated/local parsing precedes pinned Pi profile checks. `--stop` reuses the strict generated parser before exact `=` socket/session termination. Passed: systemd unit suite, launcher boundary suite (including malformed interaction, pinned profile, and ambient-socket stop cases), and 210 focused TypeScript tests. +- **Final verification:** `pnpm --dir packages/mosaic test` passed (52 files, 754 tests); package lint/typecheck, root typecheck (42 tasks), format/diff, and shell syntax checks passed. Security review passed with no findings. Code review repeated the previously refuted tmux argv concern and a pre-existing Claude trust-lock suggestion; per the assigned narrow follow-up, no tmux or unrelated trust-path change was made. + +## Risks and next review + +- This card is uncommitted and unreleased. The canonical tracker still records its dependencies as + M1-002 in progress and M1-003 not started; this continuation does not reinterpret those task states. +- Final post-documentation checks passed: `pnpm --dir packages/mosaic typecheck`, + `pnpm --dir packages/mosaic lint`, `pnpm --dir packages/mosaic test` (52 files, 752 tests), + `pnpm typecheck` (42 Turbo tasks), and `pnpm format:check`. +- Obtain independent code and security review of the complete delta next. Do not run commit, push, + PR, or live-fleet commands in this continuation. + +## Fresh-install directory remediation (2026-07-14) + +- **Objective:** Remediate only the fresh-install path where `installFleet` created `fleet/agents` + with host-umask permissions before the boundary writer correctly rejected it. +- **Plan:** Add a real `fleet install --no-enable` integration reproducer; prove red; let the + existing boundary writer own directory creation; run focused and full gates. No commit, push, + PR, review disposition, or live-fleet action. +- **Red evidence:** Before the one-line remediation, + `pnpm --dir packages/mosaic test -- src/commands/fleet.spec.ts` failed the new test with + `AgentEnvBoundaryError: code=unsafe-permissions` at `ensurePrivateProjectionDirectory`, after + `installFleet` pre-created the directory. +- **Change:** Removed only the recursive `mkdir(activePaths.agentEnvDir)` in `installFleet`. + `writeAgentEnvironmentProjection` remains the sole creator and retains its existing `lstat`, + private-directory, symlink, and existing-unsafe-directory fail-closed checks. +- **Focused green:** `pnpm --dir packages/mosaic test -- src/commands/fleet.spec.ts` — 193 tests + passed. The new integration executes a fresh `fleet install --no-enable`, asserts a real + non-symlink `0700` directory and a `0600` generated projection. Existing unsafe-directory + coverage remains in `generated-env-boundary.spec.ts` and asserts no chmod repair/no generated + file write. +- **Full gates green:** generated-boundary 12 tests; launcher and systemd suites; package + typecheck/lint and 52 files / 755 tests; root typecheck (42 tasks), lint, format, diff check, + and root test (42 tasks) all passed. +- **Independent review:** The complete inherited uncommitted delta still has Codex `REQUEST CHANGES` + findings outside this narrow fix (tmux command arity and Claude trust-lock regression), plus a + security-review medium finding on unvalidated writable ancestor directories. No out-of-scope + source changes were made. +- **Risk:** The writer's existing create-then-validate sequence is relied on for the creation + boundary; a concurrent substitution causes fail-closed validation rather than repair. The + review findings above remain residual risks for the complete card delta. diff --git a/docs/scratchpads/fcm-m5-001-fleet-config-operator-docs.md b/docs/scratchpads/fcm-m5-001-fleet-config-operator-docs.md new file mode 100644 index 00000000..59ff4433 --- /dev/null +++ b/docs/scratchpads/fcm-m5-001-fleet-config-operator-docs.md @@ -0,0 +1,85 @@ +# FCM-M5-001 — Fleet configuration operator documentation + +- Task: `FCM-M5-001` +- Issue: `#758` +- Branch: `docs/758-fleet-config-operator-docs` +- Exact base: `9745bc3f29c26b021a478b7ad03cfb494f6c9de3` (tree `4da210da9a71b035130d4160a4a2e691bdfde2da`) + +## Objective + +Deliver the accepted fleet documentation information architecture, operator workflows, operations and migration references, comprehensive contract documentation, and deterministic link/example validation without live fleet action or product mutation. + +## Scope and constraints + +- Documentation, examples, documentation validation, and tracking only. +- `roster.yaml` remains the sole writable desired-state authority; generated state is derived/observed. +- No M4-002 implementation or execution; no canary, migration, rollback, deployment, systemd/tmux/session, generated projection, or product mutation. +- `mos-comms` is temporary and is not permanent architecture. +- Parent issue `#758` remains open through M5. +- No credentials, sensitive values, or privileged command content. + +## Plan + +1. Update tracking first with exact M4-001 evidence and mark M5-001 in progress. +2. Map the M0 checklist and current implementation behavior to documentation pages. +3. Author operator, operations, migration, schema/reference, recovery, troubleshooting, and security/authority docs. +4. Add or extend deterministic documentation/link/example validation if required, red-first. +5. Run repository documentation, link, example, and relevant package checks; review and remediate. +6. Commit, queue-guard, push one branch, and open one wrapper-created PR; stop for independent review. + +## Budget + +- Task estimate: `24K`. +- Working cap: stay within the card estimate by parallelizing read-only discovery and limiting edits to checklist-required artifacts. + +## Progress checkpoints + +- [x] Loaded repository/global delivery and documentation contracts. +- [x] Verified `origin/main` is exact required base and created isolated worktree. +- [x] Tracking updated first. +- [x] Checklist mapped and docs authored. +- [x] Validation green. +- [x] Review/remediation complete. +- [x] Commit, queue guard, push, PR #789. +- [x] Rejected exact-head RoR findings repaired on a new descendant commit candidate. +- [ ] New exact-head review and CI after repair push. + +## Tests and verification + +- Red-first documentation validator initially failed for the absent fleet entry point and canonical + example, then passed after the IA and example were added. +- `pnpm --filter @mosaicstack/mosaic exec vitest run src/fleet/roster-v2.spec.ts src/fleet/example-profile-dispositions.spec.ts src/fleet/fleet-documentation.spec.ts src/fleet/v1-v2-migration.spec.ts src/fleet/generated-env-boundary.spec.ts src/fleet/fleet-agent-crud.spec.ts src/fleet/fleet-reconciler.spec.ts` — 7 files, 195 tests passed after building workspace dependencies. +- `pnpm format:check` — passed. +- `pnpm lint` — 23 tasks passed. +- `pnpm typecheck` — 42 tasks passed. +- `pnpm test` — 43 tasks passed; `@mosaicstack/mosaic` contributed 61 files and 1,045 tests. +- `bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh` — passed. +- `bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh` — passed. +- `git diff --check` — passed before final review. +- Independent staged-snapshot review identified four documentation/validation blockers: reboot safety, + heartbeat observation, migration failure envelope, and example-scan coverage. All were remediated; + focused rereview approved the staged remediations with no blockers. Exact committed-head review remains + a post-PR gate. +- Post-remediation `@mosaicstack/mosaic` lint/typecheck passed; package test passed 61 files / 1,045 + tests; sanitization and resident-budget gates passed again. + +- Post-PR exact-head RoR on rejected head `0aee2c09819fd06e28f927384ea56fa2ef374edf` + identified five blockers: update-lifecycle overclaim, missing explicit `fleet validate` gap, + fragment-blind link validation, unsupported checklist-evidence claim, and insufficient example safety + validation. Red-first regressions failed before implementation for missing-heading, privileged-command, + and credential-format fixtures. Repairs now preserve/document implementation truth, validate heading + fragments, narrow checklist claims, and scan fenced/canonical examples for common credential formats + and privileged commands without printing fixture values. +- Repair-focused fleet contracts: 7 files, 192 tests passed after review remediation; documentation + validator contributed 11 tests. Full gates passed: format; lint 23/23; typecheck 42/42; test 43/43 + tasks with `@mosaicstack/mosaic` 61 files / 1,052 tests; sanitization; resident budget; and + `git diff --check`. New exact-head review/CI remain pending until the repair commit is pushed. + +## Risks/blockers + +- Checklist may include behavior intentionally deferred to M4-002/M5-002; such items must be recorded as approved-existing holds rather than claimed delivered. +- Commands/examples must remain non-live and avoid privileged/sensitive content. + +## Final evidence + +- Pending. diff --git a/docs/scratchpads/issue-766-exact-fleet-comms.md b/docs/scratchpads/issue-766-exact-fleet-comms.md new file mode 100644 index 00000000..cdad9580 --- /dev/null +++ b/docs/scratchpads/issue-766-exact-fleet-comms.md @@ -0,0 +1,80 @@ +# Issue 766 — exact cross-harness fleet comms targeting + +- **Issue:** #766 +- **Branch:** `fix/766-exact-fleet-comms` +- **Worktree:** `/home/jarvis/src/stack-issue-766` +- **Delivery boundary:** source/tests/docs only; no live tmux, session, or fleet actions; leave uncommitted for independent review. + +## Objective + +Replace inference-prone fleet onboarding guidance with one roster-resolved contract that gives Claude Code, Codex, OpenCode, and Pi the same authoritative local identity and exact executable command for every known peer. + +## Plan + +1. Add issue-specific normative requirements to `docs/PRD.md` before source changes; do not modify orchestrator-owned `docs/TASKS.md`. +2. Extract the existing v1 roster parsing/normalization into one lightweight shared resolver used by both fleet commands and runtime comms composition. +3. Write failing contract tests for explicit SSH-only cross-host targeting, global/default socket authority, authoritative identity, unknown-peer failure, no operational metavariables, and four-harness parity. +4. Make source `TOOLS.md` non-operational and marker-versioned; prove fresh installation preserves that exact contract and composition detects a stale installed copy without rewriting it. +5. Render a deterministic comms generation and document comparison/relaunch handling; never rewrite an active session. +6. Run focused Vitest and shell exact-target tests, then package/repository typecheck, lint, format, test, and build gates as relevant. +7. Reconstruct the exact uncommitted tree, including untracked files, for independent review and remediate findings without committing. + +## Contract decisions + +- `tmux.socket_name` is the one supported socket authority for every local fleet session. A per-agent `socket`, when present for compatibility, must equal that global value; independent per-agent sockets fail closed because the runtime does not provision them. A named socket renders `-L`, while the empty literal default renders no `-L`. +- A peer is same-host only when its resolved host equals the current roster member's resolved host. Every host-omitted member resolves against the stable local fleet-host baseline, never against the viewer's explicit host. Same-host rows never render `-H`. +- A cross-host row requires that peer's explicit roster `ssh`; absence is a contract error. Never substitute `host` as an SSH target. +- The current member's explicit roster `host` wins; otherwise the local machine's short hostname is the baseline for host-omitted local members. +- Unknown members/peers return a deterministic error listing exact known names and an exact self-scoped discovery command. No fuzzy session lookup. +- Exact command fields are structurally constrained to safe targeting grammars and shell-rendered as individual arguments. Unsafe host/SSH/socket values fail roster normalization rather than entering executable guidance. +- Existing installed `TOOLS.md` remains user-owned during ordinary keep-mode updates. Currency requires the expected source and installed marker/version plus bounded SHA-256 byte identity. Explicit `mosaic update --repair-tools` is the supported current-version recovery path: it makes a digest-qualified no-clobber backup, restores the contract and regular executable helper, and does not rewrite active context. +- The v1 resolver preserves and validates `tmux`, `discord`, and `matrix` connector blocks in YAML and JSON; conflicting snake/camel aliases fail closed unless their values are identical. +- JSON roster fallback occurs only when `roster.yaml` is absent. Keep-mode reseed preserves both formats, and relaunch discovery uses the same canonical resolver. +- The helper is inspected without following symlinks and must be a regular executable file. Missing, directory, symlink, and non-executable installations fail closed with deterministic repair guidance. +- Active contexts carry a deterministic comms generation. Operators compare it to `mosaic agent comms-block ` output; mismatch means stale and requires an explicit exact-agent relaunch. + +## Risks + +- Import cycles if runtime composition imports the command-heavy `fleet.ts`; mitigate with a lightweight shared roster module and re-export compatibility. +- Existing schema prose allowed independent per-agent sockets even though runtime provisioning used one global socket; constrain compatibility declarations to the global value and preserve empty-global default behavior. +- Remote inventory may be incomplete. Fail composition closed for an unreachable cross-host row rather than generating a guessed command. +- `TOOLS.md` is user-seeded and intentionally preserved. Detect/report drift instead of overwriting custom content. + +## Planned evidence + +- `comms-onboarding.spec.ts`: resolver/renderer/failure/generation contracts. +- `compose-contract.spec.ts`: identical authoritative comms section for all four harnesses and stale installed-contract reporting without mutation. +- `file-adapter.test.ts`: source-to-fresh-install byte equality and preservation of customized installed `TOOLS.md`. +- Existing `agent-send.test.sh`, socket isolation, and tmux runtime transport tests. +- Repository quality gates and independent uncommitted-tree review. + +## Evidence log + +- Preflight collision scan: no issue-766 local/remote branch, worktree, or open PR collision before branch creation. +- Isolated branch created from fetched `origin/main` at `4990905`; original checkout not edited. +- One strict v1 resolver now serves fleet commands and communications composition; roster writes preserve `host`, `ssh`, and `socket`. +- Exact renderer covers authoritative self identity, global/default socket authority, rejected independent sockets, stable hostless-peer resolution, same-host omission of `-H`, explicit-SSH-only cross-host rows, shell-safe argv rendering, deterministic generations, and fail-closed unknown/missing targets. +- Real framework `defaults/TOOLS.md` is tested byte-equal through a fresh `FileConfigAdapter` install, the installed helper is executable, and the final Pi contract contains the same source contract plus exact generated command; separate parity coverage proves byte-equivalent comms sections for Claude Code, Codex, OpenCode, and Pi. +- Second review remediation adds strict connector/alias coverage, full-semantic generation coverage, ENOENT-only fallback, no-follow helper validation, unconditional current-version repair, digest-qualified no-clobber backups, and marker/version-gated currency. +- The helper, roster, installed TOOLS, and framework source files are read with canonical containment, every existing ancestor and target rejected if symlinked, `O_NOFOLLOW` descriptor reads, inode stability checks, and effective-identity execute access. Read-only TOOLS status treats source/installed symlinks as unavailable without following or rewriting them. +- Explicit repair validates both bundled inputs before destination creation, stages backup/TOOLS/helper plus exact-mode rollback files before any persistent file commit, revalidates destination identity at each commit boundary, installs the digest backup without clobber, and removes or exactly rolls back every committed output on injected failure. `changed: false` is returned only after full cleanup; cleanup/rollback failure is reported as `changed: true`. +- Connector schema and runtime normalization require kind-matching settings and reject inactive connector blocks. Keep-mode installers preserve only exact `roster.yaml`, `roster.json`, `agents/`, and `run/` paths while refreshing framework `roster.schema.json`; shell evidence covers byte preservation and schema refresh. +- Solo contracts render normalized role/class plus explicit no-peer/no-remote authority boundaries; composed-contract evidence keeps role Mandate/Boundaries before Fleet Comms. +- Operational documentation and CLI metavariable now use `mosaic agent comms-block `; historical issue-633 scratchpad text remains historical. +- The latest independent review rejected synthetic tree `556ae4ea04f2715a4e9d381f3cafaf4c8b991b2e` on three mandatory findings: installed `TOOLS.md` could be read through target/ancestor symlinks before unsafe status was reported; ambient class/tool-policy state could split identity authority from the canonical roster member; and the connector schema admitted empty or whitespace-only Discord/Matrix strings rejected by runtime parsing. +- Red-first reproduction proved all three findings with 20 failures and 79 passes. Remediation routes installed `TOOLS.md` through the bounded secure regular-file reader before composition, resolves one exact canonical fleet identity for persona/tool policy/normalized class/Fleet Comms, rejects canonicalized ambient class mismatches, canonicalizes compatibility classes during roster parsing, and aligns parser/schema non-whitespace requirements. +- Four-runtime coverage proves unsafe target and ancestor symlink content is omitted without mutation, while Claude Code, Codex, OpenCode, and Pi all project the same canonical member authority. Connector parser/schema coverage includes empty and whitespace-only Discord `channel_id` and Matrix `homeserver_url`, `user_id`, and `room_id` values. +- Remediated focused gates passed: 99/99 across the two finding-focused suites plus connector schema regression PASS; the six changed-suite matrix passed 341/341; secure-file/transaction coverage remains green, including 28/28 transactional repair tests; installer migration passed 21/21. +- Mosaic package suite passed 906/906. Shell/runtime regressions passed: `agent-send.test.sh` `PASS=11 FAIL=0`; named-socket isolation; matrix/tmux transport 12/12 (Matrix 5/5, tmux 7/7). +- Final repository gates passed: format check; typecheck 42/42 tasks; lint 23/23; tests 42/42 tasks (Mosaic 906/906, gateway 628 passed/12 skipped); build 23/23. +- A subsequent immutable review of tree `aa6414123643a504145fce6ac1d66f0b535feb5e` found one roster-authority blocker: a canonical member with omitted `tool_policy` inherited ambient `MOSAIC_AGENT_TOOL_POLICY`. Red-first four-runtime coverage failed 4/39 specifically on the leaked operator-interaction policy. Composition now branches on canonical membership: fleet launches use only `canonicalMember.toolPolicy` (including canonical absence), while genuinely non-fleet launches retain ambient fallback. +- Final remediated gates passed: four-runtime regression 39/39; six changed-suite matrix 345/345; connector schema regression PASS; Mosaic package 910/910; installer migration 21/21; `agent-send.test.sh` 11/11; named-socket isolation PASS; Matrix/tmux transport 12/12; repository format PASS; typecheck 42/42 tasks; lint 23/23 tasks; tests 42/42 tasks; build 23/23 tasks. +- No live tmux/session/fleet mutation, commit, push, PR mutation, issue mutation, context mutation, or reviewer launch performed. +- Exact synthetic-tree reconstruction and frozen evidence are included in the coordinator handoff. +- Sole-remediation preflight reverified the clean committed checkout at head `0dc47cac92c93a3ffd39ba9dd6685ac4165f6361`, tree `538de6ccce1f8c44ba288a7493286e63a3413e75`, branch `fix/766-exact-fleet-comms`; issue and PR state were read only through Mosaic wrappers. +- Deterministic red-first ancestor substitution swapped validated `root/tools` for an external symlink immediately after `lstat`; current head returned `external marker` (`1 failed, 4 passed`) before implementation. +- Secure reads now hold `/` and every root/descendant directory descriptor, traverse appended components through Linux `/proc/self/fd` with `O_DIRECTORY|O_NOFOLLOW`, and read plus effective-identity execute-check the same final descriptor. Non-Linux or unavailable proc-fd capability fails closed; stable errors redact managed paths while retaining Node `code` compatibility for missing/non-executable repair behavior. +- Added deterministic root-selection, descendant-ancestor, and final-target substitution coverage. All return trusted descriptor-bound bytes after rename/symlink replacement; the race suite passed 50/50 repeated runs. +- Isolated CLI verification drove `mosaic agent --mosaic-home comms-block self` while repeatedly swapping `fleet/` with an external symlink: `trusted=2 fail_closed=10 external_marker=0`; a persistent symlink ancestor exited 1 with a redacted unsafe-ancestor error. No live fleet state was used or mutated. +- Remediation gates: focused secure-file/comms/launch/tmux/Matrix `110/110`; full `@mosaicstack/mosaic` `914/914`; package and repository typecheck pass (`42/42` repository tasks); package and repository lint pass (`23/23` repository tasks); repository format check and `git diff --check` pass. +- Independent review found one production hardening blocker (nonblocking final open), one redacted-error blocker, and a deterministic ancestor-test gap. Remediation added `O_NONBLOCK`, normalized execute errors while preserving errno, proved the ancestor hook fires, and added final-target substitution coverage; post-remediation review evidence is clean on the production invariant. diff --git a/docs/scratchpads/ms-792-fleet-enoent-installer.md b/docs/scratchpads/ms-792-fleet-enoent-installer.md new file mode 100644 index 00000000..a24cac49 --- /dev/null +++ b/docs/scratchpads/ms-792-fleet-enoent-installer.md @@ -0,0 +1,38 @@ +# ms-792 — Fleet roster error handling and installer heading + +## Objective + +Make expected missing or malformed fleet roster configuration fail with an actionable message and nonzero exit instead of a raw Node stack trace. Ensure the installer preserves the `@mosaicstack/mosaic` heading. + +## Plan + +1. Add failing coverage for missing and malformed roster input. +2. Centralize roster-file read and parse error translation; add the CLI async error boundary. +3. Sweep fleet command read paths that bypass the roster loader. +4. Replace the installer heading output with format-safe rendering and test it. +5. Run focused and repository quality checks; request independent review. + +## Progress + +- 2026-07-16: Confirmed issue #792 and branch base `9745bc3f`. +- 2026-07-16: Installed locked workspace dependencies using a worktree-local pnpm store; no `.mosaic/` files were changed intentionally. +- 2026-07-16: Added a shared roster read/parse guard and routed v1 fleet commands plus v1/v2 selection through Commander’s actionable nonzero error path. V2 command modules already return structured nonzero JSON errors for their guarded reads. +- 2026-07-16: Replaced installer heading `echo` with format-safe `printf`; added a regression check for the scoped package heading. +- 2026-07-16: Rebuilt CLI and manually verified `fleet ps` with no roster prints the initialization hint, exits 1, and has no stack trace. +- 2026-07-17: Rebased #818 onto `origin/main` at `9ddc6fbd` (#791 PR3). The added `fleet regen` command had a canonical roster read in its sibling module; it now uses the same missing-roster guard and Commander exit path. Internal NORTH_STAR, preset, and post-write invariant reads remain intentionally unguarded. +- 2026-07-17: RoR found that semantically invalid v1 documents still escaped as plain `Error` values. `normalizeFleetRosterV1` now preserves each validation message while converting it to `FleetRosterConfigurationError`, so its command callers use the actionable nonzero Commander path. + +## Verification + +- `pnpm --filter @mosaicstack/mosaic test` — PASS (61 files, 1,046 tests; executed outside sandbox because CLI smoke tests spawn Node) +- `pnpm typecheck` — PASS +- `pnpm lint` — PASS +- `pnpm format:check` — PASS +- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts src/commands/install-heading.spec.ts` — PASS (209 tests) +- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-regen-command.spec.ts` — PASS (27 tests, including missing canonical roster) +- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts -t "semantically invalid v1 roster"` — RED then PASS; verifies duplicate agent names are reported as `fleet.roster` exit 1 without a stack trace. +- Instrumented Vitest coverage is unavailable because `@vitest/coverage-v8` is not declared in this repository. Each branch added in the roster guard has direct unit coverage. + +## Risks / blockers + +- Dependency installation is required before executing Vitest, TypeScript, lint, and formatting gates. diff --git a/docs/scratchpads/rm-01-reproducible-checkout.md b/docs/scratchpads/rm-01-reproducible-checkout.md new file mode 100644 index 00000000..3c6feac0 --- /dev/null +++ b/docs/scratchpads/rm-01-reproducible-checkout.md @@ -0,0 +1,58 @@ +# RM-01 — Reproducible checkout + +- Task/ref: RM-01 (`docs/remediation/TASKS.md`, internal mission tracking) +- Objective: make checkout/install/typecheck hooks fail on code rather than environmental residue, for root CI and non-root seats. +- Scope: pnpm store configuration, transactional Husky installation, dependency/generated-state preflight, checkout regression tests, developer documentation. +- Constraints: isolated worktree; no skip-switch fixes; no writes under `/root` or `/tmp`; workers do not edit `docs/remediation/TASKS.md`; author does not review or merge. +- Acceptance: AC1–AC8 from the orchestrator dispatch/addendum. +- Plan: + 1. Add RED-first tests for missing dependencies, stale/foreign `.next`, and interrupted hook installation. + 2. Implement environment-overridable HOME-based pnpm store defaults, deterministic preflight, and transactional hook installation. + 3. Run focused tests, install/build/baseline gates, and explicit AC negative controls. + 4. Obtain independent review, push after queue guard, open PR, and send evidence to `mos-remediation`. +- Budget: orchestrator estimate 6K/60K; no explicit hard token cap. Keep scope to RM-01 and avoid unrelated cleanup. +- Risks: 97%-full shared `/tmp`; native dependency install size; root-owned fixtures may require Docker for realistic verification. + +## Progress / evidence + +- Worktree created at `/home/hermes/agent-work/rm-01` from `origin/main` `06e0d403`. +- `/tmp` baseline: 28G used, 889M available (97%); worktree and planned store are on `/home`. +- Root causes confirmed from source: committed `.npmrc` pins `/root`; `prepare` invokes Husky directly; web typecheck includes generated `.next` types without validating ownership/freshness. + +## Checkpoint evidence (c45e5e19) + +- AC1 IN PROGRESS: non-root `pnpm install --frozen-lockfile --store-dir "$HOME/.local/share/pnpm/store"` exited 0; `pnpm exec turbo run typecheck --force` exited 0 (45/45 uncached). Clean CI-container run not performed. +- AC2 DONE: with `node_modules` absent, `pnpm preflight` exited 42 with `MOSAIC_PREFLIGHT_MISSING_DEPS` and `run pnpm install`; after install it exited 0. +- AC3 DONE: appending `export const x: number = "s"` to `packages/types/src/index.ts` made `pnpm -w typecheck` exit 2 with TS2322; reverting made it exit 0. +- AC4 IN PROGRESS: local `pnpm -w build` exited 0 and `git status --porcelain` showed no generated residue beyond the intended RM-01 source changes. Fresh-clone proof not performed. +- AC5 DONE: non-root install exited 0; `pnpm store path` resolved `/home/hermes/.local/share/pnpm/store/v10`; no `/root` write was attempted. +- AC6 IN PROGRESS: focused failure/rollback tests passed, but final review found a concurrent-install race. Two installers can both observe `.husky/_` absent; after one installs successfully, the losing install's catch path can quarantine the winner's active hooks and restore stale Git config (`scripts/install-hooks.mjs`, activation/catch transaction). A RED regression is committed after the checkpoint. +- AC7 DONE: install/store/worktree were on `/home`; full `pnpm -w build` exited 0; `/tmp` usage changed by 4096 bytes during the build (23,805,173,760 → 23,805,177,856 bytes), not materially. +- AC8 DONE for the implemented path: store resolves under `$HOME`; test/quarantine/build state resolves under the worktree; no implemented component requires a writable path outside `$HOME` or the worktree. + +## Continuation evidence + +- AC6 DONE: the committed race reproducer was observed RED (`node --test --test-name-pattern='a competing successful installer is not removed by the losing process' scripts/install-hooks.test.mjs`, exit 1/ENOENT), then passed after cleanup became ownership-safe. The losing installer never removes an active hook set or restores Git configuration it did not activate. `pnpm test:checkout` passes 21/21, exit 0, including the original race and a post-rename peer-replacement regression. +- Generated-state remediation: replaced mtime inference with a source/build-input fingerprint, written only after a serialized successful Next build with unchanged inputs. Failed/interrupted/overlapping builds leave no trusted marker. The fingerprint uses Next's own environment loader, covers resolved `NEXT_PUBLIC_*` values, inherited TypeScript configuration, lock/workspace inputs, and rejects symlink inputs. +- Baseline: `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` each exit 0. Local `pnpm test` still exits 97 only at the pre-existing Bash `BASH_LINENO` convention guard (#973/#1003), after checkout tests and package tests pass; this is not reported as a green full-suite result. +- Automated review remediation: resolved findings for peer-hook ownership, stale/failed build markers, build-input changes, expanded environment inputs, inherited TypeScript config, symlink inputs, and overlapping build serialization. Independent PR review remains assigned to rev-974. +- AC1 DONE at `0f706119`: a clean clone created inside `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest` ran the exact acceptance sequence `pnpm install --frozen-lockfile && pnpm -w typecheck`; exit 0 with 45/45 uncached typecheck tasks successful. An earlier bind-mounted clone attempt exited 1 because root in the container rejected the host-owned Git directory; that failed attempt is not counted as evidence. +- AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0. +- Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`. + +## Review remediation — restated AC2 + +- Independent review correctly found that an added symlink under a successfully built `.next` tree passed preflight. The exact reviewer control, `ln -s /etc/hosts apps/web/.next/reviewer-symlink && pnpm preflight`, was observed passing before remediation. +- The original blanket symlink wording conflicts with AC4 because canonical Next `output: 'standalone'` emits legitimate pnpm dependency symlinks. The coordinator independently verified 42 such links and approved the operative restatement: `.next` itself must not be a symlink; descendant symlinks must exactly match the successful build's certified manifest. +- RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted. +- GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0. +- Threat-model ruling: the manifest detects accidental, independent, stale, and foreign-residue mutation—the class exposed by the five-month-stale `.next` that produced 19 phantom TS2307 errors. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. RM-59 tracks the residual: executor/spine-side attestation outside worktree authority, dependent on RM-12, RM-21, and RM-25. +- AC8 concrete proof at `df7530ae`: a clean clone ran in `ci-base:latest` with Docker `--read-only`; its only writable mounts were `/workspace` (the worktree) and `/home/ci` (`HOME`, with `NPM_CONFIG_STORE_DIR=/home/ci/store`). `pnpm install --frozen-lockfile && pnpm -w typecheck` exited 0 with 45/45 uncached tasks. This proves the implemented checkout path requires no writable location outside `$HOME` and the worktree. An initial fixture attempt failed only because Git required `/workspace` safe-directory setup; it is not counted as evidence. + +## Handoff + +1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation. +2. The latest automated review rejected oldest-generated-file mtime as a false positive for valid incremental Next output. Use a source-content fingerprint marker written only after successful `next build`; do not continue tuning mtimes. +3. For Husky, generation in an isolated temporary Git repo avoids mutating real `core.hooksPath` during staging. Preserve that design. Fix the losing concurrent process so it never removes a peer's completed hook set or restores stale config. +4. Codex review runs in a read-only sandbox, so its attempts to run the fixture-writing Node tests report opaque test-file failures. The same tests run normally in the worktree. +5. Full `pnpm test` is not green on this host: it exits 97 at the pre-existing Bash `BASH_LINENO` convention guard (#1003), after the changed checkout tests and package tests pass. Do not weaken that gate. diff --git a/docs/scratchpads/rm-03-queue-guard.md b/docs/scratchpads/rm-03-queue-guard.md new file mode 100644 index 00000000..f2cc3d8c --- /dev/null +++ b/docs/scratchpads/rm-03-queue-guard.md @@ -0,0 +1,120 @@ +# RM-03 — CI Queue Guard Repair + +- **Task:** RM-03 +- **Issue:** #1019 +- **Branch:** `fix/rm-03-queue-guard` +- **Owner:** coder-mos1 +- **Reviewer:** rev-974 (independent; author != reviewer) +- **Started:** 2026-08-01 + +## Objective + +Repair the mandatory CI queue guard so it reads provider payloads, blocks asserted non-green CI, distinguishes provider unavailability from a real non-green result, and inspects the branch actually being pushed or merged. + +## Constraints + +- Worktree only: `/home/hermes/agent-work/rm-03`; never mutate `/src/mosaic-stack`. +- JSON payload travels through stdin; never argv. Large payload must remain below no ARG_MAX dependency. +- TDD is mandatory. Every behavior case must be observed red before implementation. +- No bypass flags or hook suppression. +- Do not cite the existing guard's green as evidence; D-23 establishes it is zero-information. +- Gate-ready is a frozen exact head. Any push after a merge-gate verdict voids that verdict. +- No merge: coordinator holds the merge hand pending Jason. + +## Design + +1. Feed JSON to `python3 -c` on stdin, including pending-context rendering. +2. Classify valid green as `READY`; pending/failure/no-status/malformed/mixed as `ASSERTED_NOT_READY`; provider/credential/transport inability as `CANNOT_ASSERT`. +3. `ASSERTED_NOT_READY` exits nonzero. `CANNOT_ASSERT` emits a loud diagnostic and appends a local JSONL audit record. Push degrades to exit 0; merge holds with distinct retryable exit 75 until provider recovery, then self-clears without manual reset. Inability to write the audit exits nonzero. +4. Derive the current branch when `-B` is omitted. The merge wrapper passes the exact PR head branch, repository, and full commit SHA—not its `main` base—so fork PRs cannot resolve against an adjacent base-repository branch. + +## Test matrix + +| Case | Required outcome | +| --- | --- | +| success | exit 0; terminal-success | +| pending | nonzero after bounded timeout | +| failure | nonzero | +| no-status | nonzero | +| malformed | nonzero | +| >=150 KiB payload | unchanged classification; never rc126 | +| provider unreachable on push | loud audited CANNOT_ASSERT; degraded exit 0 | +| provider unreachable on merge | loud audited CANNOT_ASSERT; retryable exit 75/HOLD | +| audit unavailable | nonzero | +| implicit push branch | provider URL uses checked-out feature branch | +| merge wrapper | queue guard receives exact PR head branch/repository/full SHA | + +## RED-first evidence + +Observed against the unmodified `origin/main` implementation before source edits: + +- `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` → rc 1 with 15 failed assertions. +- Success payload was reported `state=unknown`. +- Pending, failure, no-status, and malformed payloads each exited 0 and omitted `ASSERTED_NOT_READY`. +- The 160 KiB payload produced rc 141 because Python never consumed the pipe; it did not classify success. +- Provider-unreachable exited 7 with no `CANNOT_ASSERT` audit record. +- Implicit push queried `/branches/main`, not `/branches/fix/rm-03-fixture`. +- Audit-unavailable emitted no audit diagnostic. +- A credential-resolution hard-block mutant was then run before trusting that added case: `credential-unresolvable` returned rc 1 and omitted `CANNOT_ASSERT`; the matrix returned rc 1 with two named assertion failures. +- Review-blocker controls were observed red: structurally invalid `statuses` string and null-entry payloads each exited 0 as `terminal-success`; unsupported-platform discovery exited 1 without diagnostic or audit (seven named assertion failures total). +- After the push/merge asymmetry ruling, merge-side provider unavailability was observed red at rc 0; its registered case required distinct retryable rc 75. +- Aggregate `state=success` with zero contexts was observed red: it exited 0 as `terminal-success`; the registered case requires `no-status`/nonzero. +- Fork/exact-head controls were observed red: `pr-merge.sh` omitted the fork repository and full SHA, and an ignored-arguments mutant re-resolved through `/branches/` instead of the exact fork commit (two named failures). +- GitHub check-run-only success/pending/failure were each misclassified as `no-status`; the RED run had five named failures and proved the Checks API was never queried. +- The first merge-pin control was unrunnable because one `local` declaration referenced a variable before assignment under `set -u`; this was disclosed and corrected rather than counted. The runnable RED then showed Gitea payload `{"Do":"squash"}` lacked `head_commit_id`; a separate GitHub run showed `gh pr merge 123 --squash` lacked `--match-head-commit`. +- A stale-verdict mutant removed the `--expect-head` comparison and was observed red because a moved head reached the provider merge call. +- `bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh` initially returned rc 1; captured call was `--purpose merge -B main -t 900 -i 15`. + +Logs remain untracked under the worktree as `.mosaic-test-work-red-*.log` and will not be committed. + +## Progress + +- [x] Mission, remediation charter, task evidence, board, issue #1019, and superseded PR #1023 read. +- [x] Isolated worktree created and identity configured coherently. +- [x] Mutant tests authored and observed red. +- [x] Implementation green. +- [x] Baseline and focused situational gates green; full package suite has an unrelated framework-shell environment abort recorded below. +- [ ] Independent review clean (rev-974 requested changes at `44ffa99a`; bypass remediation committed and awaiting re-review). +- [ ] PR CI terminal-green at exact head by full step scan. +- [ ] Merge-gate verdict issued against frozen head. + +## Scope disposition + +- The five framework guides are consequential documentation: they define the purpose-aware tri-state contract, including audited push degradation and merge HOLD. +- The agent templates are consequential because they ship the same queue-guard instructions into newly seeded agent contracts; leaving them binary/stale would contradict the repaired tool. +- `pr-merge.sh` is consequential: it must inspect the PR's exact head branch/repository/SHA and enforce the exact-head merge pin. +- `pr-metadata.sh` is consequential only as the normalized source of that head branch/repository/SHA. Its diff is limited to exposing those fields on GitHub and Gitea. +- `test-pr-merge-gitea-empty-uid.sh` changes because exact-head Gitea merges now always use the API path (the only path that can send `head_commit_id`), superseding the prior tea-empty-identity fallback behavior. + +## Review remediation + +- rev-974 independently proved that the documented `--skip-queue-guard` merge option bypassed an exit-99 guard stub, reached the provider merge payload, printed success, and exited 0 at head `44ffa99a`. +- RED-first reproduction was added to `test-pr-merge-head-pin.sh` before the production fix: `FAIL merge-bypass: --skip-queue-guard reached the provider merge path`, suite rc 1. The test-only commit is `241113e6`. +- Production remediation `37aae650` removes the option from parsing, usage, help, and examples. Every merge-capable path now invokes the queue guard; `--dry-run` alone omits it and has a regression proving that it exits before provider dispatch and creates no merge payload. +- Existing Gitea merge tests now exercise a successful guard response rather than bypassing the guard. + +## Risks / boundaries + +- The local JSONL audit is durable operational evidence but not tamper-resistant against the same UID. RM-03 does not claim otherwise. +- Push-side audited exit 0 is an explicit owner ruling (Option B), accepted to avoid bricking recovery work; merge-side CANNOT_ASSERT remains retryable exit 75/HOLD. The automated security reviewer continues to flag the deliberate push availability tradeoff. +- Source/deployed-copy equality is owned by RM-02/D-22; this branch changes repository source and its tests only. + +## Test evidence + +Fresh after rescue checkpoint `b7175012`: + +- Focused situational matrix: tri-state, GitHub checks pagination, branch-absent, merge head branch/repository/SHA, exact-head pin, and Gitea exact-head API regressions all passed. +- `bash -n` on the three production shell scripts passed. +- `shellcheck -x -P packages/mosaic/framework/tools/git ...` on all changed shell scripts passed. +- `pnpm typecheck` passed (45/45 Turbo tasks). +- `pnpm lint` passed (25/25 Turbo tasks). +- `pnpm format:check` passed. +- `pnpm --filter @mosaicstack/mosaic test`: Vitest passed 1508/1508 on the confirmation run; framework-shell then aborted at the pre-existing wake coordinate assertion with exit 97: `BASH_LINENO ... probe reported [3 5], expected [3 4] ... (#973)`. This is outside the RM-03 diff and is disclosed rather than substituted or called green. +- The prior package-suite attempt had one transient, out-of-diff `install-ordering-guard.spec.ts` failure (1/1508); its isolated rerun passed 19/19 and the confirmation full Vitest run passed 1508/1508. +- After bypass remediation: all six focused RM-03 queue/merge regressions passed, including bypass refusal and dry-run non-dispatch; shell syntax and source-aware ShellCheck passed; `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` passed. +- Fresh `test:framework-shell` reached and passed every RM-03 test, then again aborted at the unrelated wake coordinate assertion with exit 97; it remains explicitly non-green rather than substituted. +- An ad hoc raw Prettier invocation over `.template` and `.sh` files was unrunnable because no parser is registered for those extensions; it was not used as a substitute for canonical `pnpm format:check`. + +## Final evidence + +Pending. diff --git a/docs/scratchpads/tess-20260712.md b/docs/scratchpads/tess-20260712.md new file mode 100644 index 00000000..5f21ca9f --- /dev/null +++ b/docs/scratchpads/tess-20260712.md @@ -0,0 +1,66 @@ +# Scratchpad — Tess Interaction Agent + +## 2026-07-12 — Mission intake + +**Objective:** Build a Pi-native GPT-5.6 Sol high-reasoning Mosaic interaction agent, named Tess, as Jason's primary Discord/CLI access point for Mosaic fleet and transitional Hermes capabilities. Tess complements Mos and must not become a competing orchestrator. + +**Issue:** #706 + +**Budget:** No explicit cap provided. Original working estimate was 290K implementation/review tokens. That estimate is superseded after six security prerequisite tasks were added; revised arithmetic total is pending because the calculation tool was blocked by runtime consent. Run at most two workers; prefer one implementation lane plus one independent review/discovery lane. Re-estimate after planning approval and each milestone. + +**Evidence gathered:** +- Mosaic already provides Pi lifecycle hooks, fleet/tmux sessions, Matrix connector/controller pieces, typed chat events, Discord/Telegram channel plugins, and command/plugin registries. +- Current `IProviderAdapter` is an LLM model/completion abstraction, not an external agent/session provider. +- Required new seam is `AgentRuntimeProvider`: sessions, stream, message, terminate, hierarchy, attach, health, capabilities. +- Recurring cross-runtime needs: unified memory/retrieval, Discord routing/approvals, agent state/inbox/compaction recovery, runtime bootstrap, fleet/incident controls, and GitOps workflow. +- Project truth must remain in canonical project/Mosaic stores; semantic memory is retrieval/mirror. + +**Decisions:** +1. Name: Tess (tessera). Stable machine key `tess`; display name configurable. +2. Mos owns orchestration; Tess delegates Mos-owned work through an explicit coordination contract. +3. Gateway owns ingress/auth/routing; Discord and CLI remain thin clients. +4. tmux/fleet ships first behind an adapter; Matrix/native Mosaic is the forward transport. +5. Hermes integration is transitional and capability-negotiated; unsupported operations fail closed. +6. No unrestricted Discord shell. Privileged/destructive/customer-visible actions require authorization and approval. + +**Plan:** +1. Land requirements/architecture/task graph. +2. Deliver runtime contracts and security model. +3. Deliver durable Pi service/state. +4. Deliver Discord and CLI. +5. Deliver fleet/Mos/Hermes/memory/tool plugins. +6. Deliver Matrix/native transport, migration matrix, recovery, docs, and qualification. + +**Progress:** Issue #706 created. PRD/manifest/tasks initialized on clean branch `feat/tess-interaction-agent` from `origin/main`. + +**Risks:** 14 GB root filesystem headroom; active fleet lanes; broad migration scope; Discord privilege boundary; possible duplicate orchestration authority. + +## 2026-07-12 — Independent planning and threat review + +**Verdict received:** BLOCK TESS-PLAN-001. Coding remains stopped. + +**Blocking findings:** formal threat model absent; verification matrix absent; migration inventory implied but absent; non-existent task paths; AC-TESS-03 lacked a crisp test. Security review also identified command scope bypass, cross-tenant session attachment, MCP actor impersonation, unsafe Discord service ingress, pre-persistence/egress secret leakage, non-durable replay, and globally scoped GC. + +**Remediation applied:** +- Added `docs/tess/ARCHITECTURE.md`. +- Added `docs/tess/THREAT-MODEL.md` with TM-01..12. +- Added `docs/tess/VERIFICATION-MATRIX.md` mapping AC-TESS-01..11. +- Added `docs/tess/MIGRATION-INVENTORY.md`. +- Added hard requirements TESS-SEC-002..009. +- Added six prerequisite security tasks before provider/ingress implementation. +- Corrected task paths to existing package surfaces. +- Added explicit GPT-5.6 Sol/high/tool-policy status verification for AC-TESS-03. + +**Re-review 1:** BLOCK only on composite `repo` values that looked like nonexistent paths. Remediated by declaring comma-separated roots and validating every root. + +**Final focused review:** PASS. Deterministic audit validated all task repository roots with zero missing paths; no planning placeholders remained; security prerequisites still gate Tess exposure; observability traceability is explicit. + +**Current gate:** planning PR must merge to `main` with terminal-green CI before any source-code worker starts. + +## 2026-07-13 — M3 cross-surface delivery + +**Branch:** `feat/tess-m3-integration` from `main` at `84d884b9`. + +**Delivered:** Stable Discord `conversationId` enrollment after a visible provider/runtime session is known; idempotent provider-session rebinding that preserves agent/tenant/owner scope; Discord approval/stop target resolution through the durable snapshot; SSE runtime streaming after CLI attach; denial/audit parity including provider authorization denials and HTTP 403 approval-denial mapping. + +**Evidence:** Gateway targeted suite: 37 tests passed; Mosaic CLI interaction test passed; agent durable-session test passed; gateway and CLI typechecks passed; changed-file format and whitespace checks passed. Codex security review found no confident vulnerability. Code review identified a Fastify exception-response mismatch and two UX/acknowledgement issues; all were corrected before the final validation run. diff --git a/docs/scratchpads/tess-m1-002-provider-registry.md b/docs/scratchpads/tess-m1-002-provider-registry.md new file mode 100644 index 00000000..f9d6c67a --- /dev/null +++ b/docs/scratchpads/tess-m1-002-provider-registry.md @@ -0,0 +1,38 @@ +# TESS-M1-002 — Provider Registry + +- **Issue:** #707 +- **Branch:** `feat/tess-provider-registry` +- **Objective:** Build the runtime provider registry/service boundary that derives immutable actor/tenant/channel/correlation scope server-side, fail-closes unsupported and destructive runtime operations, binds termination approval to the exact structured action, and emits correlation-safe audit events. + +## Plan + +1. Add a provider-agnostic registry in `@mosaicstack/agent` over the merged `AgentRuntimeProvider` contract. +2. Write abuse-case tests before implementation for duplicate/unknown providers, immutable server-derived scope, capability denial, approval mismatch/absence, and audit failure. +3. Implement the Gateway service that converts only authenticated `ActorTenantScope` plus trusted ingress metadata into a frozen `RuntimeScope`, gates capabilities and terminate approval, and records metadata-only audits. +4. Register the service in `AgentModule`, then run focused, baseline, cold-cache, and independent-review gates. + +## Security Invariants + +- Caller-supplied actor/tenant identity never reaches runtime providers. +- Provider capability absence and approval/audit failure deny before side effects. +- Termination approval is verified against provider, session, actor, tenant, channel, and correlation context. +- Audit events retain correlation and authority metadata but never message content or approval material. + +## Progress + +- 2026-07-12: Created fresh worktree from `origin/main` at `119f64e6`; source and Tess planning/security documentation reviewed. +- 2026-07-12: Security TDD added registry and gateway abuse tests before implementation. +- 2026-07-12: Implemented `AgentRuntimeProviderRegistry` and gateway `RuntimeProviderService`; registered both in `AgentModule` and documented the internal boundary. +- 2026-07-12: Independent review found two audit correctness issues. Remediated completion-audit failure handling and provider execution failures: pre-invocation denials are audited as `denied`; post-invocation errors as `failed`; completion audit failure does not misreport a completed effect as retryable. +- 2026-07-12: Final independent security review: no findings. Final code review had one false positive: `@mosaicstack/types` is already declared in `packages/agent/package.json`. + +## Tests + +- TDD red: `pnpm --filter @mosaicstack/gateway test -- runtime-provider-registry.service.test.ts` failed before both audit remediations, as expected. +- Focused: package registry 2 tests and gateway security boundary 7 tests pass. +- Cold-cache: removed this worktree's `node_modules`, then `pnpm install --offline --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store` passed. +- Cold-cache baseline: `TURBO_FORCE=true pnpm typecheck` — 42/42 tasks passed; `TURBO_FORCE=true pnpm lint` — 23/23 tasks passed; `TURBO_FORCE=true pnpm format:check` passed; `TURBO_FORCE=true pnpm test` — 42/42 tasks passed (gateway 548 tests passed, 11 intentionally skipped). + +## Risks / Blockers + +- The canonical durable approval implementation is currently command-specific. This card introduces a fail-closed runtime approval verifier boundary so a runtime provider cannot terminate until its exact-action verifier is wired; later provider implementations cannot bypass it. diff --git a/docs/scratchpads/tess-m1-003-fleet-provider.md b/docs/scratchpads/tess-m1-003-fleet-provider.md new file mode 100644 index 00000000..25dae597 --- /dev/null +++ b/docs/scratchpads/tess-m1-003-fleet-provider.md @@ -0,0 +1,75 @@ +# TESS-M1-003 — Fleet/tmux Runtime Provider + +- **Task:** `TESS-M1-003` +- **Issue:** `#707` +- **Branch:** `feat/tess-fleet-provider` +- **PR target:** `main` +- **Budget:** 30K estimate from `docs/tess/TASKS.md`; work remains scoped to `packages/mosaic`, `packages/agent`, and Tess architecture/scratchpad documentation. + +## Objective + +Implement `TESS-FLT-001` as a tmux/fleet `AgentRuntimeProvider` on the M1 registry contract. Operations must use roster-bound, exact tmux targets; fail closed on missing or mismatched peer identity; allow read-only attach only; use exact-target message delivery and termination; and expose no arbitrary shell, socket, or fuzzy session targeting. + +## Requirements and Security Invariants + +- `TESS-FLT-001`: fleet roster/status/heartbeat inspection, message delivery, session hierarchy, safe attach, controlled termination/recovery. +- `TESS-ARP-001` / `TESS-TRN-001`: conform to the runtime provider contract and advertise only implemented capabilities. +- TM-10: exact target/socket binding and peer identity verification; wrong socket, target, or identity must refuse delivery/attach. +- Gateway supplies immutable actor/tenant/channel/correlation scope and consumes durable termination approvals before provider invocation. +- `control` attach is denied. A provider attach is a scoped read-only logical handle; it never opens a server-side interactive terminal or exposes a raw tmux target. +- M2 will make durable attachment/session state available. This M1 provider does not claim durable attachment handles or durable message idempotency. + +## Plan + +1. Add security TDD cases first for fuzzy/unrostered targets, incorrect socket/identity, control attach, attachment scope replay, and termination exact targeting. +2. Add Mosaic fleet primitives for exact target validation and identity probing from the roster/socket. +3. Implement and export the fleet/tmux provider in `@mosaicstack/agent`, using only those primitives and a command-runner seam. +4. Update Tess architecture docs and this evidence log. +5. Run focused tests, independent code/security reviews, cold-cache forced gates, then create a PR with `Refs #707`. + +## Branch/Base Note + +The orchestrator corrected the initial brief: `feat/tess-interaction-agent` is a stale planning branch. This branch was correctly created from `origin/main` at `e92186d7` (including M1-002) and will open a clean PR to `main` with `Refs #707`. + +## Progress + +- [x] Read PRD, Tess architecture, threat model, runtime contract, registry, fleet command primitives, task record, and issue #707. +- [x] Created clean worktree from `origin/main` at `e92186d7`. +- [x] Security TDD tests written before implementation; initially failed because the transport/provider modules did not exist. +- [x] Fleet transport and capability-limited provider implemented; read/list/attach and direct Tess write/control default to deny pending scope-aware authority adapters. +- [x] Focused typecheck, lint, formatting, and abuse tests passed (transport: 7; provider: 14). +- [x] Cold-cache forced workspace gates passed after reinstall; final workspace gates also passed. +- [x] Independent Codex code and security reviews passed with no findings. + +## Verification Evidence + +- `pnpm --filter @mosaicstack/mosaic typecheck` — pass. +- `pnpm --filter @mosaicstack/agent typecheck` — pass. +- `pnpm --filter @mosaicstack/mosaic lint` — pass. +- `pnpm --filter @mosaicstack/agent lint` — pass. +- `pnpm --filter @mosaicstack/mosaic test -- src/fleet/tmux-runtime-transport.test.ts` — 7 passed. +- `pnpm --filter @mosaicstack/agent test -- src/tmux-fleet-runtime-provider.test.ts` — 14 passed. +- The worktree dependency install must use `--store-dir /home/jarvis/.local/share/pnpm/store` because machine pnpm config points to an unreadable root-owned store. This is a local tool configuration issue, not an application workaround. + +## Documentation Checklist + +- [x] Canonical PRD and Tess architecture are current for this internal provider; no HTTP/API endpoint changed. +- [x] `docs/tess/ARCHITECTURE.md` documents the internal fleet target/identity, read-only attach, and Mos authority boundary. +- [x] No user/admin/API sitemap updates are applicable because no user-facing or HTTP API surface was introduced. + +## Acceptance Criteria to Evidence + +| Acceptance criterion | Evidence target | +| --- | --- | +| Only roster-bound exact targets are operated | Provider abuse tests prove unknown/prefix targets yield typed denial and runner is untouched. | +| Socket and peer runtime identity are exact | Provider abuse tests prove wrong socket/no pane/runtime drift deny before send/attach/terminate. | +| Message sends are capability-safe and exact | Tests assert the maintained sender receives only the configured socket and exact roster session. | +| Fleet reads cannot cross an authority boundary | Tests prove list and read attach default-deny without a scope-aware read authority; per-target authority filtering is enforced. | +| Attach cannot grant control or replay across scope | Tests deny `control`; attachment handles are random, scoped, short-lived, single-use for detach, and pruned after expiry. | +| Termination is exact and caller cannot select arbitrary target | Tests assert roster/identity validation precedes exact `tmux kill-session -t =`. Gateway tests from M1-002 cover approval consumption. | +| Documentation describes the boundary | `docs/tess/ARCHITECTURE.md` documents fleet capability, scope, and non-goals. | + +## Risks / Decisions + +- Runtime process identity can only be verified from the declared fleet roster and exact tmux pane command in M1. The tmux server itself is a trusted local transport boundary; stronger authenticated peer attestations are deferred to the Matrix/native provider. +- The current roster schema does not encode per-agent tenant/owner. Scope-aware read/write authority adapters remain the integration point for gateway/Mos ownership policy; provider scope is bound to logical attachment handles to prevent replay. diff --git a/docs/scratchpads/tess-m1-obs-001.md b/docs/scratchpads/tess-m1-obs-001.md new file mode 100644 index 00000000..7bbb1b6a --- /dev/null +++ b/docs/scratchpads/tess-m1-obs-001.md @@ -0,0 +1,9 @@ +# TESS-M1-OBS-001 Scratchpad + +- Branch: `feat/tess-observability-terra` (the requested name is checked out by an abandoned worktree; orchestrator approved this clean branch). +- Base: `origin/main` at `e92186d7`. +- Scope: correlation propagation; metadata-only structured runtime/provider/tool audit; health/readiness; safe effective-policy status. +- Security invariant: audit and status data use an allowlist; no message bodies, credentials, approval references, tool arguments, or tool output. +- TDD: `packages/log/src/runtime-audit.test.ts` and `apps/gateway/src/health/health.controller.test.ts` failed before implementation and now pass. +- Verification: full `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `pnpm test` passed after implementation (2026-07-12). +- Review: corrected audit sanitizer findings by hashing every resource ID. Durable audit persistence remains fail-closed by design: the pre-existing M1 provider-boundary suite requires it to prevent an unaudited side effect. diff --git a/docs/scratchpads/tess-m1-sec-001.md b/docs/scratchpads/tess-m1-sec-001.md new file mode 100644 index 00000000..b2971702 --- /dev/null +++ b/docs/scratchpads/tess-m1-sec-001.md @@ -0,0 +1,27 @@ +# TESS-M1-SEC-001 — Command authorization and exact-action approval + +- Issue/milestone: #707 / M1 +- Branch: `fix/tess-command-authz` +- Requirement: `TESS-SEC-002`, with approval binding controls from `TESS-SEC-007` +- Scope: `apps/gateway` only, plus required in-repo security/developer documentation. + +## Plan + +1. Locate the gateway command executor, command metadata, authorization context, and existing test conventions. +2. Write abuse/authz tests before production changes. Expected red cases: non-admin blocked from admin/system command; forged caller scope cannot authorize; privileged/destructive action requires durable exact-action approval; expired/replayed/mutated approvals deny. +3. Implement server-derived role/scope enforcement and durable approval validation/consumption with audit results. +4. Run focused security tests, then repository baseline gates: typecheck, lint, format-check, test. +5. Run independent security/code review, commit, queue-guard, push, and open the PR to `main` through the stated Gitea API fallback. Stop after PR creation. + +## Assumptions + +- The existing gateway persistence interface is the available durable approval boundary. If no persistence abstraction exists, a minimal injectable repository interface will be introduced rather than an in-memory approval implementation, because TESS-SEC-002/007 require durable enforcement. +- “Exact action” is a canonical digest over structured command identity and normalized arguments; role/scope checks always use authenticated server context, not client-declared claims. + +## TDD evidence + +- Pending: abuse/authz test written and observed red before implementation. + +## Verification evidence + +- Pending. diff --git a/docs/scratchpads/tess-m1-sec-004-discord-ingress.md b/docs/scratchpads/tess-m1-sec-004-discord-ingress.md new file mode 100644 index 00000000..92e28d3d --- /dev/null +++ b/docs/scratchpads/tess-m1-sec-004-discord-ingress.md @@ -0,0 +1,35 @@ +# Scratchpad — TESS-M1-SEC-004 Discord ingress + +- **Task / issue:** TESS-M1-SEC-004 / #707 +- **Branch:** `fix/tess-discord-ingress` from `origin/main` at `59e49cfd` +- **Objective:** Authenticate the Discord plugin service at gateway ingress; enforce explicit guild/channel/user allowlists; attach Discord message and generated correlation IDs; reject replayed native message IDs. +- **Scope:** `plugins/discord`, `apps/gateway`, and existing Discord admin/developer protocol docs. +- **Budget:** Task estimate 28K; no explicit hard cap supplied. +- **Assumptions:** The Discord plugin and gateway share an injected high-entropy `DISCORD_SERVICE_TOKEN`; a configured Discord plugin fails closed without it. Allowlist configuration is comma-separated Discord snowflakes. Discord native message ID is the replay key, with bounded in-memory retention pending the M2 durable inbox/idempotency work. + +## Plan + +1. Add failing tests covering ingress service authentication/signing, unlisted guild/channel/user rejection, correlation propagation, and replay rejection. +2. Implement the signed Discord ingress envelope and allowlist validation in the plugin. +3. Authenticate and validate the envelope at the gateway boundary, then enforce bounded replay protection before agent dispatch. +4. Document the service-token and allowlist operations; run focused and baseline gates; obtain independent review. + +## Progress + +- 2026-07-12: Intake complete; PRD TESS-SEC-005, architecture, and threat model reviewed. +- Added service-token Socket.IO authentication, HMAC-signed Discord envelopes, default-deny guild/channel/user allowlists, correlated message metadata, bounded replay rejection, and fail-fast configuration checks. +- Code and security reviews completed. Code review findings on service persistence ownership, package-boundary tests, disconnected ingress observability, and chat payload validation were remediated; final independent code review approved. + +## Risks / blockers + +- Existing `main` has known unrelated Prettier debt; only changed files will be held format-clean. Durable replay persistence is intentionally out of scope for this M1 prerequisite and belongs to TESS-M2 durable inbox/idempotency work. + +## Verification evidence + +- Focused ingress suite: `pnpm --filter @mosaicstack/gateway test -- discord-ingress.security.spec.ts` — 7 passed. +- Gateway suite: `pnpm --filter @mosaicstack/gateway test` — 513 passed, 11 skipped. +- Plugin suite: `pnpm --filter @mosaicstack/discord-plugin test` — no tests, passed by configured `--passWithNoTests`. +- `pnpm typecheck` — passed. +- `pnpm lint` — passed. +- `pnpm format:check` — fails only on the known pre-existing Tess documentation debt listed in the task dispatch; changed files pass targeted Prettier verification. +- Codex security review — no findings; final Codex code review — approved. diff --git a/docs/scratchpads/tess-m1-sec-006-session-gc-scope.md b/docs/scratchpads/tess-m1-sec-006-session-gc-scope.md new file mode 100644 index 00000000..b87b37d1 --- /dev/null +++ b/docs/scratchpads/tess-m1-sec-006-session-gc-scope.md @@ -0,0 +1,22 @@ +# Scratchpad — TESS-M1-SEC-006 Session GC scope + +- **Task / issue:** TESS-M1-SEC-006 / #707 +- **Branch:** `fix/tess-session-gc-scope` from `origin/main` at `59e49cfd` +- **Objective:** Make session cleanup session-scoped and prevent automatic global retention/GC without an authorized, auditable operation. +- **Scope:** `apps/gateway`, `packages/log`, admin/developer operations documentation. +- **Budget:** Task estimate 18K; no explicit hard cap supplied. +- **Assumption:** No authorized global retention service exists today. Existing full/sweep GC must therefore be disabled from startup and cron paths, while single-session cleanup remains available. + +## Plan + +1. Add failing isolation tests proving single-session cleanup only demotes its own logs and automatic startup/scheduled GC cannot globally delete session data. +2. Add session-scoped log repository retention and make `collect(sessionId)` use it. +3. Remove automatic full/sweep GC invocation; preserve any future global operation behind an explicit authorization/audit seam. +4. Document the operational boundary, run gates, review, and commit without push. + +## Verification evidence + +- Isolation TDD: `pnpm --filter @mosaicstack/gateway test -- session-gc.service.spec.ts commands.integration.spec.ts command-executor-p8012.spec.ts` — 61 passed. +- `pnpm typecheck` — passed. +- `pnpm lint` — passed. +- `pnpm format:check` remains red only on the known pre-existing Tess documentation debt; changed files are Prettier-clean. diff --git a/docs/scratchpads/tess-m2-001-pi-service.md b/docs/scratchpads/tess-m2-001-pi-service.md new file mode 100644 index 00000000..16aaffde --- /dev/null +++ b/docs/scratchpads/tess-m2-001-pi-service.md @@ -0,0 +1,40 @@ +# TESS-M2-001 — Pi Interaction Service + +## Scope + +- Add a generic rostered/systemd Pi operator-interaction service in + `packages/mosaic/framework`. +- Pin the service to `openai/gpt-5.6-sol`, high reasoning, and the + `operator-interaction` tool policy. +- Keep identity as provisioning data; the product name appears only in the + committed example roster. + +## Security and Configuration Invariants + +1. The chosen display/roster name is supplied as data and must exactly match the + generic systemd instance. +2. The service fails before launch if runtime, model, reasoning, or tool policy + differs from the pinned policy. +3. Effective-policy output includes only name, runtime, model, reasoning, and + tool policy; it does not inspect or output credential variables. +4. The default example is replaceable without a source change; the TDD suite + provisions `Nova` from the same profile. + +## Evidence + +- `src/fleet/tess-service-profile.test.ts` proves a `Nova` provisioning path, + roster parser/env serialization, effective-policy output, fail-fast drift + rejection, and absence of the product name from generic source/profile. +- `test-fleet-units.sh` validates the generic interaction systemd unit requires + per-agent config and invokes fail-fast startup validation. +- `test-start-agent-session.sh` proves the tool-policy value is exported into + the Pi pane; `compose-contract.spec.ts` proves it becomes an explicit + runtime contract block. +- Fresh-worktree dependency install plus root `pnpm typecheck`, `pnpm lint`, + `pnpm format:check`, and `pnpm test` passed; package/full fleet suites passed. +- Independent Codex code and security reviews passed with no remaining findings. + +## Delivery Notes + +- Branch starts from fresh `origin/main` at `86a50138`. +- PR targets `main` and references issue `#708`. diff --git a/docs/scratchpads/tess-m2-002-durable-state.md b/docs/scratchpads/tess-m2-002-durable-state.md new file mode 100644 index 00000000..c2e3b809 --- /dev/null +++ b/docs/scratchpads/tess-m2-002-durable-state.md @@ -0,0 +1,63 @@ +# TESS-M2-002 — Durable Tess State + +- **Issue:** #708 +- **Task:** `TESS-M2-002` / `TESS-STA-001`, `TESS-SEC-007..008` +- **Branch:** `feat/tess-durable-state` +- **Base:** fresh `origin/main` at `e3b5113be21e51d015fa1ae54572929b2a4acd9f` +- **Budget assumption:** 38K task estimate; no explicit cap. Use focused TDD plus workspace validation. + +## Objective + +Persist a Tess session's immutable identity, inbox/outbox idempotency state, checkpoints, +handoffs, and approval bindings so a new service instance can recover it after a process +restart or context compaction without replaying a completed message or applied side effect. + +## Plan + +1. Write recovery/idempotency tests first in `packages/agent/src/tess-durable-session.test.ts`. +2. Add transport-neutral durable-state contracts/state machine in `packages/agent`. +3. Add canonical PostgreSQL schema/migration and a gateway Drizzle repository adapter. +4. Wire gateway service/module and reuse `tess:command-approval:*` durable approval semantics + for exact, actor/tenant/action-bound approval consumption. +5. Test PGlite restart recovery with separate service instances sharing the same durable DB. +6. Document the recovery/compaction operation and update Tess architecture evidence. +7. Run focused, cold-cache, workspace, migration, review, commit, push, and open PR to `main`. + +## Required Evidence + +| Requirement | Primary evidence | +| --- | --- | +| Restart recovery | Test creates a second coordinator over unchanged durable store after simulated process death. | +| No duplicate side effects | Duplicate ingress and post-restart dispatch assert one handler/effect invocation. | +| Compaction survival | Checkpoint/handoff/reconstructed state preserve the same session identity and pending records. | +| Durable approvals | Existing `tess:command-approval` record is consumed only once and survives a new authorization service instance. | +| Handoff | Stored handoff is portable and reconstructed without live process state. | + +## Progress + +- Intake complete: PRD AC-TESS-06, threat TM-07/TM-08, and verification matrix reviewed. +- Affected surfaces: `packages/agent`, `apps/gateway`, `packages/db`; auth/authorization and DB migration tests required. +- TDD is required (security authorization and critical state mutation). + +## Risks + +- An external provider action cannot be atomically committed with the database. The outbox + gives the receiver a stable idempotency key; generic recovery never replays an ambiguous + `processing` effect, and completed effects are never redispatched. +- PostgreSQL is canonical; PGlite is the local/restart test implementation. + +## Verification + +- TDD red: `pnpm --filter @mosaicstack/agent test src/tess-durable-session.test.ts` + initially failed because the durable-state module did not exist. +- Focused green: 7 agent state-machine tests; 6 PGlite repository tests (including + close/reopen recovery and encrypted-at-rest redaction); 5 durable-approval tests; DB migration tests. +- Full cold-cache green: `pnpm turbo run typecheck lint test --force` completed + 88 tasks with zero cache hits; `pnpm format:check` and `git diff --check` passed. +- Fresh worktree dependency install passed with + `pnpm install --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store`. + The default pnpm store path was inaccessible to this harness, so the explicit + user-owned store path was required. +- Codex review identified plaintext durable payload risk; resolved by AES-256-GCM sealing + after redaction, with an at-rest ciphertext assertion in the PGlite suite. +- Pending final clean review, commit, and PR. diff --git a/docs/scratchpads/tess-m4-001-mos-coordination.md b/docs/scratchpads/tess-m4-001-mos-coordination.md new file mode 100644 index 00000000..c75eb76b --- /dev/null +++ b/docs/scratchpads/tess-m4-001-mos-coordination.md @@ -0,0 +1,46 @@ +# TESS-M4-001 — Mos Coordination + +- **Issue/task:** #710 / TESS-M4-001 +- **Branch/base:** `feat/tess-mos-coordination` rebased onto `origin/main` `f1c6b37b` +- **Budget assumption:** task estimate 25K; design-first and TDD, with package contract plus gateway boundary only. + +## Objective + +Implement a transport-neutral coordination contract allowing a configured interaction agent to hand off Mos-owned work, observe activity, and receive results while preventing it from gaining coding/general orchestration authority. + +## Plan + +1. Document the contract and enforcement-point sketch; request Mos's decision on the initial concrete transport. +2. Add `@mosaicstack/coord` typed handoff/observe/result contracts and denial errors. +3. Add a gateway service which derives actor/tenant/requester identity from trusted context/configuration and validates authority. +4. Add contract and gateway boundary tests for configurable identities, self-delegation, target drift, and cross-tenant read denial. +5. Run focused, cold-cache, baseline tests; independent review; PR lifecycle. + +## Design checkpoint — 2026-07-12 + +Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `InMemoryInteractionCoordinationPort` for M4. Fleet/tmux remains a documented M5 adapter seam; no Mos-side consumer is built in this task. + +## Progress checkpoint — 2026-07-13 + +- Implemented `InteractionCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`. +- Implemented the gateway `InteractionCoordinationService`, deriving requester identity from trusted configuration and actor/tenant/correlation from authenticated context. +- Added contract and gateway boundary tests for configurable identities, native round-trip, unconfigured requester, self-delegation, target drift, and cross-tenant observe/result denial before adapter invocation. +- Did not modify `apps/gateway/src/commands/command-authorization.service.ts`. + +## Verification + +- `pnpm --filter @mosaicstack/coord test` — PASS (16 tests after authority/idempotency remediation). +- `pnpm --filter @mosaicstack/coord build` — PASS. +- `pnpm --filter @mosaicstack/gateway test -- mos-coordination.service.test.ts` — PASS (7 tests after authority/idempotency remediation). +- Standalone gateway typecheck initially reported missing built workspace packages after fresh worktree setup; root validation builds the workspace graph and passed. +- `TURBO_FORCE=true pnpm typecheck` — PASS (42 tasks, 0 cached). +- `TURBO_FORCE=true pnpm lint` — PASS (23 tasks, 0 cached after one import-type remediation). +- `TURBO_FORCE=true pnpm format:check` — PASS. +- `TURBO_FORCE=true pnpm test` — PASS (42 tasks, 0 cached; expected existing integration skips only). + +## Review checkpoint + +- Codex code review found idempotency keys needed actor scope and concurrent retries needed an in-flight reservation; both were remediated with regression coverage. +- Codex security review found whitespace-equivalent self-delegation was accepted by the exported client; identities are now normalized before invariant checks, with regression coverage. +- Re-review added immutable payload comparison for idempotency reuse, runtime string/size validation, bounded TTL/capacity tracking for gateway and native adapter state, and fresh-correlation follow-up reads; targeted tests pass (16 coord / 7 gateway). +- Final Codex security review found no issues. PR #735 was opened from commit `7936e15d`; Woodpecker pipeline #1752 is green. diff --git a/docs/scratchpads/tess-m4-003-operator-plugins.md b/docs/scratchpads/tess-m4-003-operator-plugins.md new file mode 100644 index 00000000..90c690dd --- /dev/null +++ b/docs/scratchpads/tess-m4-003-operator-plugins.md @@ -0,0 +1,27 @@ +# TESS-M4-003 — Operator Plugin Foundations + +- **Task:** TESS-M4-003 / TESS-MEM-001 +- **Branch/base:** `feat/tess-operator-plugins` rebased on `origin/main` `76325ca3` +- **Scope:** first leaf-package memory/retrieval slice only; no durable inbox ownership, gateway integration, or Mosaic catalog implementation. + +## Handoff + +Coder4's uncommitted implementation was preserved first in commit `5b99c821` before review. The completion pass corrected the contract so namespace is injected configuration rather than caller-selected scope data, storage keys include tenant/owner/session via collision-safe tuple encoding, and malformed runtime scope values fail closed. + +## Delivered boundary + +- `OperatorMemoryPlugin` exposes `capture`, `search`, `recent`, `stats`, and `startupContext` through `MemoryAdapter` only. +- Scope is server-derived `{tenantId, ownerId, sessionId}`; adapter and namespace are configuration, not operation input. +- Capture redacts before persistence and records configured instance/namespace/source provenance. +- Wildcard retrieval is documented at the `MemoryAdapter` boundary and implemented by the keyword adapter. +- Startup context uses a bounded 64-result candidate window, then prioritizes project and flat-file provenance before slicing the configured output limit. +- No `Tess` identity is hardcoded in storage keys or defaults; tests use configured `Nova`. + +## Verification + +- `pnpm --filter @mosaicstack/memory test` — PASS (32 tests) +- `pnpm --filter @mosaicstack/memory typecheck` — PASS +- `pnpm --filter @mosaicstack/memory lint` — PASS +- `pnpm --filter @mosaicstack/memory build` — PASS +- Codex code review — APPROVE after remediation +- Codex security review — no findings after runtime scope-validation remediation diff --git a/docs/tess/ADMIN-GUIDE.md b/docs/tess/ADMIN-GUIDE.md new file mode 100644 index 00000000..03b78c3a --- /dev/null +++ b/docs/tess/ADMIN-GUIDE.md @@ -0,0 +1,5 @@ +# Tess Administration + +Configure agent/provider identities outside client input. Verify `/health/ready` and provider health before enabling interaction clients. Every interaction request requires an authenticated actor and correlation header; tenant and owner scope are server-derived. Do not log or return service credentials. + +For an incident, preserve correlation IDs, inspect provider status and durable checkpoint/inbox/outbox state, then use the recovery endpoint. Do not retry an ambiguous external effect automatically. Stop operations require an exact one-time approval reference; provisioning or granting a broad admin capability does not replace that check. diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md new file mode 100644 index 00000000..88121829 --- /dev/null +++ b/docs/tess/ARCHITECTURE.md @@ -0,0 +1,123 @@ +# Tess Architecture + +## Purpose + +Tess is the Mosaic operator interaction plane. Mos remains the coding/general fleet orchestration authority. Tess receives authorized operator intent, presents fleet/session state, delegates Mos-owned work to Mos, and exposes native Mosaic plus transitional external-agent capabilities through normalized providers. + +## Component Boundaries + +```text +Discord plugin ─┐ + ├─ authenticated ingress envelope ─> Mosaic Gateway +mosaic tess CLI ┘ │ + ├─ policy/approval/audit + ├─ Tess durable session service (Pi GPT-5.6 Sol high) + ├─ AgentRuntimeProvider registry + │ ├─ native Pi provider + │ ├─ fleet/tmux provider + │ ├─ Hermes adapter + │ └─ Matrix/native transport provider + ├─ memory/state/inbox plugins + └─ Mos coordination adapter ─> Mos / fleet queue +``` + +## Core Contract + +`AgentRuntimeProvider` is separate from the existing model-completion `IProviderAdapter`. It normalizes external and native agent runtimes without leaking provider-specific schemas. + +Required operations: + +- `capabilities()` and `health()` +- `listSessions(scope)` +- `getSessionTree(scope)` +- `streamSession(sessionRef, cursor, scope)` +- `sendMessage(sessionRef, message, idempotencyKey, scope)` +- `attach(sessionRef, mode, scope)` / `detach()` +- `terminate(sessionRef, approvalRef, scope)` + +Every call receives an immutable, server-derived actor/tenant/channel scope and correlation ID. Caller-supplied actor IDs are forbidden. Unsupported capabilities fail closed with typed errors. + +### M1 Registry Boundary + +`@mosaicstack/agent` owns the explicit `AgentRuntimeProviderRegistry`; duplicate provider IDs are rejected rather than replaced. Gateway owns `RuntimeProviderService`, which creates a frozen `RuntimeScope` from authenticated `ActorTenantScope` and trusted ingress channel/correlation metadata before every provider call. The service checks the declared provider capability before invoking a side effect and records metadata-only audit events (`providerId`, operation, outcome, actor/tenant/channel, correlation, and resource ID). It never records message bodies, idempotency keys, or approval references. + +Termination is fail-closed: a runtime approval verifier consumes a one-time, exact action binding for the provider, session, actor, tenant, channel, and correlation ID before `terminate` reaches a provider. The verifier reuses the Redis-backed `interaction:command-approval:*` store and its expiry/delete-on-consume semantics; it has no parallel approval store. This internal service introduces no HTTP endpoint; later Discord, CLI, MCP, and provider adapters consume the same gateway boundary. + +## Authority Model + +| Intent | Owner | Tess behavior | +| --------------------------------------------------------------------------- | ----------------------- | ---------------------------------------------------------------------- | +| Conversation, status, retrieval, safe diagnostics | Tess | Execute within policy | +| Code/project decomposition, worker assignment, reviews, merge orchestration | Mos | Create a correlated handoff and observe result | +| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently | +| Provider-specific unsupported action | None | Fail closed; never emulate silently | + +### Mos Coordination Boundary + +`@mosaicstack/coord` exposes only the transport-neutral `InteractionCoordinationPort` +verbs `handoff`, `observe`, and `result`. Gateway derives the actor, tenant, +correlation, and interaction-agent identity from authenticated context plus +trusted configuration; callers never provide an orchestration target. It +rejects unconfigured identities, self-delegation, target/correlation drift, and +cross-tenant handoff reads before an adapter call. No dispatch, assignment, +review, merge, or cancellation API exists at this boundary. + +M4 uses a deterministic native in-process queue adapter to prove the handoff → +observe → result flow without coupling the contract to tmux. A fleet/tmux +adapter is deferred to the M5 live-deployment seam and must implement the same +port. + +## Session and State Model + +A Tess session has stable `sessionId`, `tenantId`, `ownerId`, provider/runtime identity, ingress bindings, cursor, checkpoint, inbox/outbox, and idempotency records. Discord and CLI bind to the same authorized session. Ownership is verified server-side on every list/read/attach/send/terminate operation. + +Valkey holds the existing short-lived, one-time command-approval records; PostgreSQL is canonical for durable session bindings, checkpoints, inbox/outbox, and idempotency. Pi session files are replay sources, not cross-agent truth. + +### M2 Durable Recovery + +`@mosaicstack/agent` owns a transport-neutral state machine and `apps/gateway` provides its +PostgreSQL adapter. `interaction_sessions` holds immutable identity; inbox/outbox records use a +per-session unique idempotency key and transition `pending → processing → processed|delivered`. +Checkpoints are immutable history scoped by session and checkpoint ID: the latest checkpoint +supports compaction recovery, while a handoff always resolves the exact checkpoint it references. +Recovery requeues only interrupted inbox work; an ambiguous `processing` outbox record is preserved +until separately authorized reconciliation can establish its external delivery state. + +Provider sends travel through the existing `RuntimeProviderService` with the persisted outbox +idempotency key. A normal dispatch claims exactly one outbox record and verifies its stored +correlation and channel against the server-derived request scope; it never requeues or drains +another live record. Inbox/outbox payloads and checkpoint cursor/summary pass through the existing +secret/PII redactor and AES-256-GCM sealing before persistence; decryption occurs only in the +scoped gateway repository path, and runtime audit remains metadata-only. + +An external effect cannot share a database transaction. If a process dies after an effect begins +but before its terminal outbox transition, automatic recovery does not replay that ambiguous claim. +It remains `processing` until separately authorized reconciliation can establish delivery state; +completed effects are never redispatched. Operators can therefore restart the gateway/Pi service, +reconstruct the session, and resume pending inbox work without relying on process-local state. + +## Transport Strategy + +- **Initial:** fleet/tmux provider, including exact target, socket, identity, heartbeat, and safe attach semantics. +- **Forward:** Matrix/native Mosaic provider using authenticated identity, idempotent transaction IDs, replay cursors, and the same contract suite. +- Discord/CLI never call tmux or Matrix directly. + +### Fleet/tmux Provider Boundary + +`TmuxFleetRuntimeProvider` supports only rostered fleet peers. Its transport resolves the configured roster socket itself and verifies the exact `=:0.0` pane and declared runtime command before every attach, message, or termination operation. Prefixes, unrostered session IDs, unavailable sockets, dead panes, and runtime identity mismatches fail closed; callers cannot supply a socket or raw tmux target. + +The provider advertises list, tree, read-only attach, send, and terminate. List/tree/health and read attach all default-deny until a scope-aware read authority permits the operation and exact peer. Attach produces a short-lived handle bound to the immutable actor, tenant, channel, and correlation scope; it never opens a server-side terminal and rejects `control` mode. Fleet stream support is intentionally absent. Tess has no direct write/control authority: send and terminate default-deny until a Mos authority adapter explicitly allows the exact session and immutable scope. The gateway registry remains the audit boundary for every requested, denied, and successful provider operation, and still consumes the exact-action termination approval before the provider is invoked. + +## Plugin Families + +1. Channel: Discord now; other channels later. +2. Runtime: Pi, fleet/tmux, Hermes, Matrix/native. +3. Operator tools: fleet health, Mos handoff, GitOps wrappers, incident-safe diagnostics. +4. Memory/state: search/recent/capture, durable inbox, checkpoint, handoff, compaction recovery. +5. Migration: capability inventory, adapters, cutover, rollback, telemetry. + +## Deployment + +Tess runs as a rostered, systemd-supervised Pi agent using GPT-5.6 Sol and high reasoning. Secrets are supplied through approved runtime secret mechanisms. Startup fails when required model, gateway identity, Discord binding, or durable-state dependencies are missing. Health reports effective model/reasoning/tool policy without credential material. + +The interaction-service identity is provisioning data, not a source identifier: the roster and per-agent environment carry the chosen display/roster name into a generic systemd instance. The service rejects a name mismatch or any drift from its pinned Pi/GPT-5.6 Sol/high/operator-interaction effective policy before launch. Its policy printer exposes only those resolved safe fields. diff --git a/docs/tess/DEVELOPER-GUIDE.md b/docs/tess/DEVELOPER-GUIDE.md new file mode 100644 index 00000000..dfd534af --- /dev/null +++ b/docs/tess/DEVELOPER-GUIDE.md @@ -0,0 +1,3 @@ +# Tess Developer Guide + +Interaction adapters pass only server-derived actor/tenant scope, channel, and correlation to runtime providers. Durable session state owns inbox/outbox/checkpoint recovery. Use the OpenAPI contract rather than inventing routes; unsupported provider capabilities fail closed. diff --git a/docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md b/docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md new file mode 100644 index 00000000..5bacf6a6 --- /dev/null +++ b/docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md @@ -0,0 +1,17 @@ +# TESS-M4-003 Operator Plugin Sketch + +## Memory/retrieval slice — TESS-MEM-001 + +Introduce a transport-neutral `OperatorMemoryPlugin` in `packages/memory`. The plugin receives a server-derived `{tenantId, ownerId, sessionId}` scope and delegates to a registered `MemoryAdapter`; adapter and namespace are injected configuration, never caller input. Its operations are `capture`, `search`, `recent`, `stats`, and `startupContext`. Results carry configured instance, provenance, and namespace metadata. Capture/redaction occurs before adapter persistence; startup context uses a bounded candidate window ordered so project/flat-file truth takes precedence within returned material. + +Registration remains replaceable-adapter based: the existing `registerMemoryAdapter(kind, factory)` / `createMemoryAdapter(config)` seam supplies the injected adapter to `createOperatorMemoryPlugin(config)`. Identity and namespace are configuration data; no interaction-agent name is embedded in keys or defaults. + +## Remaining plugin foundations — TESS-PLG-001 + +- `packages/agent`: capability descriptors for runtime bootstrap, durable inbox/state hooks, and read-only fleet diagnostics. Each capability advertises supported operations and fails closed when absent. +- `packages/mosaic`: a catalog/registration surface for GitOps, fleet diagnostics, runtime bootstrap, Discord, and MCP/skill discovery. Catalog entries describe authority, input schema, and safe/read-only status; they do not invoke provider transports directly. +- Gateway/channel adapters consume these contracts through server-derived actor/tenant context and durable session state, preserving the replaceable-adapter boundary. + +## First implementation boundary + +The first PR slice should add the operator-memory plugin contract, configuration-injected adapter seam, scope isolation, provenance-bearing retrieval, and tests for namespace isolation plus a differently named configured instance. Durable inbox/outbox remains owned by the existing `DurableSessionCoordinator`; this plugin only supplies bounded context/capture at lifecycle boundaries. diff --git a/docs/tess/M5-003-DOCUMENTATION-CHECKLIST.md b/docs/tess/M5-003-DOCUMENTATION-CHECKLIST.md new file mode 100644 index 00000000..5622d0a3 --- /dev/null +++ b/docs/tess/M5-003-DOCUMENTATION-CHECKLIST.md @@ -0,0 +1,8 @@ +# TESS-M5-003 Documentation Checklist + +- [x] `openapi-tess.yaml`: authenticated interaction endpoints including SSE stream, Mos handoff/observe/result, and memory preferences, insights, and search. +- [x] User guide: authorized session and handoff workflows. +- [x] Admin guide: provisioning, policy, health, and approval boundary. +- [x] Developer guide: scope, durable state, and provider adapter contract. +- [x] Plugin guide: replaceable-adapter, redaction, and identity-as-data rules. +- [x] Operations guide: readiness, recovery, ambiguous-effect safety, and tracing. diff --git a/docs/tess/M5-MIGRATION-CUTOVER.md b/docs/tess/M5-MIGRATION-CUTOVER.md new file mode 100644 index 00000000..973eb96f --- /dev/null +++ b/docs/tess/M5-MIGRATION-CUTOVER.md @@ -0,0 +1,12 @@ +# TESS-MIG-001 — Cutover Procedure + +This procedure is evidence-bound. It does not authorize a production cutover until the M5 qualification gate records the required validation. + +1. Confirm the gateway has the explicitly registered `runtime.hermes` adapter (`apps/gateway/src/agent/agent.module.ts`) and provider reachability evidence (`apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts`). +2. Query the normalized runtime capability surface, not a Hermes API directly. Confirm the session capabilities required for the operation are advertised. +3. Query the transitional matrix through `RuntimeProviderService.transitionalCapabilityMatrix` (`apps/gateway/src/agent/runtime-provider-registry.service.ts`). Kanban, skills, memory, tools, and cron must remain `unsupported`; stop rather than route those operations through Hermes. +4. Route new memory activity through the Mosaic operator-memory plugin path; there is no landed Hermes memory import. +5. Use `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`) for orchestration handoff. The interaction agent does not take configured orchestrator authority. +6. Record the qualification evidence and only then update an external deployment/channel binding through its separately authorized operational process. + +No claim here authorizes bulk transcript copying, data-schema migration, or enabling an unsupported transitional capability. diff --git a/docs/tess/M5-MIGRATION-INVENTORY.md b/docs/tess/M5-MIGRATION-INVENTORY.md new file mode 100644 index 00000000..adaeebbf --- /dev/null +++ b/docs/tess/M5-MIGRATION-INVENTORY.md @@ -0,0 +1,11 @@ +# TESS-MIG-001 — Hermes → Mosaic Evidence Inventory + +Hermes is a reference adapter, not a Mosaic core dependency. `packages/agent/src/hermes-runtime-provider.ts` contains the adapter-local `HermesLegacySession` and converts it to core `RuntimeSession`; `packages/types/src/agent/agent-runtime-provider.ts` contains only normalized contracts. `apps/gateway/src/agent/agent.module.ts` explicitly registers the adapter, while `apps/gateway/src/agent/runtime-provider-registry.service.ts` exposes it only through the runtime registry. + +| Reference concern | Landed Mosaic evidence | State | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | +| sessions, hierarchy, streaming, send/attach/terminate | `HermesRuntimeProvider` plus `hermes-runtime-provider.test.ts` | adapted | +| Kanban, skills, memory, tools, cron | normalized matrix in `HermesRuntimeProvider.transitionalCapabilityMatrix`; each is `unsupported` and `assertTransitionalCapability` denies before a transport call | deferred / fail-closed | +| operator memory | `packages/memory/src/operator-memory-plugin.ts`, constructed by `apps/gateway/src/memory/memory.module.ts` and session-scoped by `apps/gateway/src/agent/agent.service.ts` | native Mosaic path | +| orchestration handoff | `InteractionCoordinationService` in `apps/gateway/src/coord/interaction-coordination.service.ts` retains authenticated handoff/observe/result ownership checks | native Mosaic path | +| transcripts, profiles, preferences | no Hermes importer/schema mapping landed | no automatic migration | diff --git a/docs/tess/M5-MIGRATION-RETENTION-DEPRECATION.md b/docs/tess/M5-MIGRATION-RETENTION-DEPRECATION.md new file mode 100644 index 00000000..9f63dc89 --- /dev/null +++ b/docs/tess/M5-MIGRATION-RETENTION-DEPRECATION.md @@ -0,0 +1,14 @@ +# TESS-MIG-001 — Retention and Legacy Deprecation Policy + +## Retention + +- Hermes is not a Mosaic persistence authority. The adapter maps runtime behavior only; it does not import or persist Hermes legacy session shapes. +- Mosaic operator memory is scoped by tenant, owner, and session in `packages/memory/src/operator-memory-plugin.ts`; gateway session ownership is derived before that plugin is made available in `apps/gateway/src/agent/agent.service.ts`. +- Existing Hermes archives remain in their source system under its existing retention policy. This project has no landed automatic transcript, profile, or preference migration. +- Any future import requires an explicit, scoped design and redaction/provenance evidence; it must not extend `packages/types` with Hermes schema. + +## Deprecation + +- Session adapter use remains transitional until M5 qualification demonstrates the normalized provider path. +- Kanban, skills, memory, tools, and cron are not deprecated into a Hermes bridge: they remain explicitly unsupported until their Mosaic-owned contracts are implemented and qualified. +- A future deprecation change must remove the external binding first, retain rollback evidence, and then remove the adapter in a separately reviewed code change. It must not silently replace or widen a registered provider. diff --git a/docs/tess/M5-MIGRATION-ROLLBACK.md b/docs/tess/M5-MIGRATION-ROLLBACK.md new file mode 100644 index 00000000..6f659d3e --- /dev/null +++ b/docs/tess/M5-MIGRATION-ROLLBACK.md @@ -0,0 +1,10 @@ +# TESS-MIG-001 — Rollback Procedure + +Rollback is configuration/binding reversal, not a database rollback: no Hermes schema migration or automatic data import is implemented by the landed adapter. + +1. Stop sending new traffic to the Mosaic Hermes adapter by reverting the external runtime/channel binding through its authorized deployment process. +2. Keep the gateway registration and core contracts unchanged unless a reviewed code rollback is required; `AgentRuntimeProviderRegistry` registration is explicit and non-replacing (`packages/agent/src/runtime-provider-registry.ts`). +3. Do not replay an unsupported Kanban, skills, memory, tools, or cron operation. The transitional matrix is intentionally fail-closed. +4. Preserve Mosaic audit, session, and operator-memory records under their normal scoped retention rules; do not copy them into Hermes as a rollback shortcut. +5. For an in-flight coordination request, use the owned handoff observation/result flow in `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`); do not create a second orchestrator path. +6. Capture the binding reversal, affected scope, correlation IDs, and reason in the approved operational record before retrying a cutover. diff --git a/docs/tess/MIGRATION-INVENTORY.md b/docs/tess/MIGRATION-INVENTORY.md new file mode 100644 index 00000000..f6d75fbe --- /dev/null +++ b/docs/tess/MIGRATION-INVENTORY.md @@ -0,0 +1,34 @@ +# Tess Capability Migration Inventory + +Status values: `native` · `adapt` · `defer` · `reject`. This is the initial inventory; M5 requires implementation and evidence fields to be completed before cutover. + +| Capability | Current source | Target | Initial status | Cutover/rollback intent | +| ---------------------------------------- | ---------------------------------------- | ------------------------------------------ | -------------- | ----------------------------------------------------------------------- | +| Interactive agent chat/session streaming | Hermes/Pi/OpenClaw | Mosaic Tess session service | native | Dual-run per channel; revert binding to legacy gateway | +| Discord dedicated-channel routing | Hermes/Claude/OpenClaw plugins | Mosaic Discord plugin + gateway | native | Per-channel binding switch; legacy bot disabled only after soak | +| CLI/TUI session interaction and attach | Hermes/Pi/tmux | `mosaic tess` + AgentRuntimeProvider | native | Keep direct tmux attach as break-glass rollback | +| Session list/tree/send/terminate | Hermes/fleet | AgentRuntimeProvider | native | Capability-negotiated adapter remains during migration | +| Mos/fleet orchestration handoff | tmux messaging/Mosaic fleet | Mosaic coord/fleet provider | native | tmux handoff remains initial transport | +| Kanban/projects/tasks | Hermes Kanban | Mosaic queue/coord/project providers | adapt | Read projection first; mutating cutover after parity/audit | +| Skills catalog/load/manage | Hermes skills/Pi skills | Mosaic skill registry/provider | adapt | Import metadata/provenance; preserve source skill until validated | +| Tools and MCP | Hermes/OpenClaw/MCP | Mosaic tool registry/MCP | adapt | Default deny; migrate allowlisted tools one capability at a time | +| Cron/scheduled work | Hermes cron | Mosaic scheduler/queue | adapt | Shadow schedules; prevent duplicate execution; rollback owner field | +| Memory search/recent/capture | jarvis-brain/OpenViking/OpenBrain/Hermes | Mosaic memory provider | adapt | Flat/project stores remain truth; semantic systems are mirrors | +| User/profile preferences | Hermes memory/user profile | Mosaic user/memory domain | adapt | Provenance + explicit conflict rules; exportable rollback snapshot | +| Agent state/inbox/handoff | OpenClaw extensions/session files | Mosaic durable state service | native | Read legacy handoff during coexistence; write Mosaic only after cutover | +| Runtime contract/bootstrap | Mosaic framework/Hermes/OpenClaw | Mosaic compose/runtime provider | native | Legacy launchers remain until clean-host parity passes | +| Repository/PR workflow | Mosaic wrappers/Hermes tools | Mosaic operator plugin | native | Wrapper-only; no raw-provider fallback | +| Incident-safe diagnostics | Hermes skills/tools | Mosaic scoped operator plugin | adapt | Read-only first; privileged recovery requires approval | +| Broad unrestricted shell from Discord | Hermes/OpenClaw configurations | None | reject | No cutover; replace with allowlisted typed operations | +| Raw full transcript bulk migration | Hermes/Claude/OpenClaw histories | Indexed summaries/selective import | reject | Keep source archives subject to retention; no automatic copy | +| Voice/video interaction | Hermes optional tools | Future Mosaic channel plugins | defer | Not required for Tess operational release | +| Matrix transport | Mosaic connector | AgentRuntimeProvider Matrix implementation | native | Non-default until contract/reliability parity; tmux rollback | + +## Cutover Gates + +1. Capability contract and security tests pass. +2. Data mapping/provenance and retention are documented. +3. Shadow or dual-run shows no unauthorized access, loss, or duplicate effects. +4. Operator runbook and rollback are exercised. +5. Channel/provider binding changes are reversible without schema rollback. +6. Legacy capability is disabled only after a defined soak period and evidence review. diff --git a/docs/tess/MISSION-MANIFEST.md b/docs/tess/MISSION-MANIFEST.md new file mode 100644 index 00000000..5c3effe7 --- /dev/null +++ b/docs/tess/MISSION-MANIFEST.md @@ -0,0 +1,46 @@ +# Mission Manifest — Tess Interaction Agent + +## Mission + +- **ID:** tess-20260712 +- **Issue:** #706 +- **Branch:** `feat/tess-interaction-agent` +- **Phase:** Execution +- **Current Milestone:** TESS-M1 — Runtime contracts and security foundation +- **Progress:** 0 / 5 delivery milestones complete +- **Status:** active +- **Owner:** Mosaic orchestrator; Mos is coordinating fleet authority +- **Source PRD:** `docs/PRD.md` — `TESS-*` requirements +- **Scratchpad:** `docs/scratchpads/tess-20260712.md` + +## Mission Statement + +Ship Tess as Jason's durable Pi-native GPT-5.6 Sol high-reasoning interaction agent for Discord and CLI, with safe visibility/control of Mosaic fleet and transitional Hermes capabilities, while Mos remains the coding/general orchestration authority. + +## Invariants + +1. Mosaic is the enterprise AI hub; Hermes is a reference migration adapter. +2. Gateway is the single API surface. +3. Mos owns coding/general fleet orchestration; Tess owns human interaction, visibility, mediation, and migration access. +4. Runtime, transport, channel, memory, and external-agent integrations are replaceable adapters. +5. No source task completes before merged PR, terminal-green CI, independent review, and linked task/issue closure. + +## Milestones + +| ID | Issue | Name | Status | Exit gate | +| ------- | ----- | ------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- | +| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged | +| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests | +| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals | +| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | in-progress | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end | +| TESS-M5 | #711 | Matrix/native migration, recovery, documentation, and qualification | not-started | Transport parity, migration/rollback matrix, security review, docs, greenfield and deployment validation complete | + +## Success Criteria + +All `AC-TESS-*` criteria in `docs/PRD.md` are mapped to reproducible evidence. The final operational test must prove Discord + CLI session continuity, fleet/Mos coordination, authorized Hermes transition capabilities, denial/audit paths, restart recovery, and rollback. + +## Session History + +| Session | Date | Runtime | Outcome | +| ------- | ---------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| S1 | 2026-07-12 | Hermes / GPT-5.6 Sol | User commission captured; Mosaic/OpenViking/session/code archaeology completed; issue #706 created; PRD and task control plane initialized. | diff --git a/docs/tess/MOS-COORDINATION.md b/docs/tess/MOS-COORDINATION.md new file mode 100644 index 00000000..6ee7d099 --- /dev/null +++ b/docs/tess/MOS-COORDINATION.md @@ -0,0 +1,87 @@ +# Tess–Mos Coordination Contract Sketch + +**Task:** TESS-M4-001 · **PRD:** TESS-MOS-001 / AC-TESS-04 + +## Boundary + +Agent identities are deployment data. A configured interaction agent may request +Mos-owned work; the configured orchestration agent owns decomposition, worker +assignment, reviews, and merge decisions. The interaction agent receives a +correlated receipt, read-only activity projection, and terminal result. It has +no dispatch, assignment, review, merge, or cancellation operation. + +## `@mosaicstack/coord` interface + +```ts +interface CoordinationScope { + readonly actorId: string; + readonly tenantId: string; + readonly correlationId: string; + readonly requesterAgentId: string; // trusted gateway/configuration data +} + +interface HandoffRequest { + readonly idempotencyKey: string; + readonly summary: string; + readonly context?: string; + readonly missionId?: string; +} + +interface HandoffReceipt { + readonly handoffId: string; + readonly targetAgentId: string; + readonly status: 'accepted' | 'queued'; + readonly correlationId: string; +} + +interface Handoff { + readonly handoffId: string; + readonly targetAgentId: string; + readonly request: HandoffRequest; + readonly scope: CoordinationScope; +} + +interface InteractionCoordinationPort { + handoff(handoff: Handoff): Promise; + observe(handoffId: string, scope: CoordinationScope): Promise; + result(handoffId: string, scope: CoordinationScope): Promise; +} +``` + +The port deliberately omits generic orchestrator verbs. It is tenant- and +correlation-scoped; its gateway implementation obtains `actorId`, `tenantId`, +and the requester agent from trusted authentication/configuration only. + +## HTTP routes + +`/api/coord/interaction` is the canonical HTTP coordination prefix for handoff, observe, and result. `/api/coord/mos` remains a backward-compatible alias with the same handlers and DTOs; new integrations use the neutral canonical prefix. + +## Enforcement point + +`apps/gateway` owns an `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`) boundary that compares the +trusted configured requester/target identities and rejects all of the following +before calling a transport: unconfigured requester, self-delegation, target +identity drift, cross-tenant observe/result lookup, and attempts to observe or +receive a result for a handoff outside the originating tenant. The service exposes handoff, observe, +and result only, and delegates delivery to an injected adapter. + +M4 ships a native in-process `InMemoryInteractionCoordinationPort` as the concrete, +deterministic adapter. It preserves the immutable handoff ID, tenant, requester +identity, and correlation ID while demonstrating the handoff → observe → result +round trip. It is a queue/port adapter, not a Mos-side consumer. + +A future fleet/tmux adapter is a documented M5 deployment seam and must +implement the same `InteractionCoordinationPort`; no channel client or interaction +runtime calls a transport directly. + +## Required tests + +1. A configured non-default interaction identity can hand off work to a + configured non-default orchestration identity and receive its result. +2. The gateway passes only server-derived scope/identity to the adapter. +3. Self-targeting, target drift, and cross-tenant observe/result all fail closed + without invoking the adapter. +4. The exported public contract has no worker-dispatch, assignment, review, + merge, or cancellation capability. +5. The native adapter round-trips queued work, activity, and a host-recorded + terminal result without a live fleet dependency. diff --git a/docs/tess/OPERATIONS-GUIDE.md b/docs/tess/OPERATIONS-GUIDE.md new file mode 100644 index 00000000..e0a262e8 --- /dev/null +++ b/docs/tess/OPERATIONS-GUIDE.md @@ -0,0 +1,3 @@ +# Tess Operations and Recovery + +Check `/health/ready`, provider health, and effective policy before recovery. Recover durable sessions through the interaction recovery operation; it requeues only interrupted work and does not replay ambiguous external effects. Preserve correlation IDs for incident tracing and use Mos handoff observation/result endpoints for orchestration visibility. diff --git a/docs/tess/PLUGIN-GUIDE.md b/docs/tess/PLUGIN-GUIDE.md new file mode 100644 index 00000000..efa54fdb --- /dev/null +++ b/docs/tess/PLUGIN-GUIDE.md @@ -0,0 +1,28 @@ +# Tess Plugin Authoring + +Plugins are replaceable adapters. Declare capabilities, derive scope from trusted context, preserve correlation IDs, redact before persistence/egress, and return unsupported operations as fail-closed results. Names and identities are configuration data, not literals in keys or defaults. + +## Official channel adapter contract + +Official Discord, Matrix, Slack, and future channel adapters share contracts exported from `@mosaicstack/types` under `channel/`: + +- `OfficialChannelAdapter` provides `name`, `start()`, `stop()`, and non-throwing connection `health()`. +- `ChannelMessageDto` and `ChannelAttachmentDto` normalize transport data with JSON-safe metadata. +- `ChannelBindingDto` and `ChannelAuthorizedPrincipalDto` normalize configuration-owned logical-agent binding and the already-allowlisted/paired external actor. +- `ChannelIngressDto` carries operation, correlation, native message ID, authorized principal, normalized message, and stable route into `ChannelIngressPort`. +- `ChannelConversationRouteDto` binds a configured channel to `logicalAgentId`, stable `conversationId`, authorization parent, and response target. +- `ChannelEgressDto` and `ChannelEgressPort` separate where a response is delivered from the gateway's runtime/provider selection. + +`ChannelConversationRouteDto` deliberately has no harness, provider, model, process, or native runtime-session field. The gateway owns runtime selection, durable enrollment, authorization, audit, and lease/fencing. A channel adapter must not call Claude, Codex, Pi, OpenCode, tmux, or Matrix runtime providers directly. Discord currently preserves its signed Socket.IO compatibility ingress for established gateway authentication/replay/approval controls while normalizing the same ingress DTO; supplied direct ports are the future registration path. + +## Adapter requirements + +1. Resolve configuration-owned channel and logical-agent bindings before dispatch. A binding may carry a trusted gateway agent-config reference, but the stable route contains only the logical agent; gateway verifies the reference resolves to that agent before runtime selection. +2. Apply channel-native allowlists and paired-user roles before any external side effect such as thread creation. +3. Preserve native message ID, correlation ID, channel/thread address, attachments, and response target. +4. Treat normal channel parents (for example Discord categories) separately from thread parents. +5. Keep reconnect and conversation identity independent of the active runtime provider. +6. Report sanitized connection/routing failures without message bodies or credentials. +7. Pass the shared route/authorization contract suite plus adapter-specific translation tests. + +Discord establishes the first policy: authorized untagged messages respond in the configured channel; a mention creates a thread or reuses the thread already attached to that message; existing thread messages stay there. Runtime control commands remain on the current durable session. Matrix and Slack should translate native rooms/threads into the same route and response-target semantics rather than adding transport branches to gateway core. diff --git a/docs/tess/TASKS.md b/docs/tess/TASKS.md new file mode 100644 index 00000000..6becd141 --- /dev/null +++ b/docs/tess/TASKS.md @@ -0,0 +1,46 @@ +# Tasks — Tess Interaction Agent + +> Mission: `tess-20260712` · Issue: #706 · PRD requirements: `TESS-*` +> Orchestrator is sole writer. Workers must not modify this file. +> `repo` contains one or more comma-separated repository-relative roots; every listed root must exist before dispatch. +> **BASE CONVENTION (Mos-locked 2026-07-12):** EVERY M1 dispatch targets `base=main` and branches from fresh `origin/main`. Do NOT base on `feat/tess-interaction-agent` — that is the STALE planning branch (merged via #712); basing on it yields `mergeable=False` + intervening-commit noise (cf. #723/SEC-005 re-base). Every dispatch brief must state base=main + branch-from-origin/main. + +| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| TESS-PLAN-001 | done | Finalize PRD, architecture, authority boundary, threat model, migration inventory, and verification matrix | #706 | sonnet | docs, packages/types, apps/gateway | feat/tess-interaction-agent | — | 22K | Independent gate PASS after two remediation rounds; completion effective when planning PR merges | +| TESS-M1-SEC-001 | done | Enforce command scopes/roles and durable exact-action approval for privileged/destructive commands | #707 | codex | apps/gateway | fix/tess-command-authz | TESS-PLAN-001 | 25K | TESS-SEC-002; diskhygiene-terra; PR #718 head e3d98d73 MERGED (ROR 16829) | +| TESS-M1-SEC-002 | done | Enforce owner/tenant binding on session list/read/attach/send/terminate across REST and WS | #707 | codex | apps/gateway | fix/tess-session-ownership | TESS-PLAN-001 | 30K | TESS-SEC-003; coder0; PR #715 head 43ffbe7b MERGED (ROR 16808) | +| TESS-M1-SEC-003 | done | Bind MCP actor/tenant to authenticated context and add per-tool scopes | #707 | codex | apps/gateway | fix/tess-mcp-identity | TESS-PLAN-001 | 22K | TESS-SEC-004; coder1; PR #717 head 9ab776f9 MERGED (ROR 16819) | +| TESS-M1-SEC-004 | done | Add authenticated Discord service ingress, allowlists, correlation and replay protection | #707 | codex | plugins/discord, apps/gateway | fix/tess-discord-ingress | TESS-PLAN-001 | 28K | TESS-SEC-005; coder4; PR #716 head 55ae77b6 MERGED (ROR 16830) | +| TESS-M1-SEC-005 | done | Redact/classify secret and PII before persistence/egress; harden provider login flow | #707 | codex | apps/gateway, packages/log | fix/tess-redaction | TESS-PLAN-001 | 28K | TESS-SEC-006; coder1 (Mos-routed from blocked wrapfix-terra); mis-based #723 CLOSED superseded. MERGED to main: PR #725 head 726f7ab7 (ROR 16880). Verified chat persistence + egress redaction hooks; canary tests split-secret/private-key/overflow/persistence classification; provider login no auth URL/raw token in chat output | +| TESS-M1-SEC-006 | done | Scope session GC/retention or separate authorized global retention job | #707 | codex | apps/gateway, packages/log | fix/tess-session-gc-scope | TESS-PLAN-001 | 18K | TESS-SEC-009; coder4; PR #720 base=main. MERGED to main: head 37be090e (ROR 16861). Both blockers fixed: glob metachar sessionIds escaped+regression; durable approval survives GC pass (create→GC→key remains→authz succeeds); /gc disabled, per-session log demotion, no fullCollect/sweepOrphans entry, legacy repeatable GC schedule removed | +| TESS-M1-001 | done | Define AgentRuntimeProvider, capabilities, session tree, normalized stream events/errors, attach semantics | #707 | codex | packages/types, packages/agent | feat/tess-runtime-contract | TESS-PLAN-001 | 25K | TESS-ARP-001, TESS-TRN-001; wrapfix-terra; PR #719 head 5c42e67f MERGED (ROR 16813) | +| TESS-M1-002 | done | Implement provider registry/service with immutable actor scope, approval, audit and correlation boundaries | #707 | codex | apps/gateway, packages/agent | feat/tess-provider-registry | TESS-M1-001,TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003 | 30K | TESS-SEC-001..004,007; coder0; PR #722 head c529022d MERGED (ROR 16849) | +| TESS-M1-003 | done | Implement tmux/fleet runtime provider and safe attach/message/terminate capability policy | #707 | codex | packages/mosaic, packages/agent | feat/tess-fleet-provider | TESS-M1-002 | 30K | TESS-FLT-001; coder0; PR #724 base=main. MERGED to main: head 355d814f (ROR 16868). HARD BOUNDARY verified: writes/control default-deny before tmux probing unless write authority permits; final exact-target authz after roster/socket/runtime verification; exact target/prefix/runtime-drift tests; read-only attach with immutable scope handles; empty-msg/default-deny/unverified-target tests; no credential material | +| TESS-M1-OBS-001 | done | Implement correlation propagation, structured runtime/provider/tool audit, health/readiness and safe effective-policy status | #707 | codex | apps/gateway, packages/agent, packages/log | feat/tess-observability-terra | TESS-M1-002 | 24K | TESS-OBS-001; SOLE owner=diskhygiene-terra. MERGED to main at REBASED head: PR #726 head 53f5414 (re-ROR comment 16886, prior 16881@adfa5c06 discarded after head move), CI 1723 green. Both @mosaicstack/log barrel exports coexist (redaction + runtime-audit), metadata-only/SHA-256 audit intact, fail-closed audit-before-side-effects intact, safe status/effective-policy/readiness | +| TESS-M1-V | done | Independent architecture/security review and complete contract/abuse-suite verification | #707 | mos-reviewer | apps/gateway, packages/agent, packages/log, plugins/discord | review/tess-m1 | TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003,TESS-M1-SEC-004,TESS-M1-SEC-005,TESS-M1-SEC-006,TESS-M1-003,TESS-M1-OBS-001 | 20K | Gate M2 = PASS (Mos-owned independent non-author reviewer, 2026-07-12): all 5 priority seams verified live, 60+ tests green. M2 (#708) OPEN. Orchestrator did NOT run a competing lane (prior reviewer-lane dispatch recalled). Non-gating follow-up from this review captured as TESS-M1-FUP-001 (execute() authz-error mis-classification), scheduled to land before/with TESS-M3-001 — not now | +| TESS-M1-FUP-001 | not-started | RuntimeProviderService.execute() records provider-thrown authorization errors as failed/provider_error instead of denied/policy_denied | #707 | — | apps/gateway, packages/agent | — | — | 6K | NON-GATING follow-up from M1-V review (2026-07-12). Provider-thrown authz errors (e.g. FleetRuntimeProviderError 'forbidden' from M1-003 write-authority) mis-classify as outcome:'failed'/'provider_error' rather than 'denied'/'policy_denied'. Low-priority. Land BEFORE/WITH TESS-M3-001 fleet-provider registry wiring (#709), NOT now. UPDATE 2026-07-13: did NOT ride in #730; Mos FOLDED this into TESS-M3-003 C4 (authz-error classification + RuntimeApprovalDeniedError→403 filter) — tracked there, land with M3-003. | +| TESS-M2-001 | done | Add Tess roster/profile/service pinned to GPT-5.6 Sol high with fail-fast config and observable effective policy | #708 | coder0 | packages/mosaic | feat/tess-pi-service | TESS-M1-V | 22K | TESS-PI-001; AC-TESS-03. Mos-dispatched directly to coder0. MERGED to main by Mos (2026-07-12): PR #728 head c58e86e2, ROR comment 16905, CI 1728 green. NAME-AS-CONFIG invariant VERIFIED — identity via MOSAIC_AGENT_NAME/%i/roster data, no hardcoded tess identity in impl/service/schema/tool, Tess example-only; Nova distinct-name zero-code-change provisioning test; GPT-5.6 Sol high fail-fast; credential-safe effective-policy output; no live credential material | +| TESS-M2-002 | done | Implement durable session identity, inbox/outbox, approval, checkpoint, handoff, compaction and restart recovery | #708 | coder0 | apps/gateway, packages/agent, packages/db | feat/tess-durable-state | TESS-M2-001 | 38K | TESS-STA-001, TESS-SEC-007..008; recovery TDD. Mos-dispatched directly to coder0 (2026-07-12) — orchestrator tracks, no competing lane. Branch feat/tess-durable-state from fresh origin/main e3b5113b; first recovery TDD packages/agent/src/tess-durable-session.test.ts; reuses existing provider registry + durable approval store. base=main, PR-open-STOP, independent non-author ROR then Mos merges. Next gate after merge = TESS-M2-V (M2→M3 review). PR #729 OPEN (feat(tess): persist durable session state; base=main, mergeable=true, head 102a7b606bd492201e3d731a86397a9cfe4eb998); coder0 pnpm turbo typecheck/lint/test --force 88/88 cold-cache + format green + Codex code/security remediated; scope = durable identity/inbox-outbox idempotency/immutable checkpoints/portable handoffs/PGlite close-reopen recovery/exact one-time approvals/sealed-redacted payloads/scoped dispatch. ROR routed to reviewer lane (non-author) at exact head 102a7b60. CHANGES REQUESTED (reviewer comment 16917 @102a7b60, CI pipeline 1730 RED) — 3 blockers routed to coder0: (1) CI red: gateway PGlite close/reopen durable recovery test TIMEOUT; (2) idempotency compares REDACTED payloads so distinct secrets collapse/collide — key must be over pre-redaction canonical identity; (3) durable schema/approval NAMESPACE hardcoded to tess, violates name-as-config — must derive from configured agent name (Nova zero-code-change). coder0 remediating; head will move → re-ROR required at new exact head. REMEDIATED + re-pushed: new head 444988d23bada28d3cc9ce7e588e18e052f058ff — (1) recovery suite uses one shared PGlite fixture, close/reopen AC completes ~0.5s (was 30s timeout); (2) pre-redaction SHA-256 payload digest added to idempotency conflict checks (distinct secrets no longer collapse); (3) hardcoded tess durable DB objects/approval prefix replaced with generic interaction naming + agent-bound approval namespace. push-hook typecheck/lint/format green; CI pipeline 1731 GREEN on new head 444988d2. Head confirmed unchanged at green SHA; re-ROR routed to reviewer (non-author) at exact head 444988d2 — prior REQUEST CHANGES 16917 void at old head. RE-ROR = REQUEST CHANGES (reviewer comment 16925 @444988d2; CI 1731 green + recovery test passes 1515ms not skipped). 2 residual payload-digest blockers routed to coder0: (1) CHECKPOINT idempotency still collapses — pre-redaction digest fix reached inbox/outbox but NOT checkpoint path; distinct sensitive checkpoint payloads under same sessionId+checkpointId collapse; needs pre-redaction digest column on checkpoint conflict check; (2) digest is UNKEYED plaintext SHA-256 over sensitive payloads → allows offline plaintext confirmation; switch to KEYED HMAC-SHA256 with config-sourced fail-fast secret (never hardcoded/logged), applied to inbox/outbox + checkpoint. coder0 remediating; head will move → re-ROR at new exact head. ROUND-2 REMEDIATED + pushed: new head cbdc38af954d49016b5fdc4a6dab0497317b6a1c — checkpoint now persists pre-redaction HMAC digest; inbox/outbox switched to VERSIONED HMAC; distinct-secret + delimiter-collision regressions added; migration safe for pre-existing checkpoint rows and legacy checkpoints FAIL CLOSED (cannot prove original payload); Codex security review no findings; format/diff green, PGlite recovery executes 1.765s. CI pipeline 1732 TERMINAL GREEN (success) on cbdc38af. RE-ROR = APPROVE — canonical VERIFIED APPROVE reviewer-of-record [W-jarvis:reviewer] head cbdc38af954d49016b5fdc4a6dab0497317b6a1c (visible Gitea comment 16933). Head confirmed unchanged at ROR target + PR mergeable=true. Reviewer verified checkpoint HMAC digest + collision regressions, recovery no-duplicate-side-effect, sealed/redacted at-rest payloads, scoped dispatch, agent-bound generic namespace; CI 1732 steps ci-postgres/install/sanitization/typecheck/lint/format/test all green; no merge by reviewer (pr-review wrapper self-approve blocked → recorded as verified PR comment). MERGED to main by Mos — merge_commit 99a2d0fc, final APPROVE at head cbdc38af (3-round reviewer loop 16917→16925→16933). M2 build phase CONVERGED (M2-001 #728 + M2-002 #729). TESS-M2-V (M2→M3 gate, AC-TESS-03/06) now running as Mos-owned independent Sonnet reviewer (same arrangement as M1-V) — orchestrator does NOT launch a competing review lane; Mos reports verdict. On M2-V PASS, M3 (#709) tasks TESS-M3-001/002 unblock. | +| TESS-M2-V | done | Clean-host Pi launch plus model/policy status and restart/compaction/duplicate-side-effect verification | #708 | mos-reviewer | apps/gateway/src/__tests__/integration, packages/mosaic/src | review/tess-m2 | TESS-M2-002 | 18K | Gate M3 = PASS (Mos-owned independent Sonnet reviewer at merged main 99a2d0fc, 2026-07-13). All 5 criteria verified with live test runs: model/policy fail-fast + credential-safe; restart recovery exactly-once; keyed-HMAC pre-redaction idempotency; compaction/handoff survival; name-as-config (interaction_* everywhere, Nova tests). Orchestrator did NOT run a competing lane (same as M1-V). M3 (#709) UNBLOCKED. Non-gating follow-ups from this review captured as TESS-M2-FUP-001/002/003 below. | +| TESS-M2-FUP-001 | not-started | Delete dead unkeyed-sha256 OR-branch in tess-durable-session.repository.ts matchesContentDigest (:417-422) to match strict matchesCheckpointDigest | #708 | — | apps/gateway | — | — | 4K | NON-GATING follow-up from M2-V review (2026-07-13). Dead OR-branch NOT exploitable (all writers hmac:v1:-prefixed, content_digest NOT NULL from migration 0012) but weakens exactness guarantee. Low-priority. SCHEDULE TO LAND WITH M3 work. | +| TESS-M2-FUP-002 | not-started | Add literal approval+compaction combined integration test | #708 | — | apps/gateway/src/__tests__/integration | — | — | 4K | NON-GATING follow-up from M2-V review (2026-07-13). Subsystems currently tested separately; what matters IS covered. OPTIONAL, low-priority. | +| TESS-M2-FUP-003 | not-started | Cosmetic rename pass: 'Tess' in class/file/log names (TessDurableSessionRepository etc.) so Nova deployment logs don't say Tess | #708 | — | apps/gateway, packages/agent | — | — | 5K | NON-GATING follow-up from M2-V review (2026-07-13). Cosmetic only — functional name-as-config already correct (interaction_* data path). Low-priority. SCHEDULE TO LAND WITH M3 work (now M3-003 window). Related cosmetic logged separately as TESS-M2-FUP-004. | +| TESS-M2-FUP-004 | not-started | Cosmetic: sourceLabel ?? 'tess' default literal @packages/agent/src/tmux-fleet-runtime-provider.ts:142 | #708 | — | packages/agent | — | — | 2K | NON-GATING cosmetic follow-up (pre-existing from #722/#724), logged per Mos 2026-07-13. Same class as TESS-M2-FUP-003 (name-as-config cosmetic; functional data path already generic). Low-priority — fold into a cosmetic-rename pass alongside M2-FUP-003. | +| TESS-M3-001 | done | Bind dedicated Tess Discord channel with streaming, threads, attachments, pairing/RBAC and approvals | #709 | coder4 | plugins/discord, apps/gateway | feat/tess-discord | TESS-M2-V,TESS-M1-SEC-004 | 35K | TESS-DSC-001. Mos DIRECT-DISPATCHED to coder4 on feat/tess-discord (base=main, 2026-07-13) — orchestrator is DYOR-loaded so Mos dispatched to avoid double-dispatch; orchestrator TRACKS only, no competing lane. PR-open-STOP, independent non-author ROR then Mos merges. Land TESS-M1-FUP-001 (execute() authz-error mapping) + TESS-M2-FUP-001/003 with this M3 work. PR #730 OPEN (feat(#709): add configured Discord interaction binding; base=main, mergeable=true, head 25ed3676550649d805737ddd97cec08d49873662; config-owned Discord bindings w/ pairing/RBAC, thread metadata, attachments, streaming correlation, authenticated ingress reuse). NOTE: coder4 did not report PR-open to orchestrator; picked up from reviewer ROR. CI pipeline 1734 GREEN on head. ROR = REQUEST CHANGES (reviewer comment 16951 @25ed3676) — 2 functional blockers routed to coder4: (1) THREAD/SUB-SESSION ROUTING: allowedChannelIds check tests the THREAD id before parent-channel binding resolution → thread messages in a bound channel are wrongly rejected; must resolve parent-channel binding FIRST then evaluate allowlist against bound parent + add bound-channel-thread test; (2) APPROVAL NOT WIRED TO M2 DURABLE STORE: Discord approval op defined but no path invokes the durable M2 exact-action approval store (only send wired) — must route Discord approval through the #729 durable exact-action approval surface (one-time/exact-action consume, no replay) + denial + exact-action approval tests. Root-cause fixes only (no allowlist-loosen, no approval stub). coder4 remediating; head will move → re-ROR required at new exact head after CI terminal green. ROUND-1 REMEDIATED + pushed: new head 689d5b68706fd5f5b190b0e4d988efc59fb51acb, CI pipeline 1736 GREEN. RE-ROR = REQUEST CHANGES (reviewer comment 16968 @689d5b68) — 2 residuals routed to coder4: (1) thread allowlist directionally fixed but REQUESTED regression test absent — add explicit 'message in thread of bound channel is ACCEPTED' test; (2) CORE BLOCKER: discord:approve still calls commandExecutor.createApproval (generic slash-command approval), NOT the M2 durable exact-action store — must call createRuntimeTerminationApproval writing agent::command-approval exact-action key (durable one-time consume, no replay), plus Discord one-shot tests: approval consumes exact action once + second attempt rejected, and denial-path rejection. Root-cause only (no aliasing generic path). coder4 remediating; head will move → re-ROR at new exact head after CI terminal green. ROUND-2 REMEDIATED + pushed: head moved (via 34b82bd2), CI green — but ROUND-3 RE-ROR = REQUEST CHANGES (reviewer comment 16973 @34b82bd257154ece2f51d29df698b01150cb9a2b, CI pipeline 1738 green). Progress: discord:approve now CALLS createRuntimeTerminationApproval — but STILL WRONG: it binds the approval to DISCORD_SERVICE_USER_ID + approval-message correlation/channel, making a Discord-SILO record NOT consumable by the CLI/runtime stop exact-action path. CORE M3 AC = ONE durable exact-action approval consumable cross-surface (Discord OR CLI OR runtime); exact-action KEY must be agent::command-approval for the specific pending command/termination action (same key CLI 'mosaic tess stop'/runtime-stop consumes); Discord actor + message correlation belong in METADATA, not the key. Routed to coder4 (round-3): re-key to agent+action; add tests (a) discord:approve happy consumes exact action, (b) SAME approval consumable cross-surface by CLI/runtime stop, (c) one-shot replay rejected, (d) denial rejects, (e) thread-under-parent accept. THIRD round on the same durable-approval seam (16951→16968→16973); reviewer holding cross-surface exact-action boundary firm. coder4 remediating; re-ROR at new exact head after CI terminal green. ROUND-3 REMEDIATED + pushed: head a4c70c5a71a4a73f24f04060a301703ba5ac7530, CI pipeline 1739 GREEN — but ROUND-4 RE-ROR = REQUEST CHANGES (reviewer comment 16978 @a4c70c5a). coder4 OVER-CORRECTED: removed actor/tenant/channel/correlation from runtimeActionDigest/consume (WEAKENS command-authorization exactness — the merged M2 contract digests all 7 fields), still NO Discord-origin consume/terminate path for the minted approval, required Discord-path tests still absent (approve happy+denial, one-shot replay, thread-under-parent accept). ROUND-5 ROUTED to coder4 with the FROZEN merged contract extracted from origin/main:apps/gateway/src/commands/command-authorization.service.ts: runtimeActionDigest is sha256 over EXACTLY {providerId,sessionId,actorId,tenantId,channelId,correlationId,agentName} — do NOT modify/strip (revert round-4 change); consume also re-checks actorId+tenantId, requires approver role=admin, one-shot redis.del; store key interaction:command-approval::. Cross-surface = Discord mint and CLI/runtime stop present the SAME 7 fields of the TARGET pending termination (NOT the Discord message's own channel/correlation, NOT DISCORD_SERVICE_USER_ID); actorId = approving admin's resolved user id. Add the missing Discord-origin consume/terminate invocation (via runtime-approval-verifier.ts adapter) + 5 tests. FOURTH round same seam (16951→16968→16973→16978); reviewer holding boundary firm. coder4 remediating; re-ROR at new exact head after CI terminal green. ROUND-5: writer RE-ROUTED coder4→coder0 (Mos-dispatched follow-up; coder0 is original author of the M2 durable approval store, so the ideal lane to wire the Discord path to it — orchestrator did not have this re-route tracked, flagged to Mos for confirmation, single-writer stand-down requested from coder4). coder0 pushed head 6af68e76de3657d837875b1c9d0f5c9678429e59 (branch tip confirmed), CI Woodpecker pipeline 1742 = SUCCESS (terminal green). Scope: approve-gated stop command (provider/session/approval args), one-shot durable approval consumption, explicit stop authorization (Discord-origin consume/terminate path now present), 4 security regressions; command-authorization.service.ts UNCHANGED (frozen 7-field runtimeActionDigest preserved — round-4 field-stripping reverted). ROUND-5 ROR routed to reviewer (non-author) at exact head 6af68e76; prior round-4 REQUEST CHANGES 16978 void at old head. ROUND-5 RE-ROR = REQUEST CHANGES (reviewer comment 16986 @6af68e76, CI 1742 green). PROGRESS: 7-field digest preserved, thread routing + tests present, no live creds. RESIDUAL (actor-identity seam) routed to coder0 (round-6): (1) approval/stop still binds actorId = DISCORD_SERVICE_USER_ID (bot) — must be the RESOLVED APPROVING ADMIN's mosaic user id at both mint and consume (consume requires approval.actorId===action.actorId AND resolveRole(actorId)==='admin'; bot is not the approver); (2) stop consumes as service actor — must present the same approving-admin actorId bound at mint; (3) approve→stop tests share one fixture correlation, masking production separate-message flow — must model approval message + stop command as separate correlations resolving to the SAME target action 7-field identity (admin A approves T → stop consumes T once as A → replay rejected). Root-cause only (do not make the bot admin). FIFTH round on the seam (16951→16968→16973→16978→16986); reviewer holding actor boundary firm. coder0 remediating; re-ROR at new exact head after CI terminal green. ROUND-6 REMEDIATED + pushed: head 533e9702591436810160dfb1cf8a42a222cfb7c7 (branch tip confirmed), CI Woodpecker pipeline 1743 = SUCCESS (terminal green). Scope: stable target correlation + real RuntimeProviderService consume coverage; approval agent binding matches MOSAIC_AGENT_NAME with explicit mismatch denial; command-authorization.service.ts unchanged. ROUND-6 ROR routed to reviewer (non-author) at exact head 533e9702; prior round-5 REQUEST CHANGES 16986 void at old head. ROUND-6 RE-ROR = APPROVE — canonical VERIFIED APPROVE reviewer-of-record [W-jarvis:reviewer] head 533e9702591436810160dfb1cf8a42a222cfb7c7 (visible Gitea comment 16991, CI 1743 success). Reviewer verified: actorId mint+stop consume uses RESOLVED mosaic admin (not DISCORD_SERVICE_USER_ID, not bot-made-admin); RuntimeProviderService consumes via verifier adapter; separate ingress correlations map to stable target action + one-shot replay rejected; 7-field digest intact; thread-under-parent test present; no live creds. pr-review wrapper self-approve blocked → recorded as verified PR comment. Head confirmed UNCHANGED at ROR target 533e9702 + PR mergeable=true, base=main. SIX-round durable cross-surface exact-action approval seam CLOSED (16951→16968→16973→16978→16986→16991); writer re-routed coder4→coder0 (Mos-dispatched) mid-flight. #730 MERGEABLE (independent non-author ROR + green CI at exact head). HARD STOP — Mos owns merge. MERGED to main by Mos — squash commit 84d884b9, approved head 533e9702, branch feat/tess-discord deleted, independent Sonnet ROR APPROVE (16991). M3 BUILD PHASE CONVERGED (M3-001 #730 + M3-002 #731 both merged). Writer re-routed coder4→coder0 mid-flight (Mos-dispatched); coder0 confirmed stand-down cleanup (dropped superseded WIP stash), on clean main 84d884b9, idle. NOTE: M3-001 #730 scope was Discord binding + durable approval; TESS-M1-FUP-001 (execute() authz-error mapping) + TESS-M2-FUP-001/003 did NOT ride in this PR — they remain not-started follow-ups to schedule (flagged to Mos). TESS-M3-V (M3→M4 gate) now READY — Mos dispatching as Mos-owned independent Sonnet checkpoint against merged main; orchestrator tracks, no competing lane. | +| TESS-M3-002 | done | Implement `mosaic tess` chat/status/sessions/tree/attach/send/stop/health/recover CLI | #709 | diskhygiene-terra | packages/mosaic | feat/tess-cli | TESS-M2-V | 30K | TESS-CLI-001. Mos DIRECT-DISPATCHED to diskhygiene-terra on feat/tess-cli (base=main, 2026-07-13) — orchestrator TRACKS only, no competing lane. PR-open-STOP, independent non-author ROR then Mos merges. Intake initially blocked on wrapper (issue-view.sh bare positional → 'Unknown option: 709'); orchestrator relayed -i flag fix. PR #731 OPEN (feat(tess): add generic interaction CLI; base=main, mergeable=true, head de74e46a0640c44c6b7294c24669496980761c92, fresh origin/main base 99a2d0fc). Scope: generic mosaic interaction command chat/status/sessions/tree/attach/send/stop/health/recover (NO instance-specific literal); --agent/MOSAIC_AGENT_NAME + Nova name-as-config test; authenticated gateway durable/provider boundary — server-derived actor scope, required non-simple correlation header, identity binding, durable recovery, registry routing, exact-action approval stop; status/health safe. Terra: full cold-cache pnpm turbo typecheck/lint/test --force + gates green, Codex security clean after remediation. CI pipeline 1735 TERMINAL GREEN (success) on de74e46a. ROR = APPROVE — canonical VERIFIED APPROVE reviewer-of-record [W-jarvis:reviewer] head de74e46a0640c44c6b7294c24669496980761c92 (visible Gitea comment 16959). Head confirmed unchanged at ROR target + mergeable=true. Reviewer verified generic mosaic interaction CLI verbs, --agent/MOSAIC_AGENT_NAME name-as-config, server-derived actor scope + required correlation, durable identity/recovery path, registry routing, exact-action approval stop, no live creds; no merge by reviewer (self-approve wrapper blocked → recorded as verified PR comment). MERGED to main by Mos — merge_commit 8246ee01 (independent Sonnet ROR 16959, all criteria + name-as-config Nova test verified). TESS-M3-002 DONE. TESS-M3-V (M3→M4 gate) advances once M3-001 #730 also merges — Mos-owned reviewer, no competing lane from orchestrator. | +| TESS-M3-003 | done | Cross-surface durable session integration + functional attach + denial/audit parity | #709 | coder0 | apps/gateway, plugins/discord, packages/mosaic, packages/agent | feat/tess-m3-integration | TESS-M3-001,TESS-M3-002 | 30K | Mos-DISPATCHED to coder0 (base=main, 2026-07-13) after M3-V gate = FAIL (integration unwired). Scope: C1 wire DurableSessionCoordinator.create into session-start + Discord resolves via durable snapshot + cross-surface E2E (AC-TESS-01); C2 expose streamSession over HTTP+CLI for functional attach + success test; C4 CLI denial spec + Discord mint-side durable audit + FOLD TESS-M1-FUP-001 (execute() authz-error classification) + RuntimeApprovalDeniedError→403 filter. PR-open-STOP, independent non-author ROR then Mos merges. M3-V RE-GATES after this lands. C1 design question raised by coder0 (origin/main has NO runtime-provider session-start/create op — only AgentService/Pi chat createSession, an LLM conversation, not a RuntimeProvider session carrying providerId/runtimeSessionId) — RESOLVED by Mos synthesis ruling (2026-07-13): conversationId is the durable handle; DurableSessionCoordinator.create happens at RUNTIME ENROLLMENT once providerId/runtimeSessionId are known; BOTH surfaces (Discord + CLI) snapshot it. coder0 PROCEEDING: C2/C4 in parallel now + implementing the C1 enrollment boundary per the ruling. PR-open-STOP; orchestrator serializes CI + routes independent non-author ROR at exact head; Mos merges. UPDATE 2026-07-13: **PR #732 OPEN** (base=main), head 451f7e04ec6c45adb12007dd7131da6a2b199962. coder0 stopped at PR creation, holding (no force-push). Local forced validation green (typecheck/lint/format:check/test: gateway 590 pass/11 skip, mosaic 640 pass), Codex code+security clean, command-authorization.service.ts byte-identical to origin/main. CI pipeline 1745 running — orchestrator serializing; on green-at-exact-head, independent non-author ROR routes to reviewer; HARD STOP for Mos merge. UPDATE 2026-07-13 (CI-GREEN): pipeline 1745 (event=pull_request, refs/pull/732/head, exact head 451f7e04) = SUCCESS; PR mergeable=true, head unmoved. Independent non-author ROR ROUTED to reviewer at exact head 451f7e04 (author coder0). Awaiting ROR verdict; HARD STOP for Mos merge; M3-V re-gates on merge. UPDATE 2026-07-13 (ROR round 1 = REQUEST CHANGES @ 451f7e04, Gitea comment 17007, CI 1745 green): ONE real blocker — Discord approve path UNREACHABLE in production. ChatGateway.handleDiscordApproval accepts only bare '/approve' (^/approve\\s*$), but plugins/discord/src/index.ts routes to discord:approve only on startsWith('/approve ') — so bare /approve → normal message (dead), '/approve x' → gateway-rejected; AC-TESS-01 mint/cross-surface flow can't fire; tests bypass DiscordPlugin.handleDiscordMessage so they mask it (same integration-unwired class M3-V flagged). Positives HELD: command-authz byte-identical (hash a9f829e7), 7-field digest intact, actor=resolved admin (not DISCORD_SERVICE_USER_ID), durable/stream/denial/audit coverage present, no live creds. Root-cause routed to coder0: reconcile plugin-routing predicate ↔ gateway accept-grammar + add end-to-end test through real handleDiscordMessage (no bypass). Fix push MOVES head → invalidates ROR, re-serialize CI + re-ROR at new exact head. UPDATE 2026-07-13 (RESOLUTION — MERGED by Mos): before coder0's fix was pushed, Mos ran a combined M3-V re-review at the SAME head 451f7e04 ([W-jarvis:reviewer-sonnet], Gitea comment 17009 = VERIFIED APPROVE + M3-V GATE PASS) and MERGED #732 → main squash commit **0b621660** ("feat(tess): wire durable interaction surfaces"). Mos milestone signal: M3-001/002/003 done, M3-V PASS, M3 COMPLETE, advancing to M4. ⚠️ ORCHESTRATOR RECONCILIATION: the two independent ROR reads at 451f7e04 CONFLICT on one concrete mechanism — my reviewer (17007) flagged the Discord approve path unreachable; Mos's re-review (17009) validated the gateway handler + integration test but that test bypasses DiscordPlugin.handleDiscordMessage. Orchestrator VERIFIED against merged main 0b621660 (read-only git show/grep): the plugin-routing defect is STILL LIVE — see TESS-M3-FUP-005. Surfaced to Mos as a fast-follow; coder0's uncommitted fix held pending Mos disposition. | +| TESS-M3-V | pass | Discord+CLI same-session E2E, denial/approval tests, and operator-flow review | #709 | mos-sonnet | apps/gateway/src/__tests__/integration, plugins/discord, packages/mosaic/src | review/tess-m3 | TESS-M3-001,TESS-M3-002,TESS-M3-003 | 20K | Gate M4. FIRST RUN = FAIL (Mos-owned independent Sonnet checkpoint against merged main 84d884b9, 2026-07-13): integration UNWIRED — Discord/CLI not cross-surface functional against durable sessions; attach/denial/audit parity incomplete. Remediation = TESS-M3-003 (coder0). SECOND RUN = **PASS** (Mos-owned combined re-review+re-gate at head 451f7e04, [W-jarvis:reviewer-sonnet] Gitea comment 17009, 2026-07-13): C1 cross-surface durable session, C2 functional attach, C4 denial/audit parity all closed; hard constraint (command-authz byte-identical, 7-field digest untouched) intact; C3/C5 no regression. Mos MERGED #732 → main 0b621660 and declared M3 milestone COMPLETE, advancing to M4. ⚠️ CAVEAT (orchestrator-verified live defect, NON-GATING per Mos): the M3-V integration test does not exercise DiscordPlugin.handleDiscordMessage, so the production Discord approve-path routing defect survived the gate — logged as TESS-M3-FUP-005 (fast-follow, surfaced to Mos). M4 (#710) UNBLOCKED (dep TESS-M3-V met). | +| TESS-M3-FUP-005 | done | Discord approve/stop production routing predicate mismatch — approve path unreachable | #709 | coder0 | plugins/discord, apps/gateway | feat/tess-m3-integration (rebased onto 0b621660) | TESS-M3-003 | 8K | **MERGED by Mos** → main squash commit **f1c6b37b** ("fix(tess): route bare Discord approvals (#733)"), 2026-07-13; post-merge main CI pipeline 1750 = SUCCESS. Verified-live Discord approve-path defect CLOSED — merged cross-surface mint path now functional end-to-end. HISTORY: **PR #733 OPEN** (base=main), head ed1d985c90e24ca3046bd570a556802927d80ed8 (rebased onto merged main 0b621660 to clear a Gitea conflict, force-with-lease; supersedes pre-rebase a02f526d whose pipeline 1747 was killed). pr-diff confirms clean 2-file scope: plugins/discord/src/index.ts + tess-cross-surface.integration.test.ts. coder0: bare /approve now routes to discord:approve; E2E invokes the REAL DiscordPlugin.handleDiscordMessage proving approve→stop against the durable conversation handle; forced typecheck/lint/format + targeted 17/17 green; command-authz zero-diff from main. CI pipeline 1748 (pull_request, refs/pull/733/head, commit ed1d985c) = **SUCCESS**. Independent non-author ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head ed1d985c (Gitea comment 17017) — command-authz byte-identical hash a9f829e7 + 7-field digest intact, bare /approve REAL DiscordPlugin route fixed, gateway uses durable getSnapshot(conversationId), anti-masking divergent test inputs present, no Tess literal, no live creds. Head UNMOVED (ed1d985c), base main, mergeable=true. **MERGEABLE — HARD STOP for Mos merge** (2026-07-13). On merge → FUP-005 done; makes the merged Discord approve surface functional end-to-end. | ⚠️ VERIFIED-LIVE DEFECT on merged main 0b621660 (orchestrator read-only git show/grep, 2026-07-13). Gateway apps/gateway/src/chat/chat.gateway.ts:670 accepts approval ONLY as bare '/approve' (regex /^\\/approve\\s*$/i). Plugin plugins/discord/src/index.ts trims content (:339-341) then routes to discord:approve ONLY when content.startsWith('/approve ') (:385, requires a space+arg). NET: trimmed bare '/approve' fails the plugin predicate → emitted as normal message → gateway approval handler never fires (DEAD); '/approve x' passes the plugin but the gateway regex rejects it. So the production Discord approve path (AC-TESS-01 cross-surface mint) is UNREACHABLE end-to-end. M3-V PASSED because its integration test drives the gateway handler directly and bypasses DiscordPlugin.handleDiscordMessage, so the plugin predicate was never exercised (my reviewer 17007 caught it; Mos re-review 17009 validated the handler+test, not the plugin predicate). '/stop' likely same class (plugin needs startsWith('/stop ') args; confirm against gateway stop grammar). FIX: reconcile plugin routing predicate ↔ gateway accept-grammar (route bare '/approve' and snapshot-resolved '/stop ') + add an E2E test through the REAL DiscordPlugin.handleDiscordMessage (no bypass) proving mint fires. coder0 HAS this fix uncommitted in its worktree (from the round-1 remediation, pre-empted by the merge) — needs a fresh branch off main 0b621660 as a follow-up PR. AWAITING Mos disposition (fast-follow now vs after M4). Standard gates: PR-open-STOP, independent non-author ROR at exact head, Mos merges. | +| TESS-M4-001 | done | Implement Mos coordination handoff/observe/result contract with authority-boundary tests | #710 | coder0 | packages/coord, apps/gateway | feat/tess-mos-coordination | TESS-M3-V | 25K | **MERGED by Mos** → main squash **76325ca3** ("feat(tess): add Mos coordination boundary (#735)"), 2026-07-13 — merge = native-in-process transport ACCEPTED (contract transport-neutral). TESS-MOS-001. Mos-DISPATCHED 2026-07-13 to coder0 DESIGN-FIRST. UPDATE 2026-07-13: coder0 wrote docs/tess/MOS-COORDINATION.md; design checkpoint surfaced to Mos with the transport-adapter question (existing fleet/tmux Mos-authority channel vs dedicated native queue/HTTP). coder0 PROCEEDED (ahead of the Mos transport ruling) choosing a **native in-process adapter** and opened **PR #735** (base=main), head 7936e15d3ae137c91c88efdab4bb09b863a2195d. Impl: transport-NEUTRAL handoff/observe/result contract (MosCoordinationPort); deterministic native in-process InMemoryMosCoordinationPort; gateway derives actor/tenant/requester from trusted context/config; fail-closed for unconfigured-requester, self-delegation, target-drift, cross-tenant observe/result; NO public orchestrator verbs; **NO fleet/tmux transport, NO Mos-side consumer**; command-authorization byte-identical hash a9f829e7; no live creds; no hardcoded Tess identity. Local forced cold-cache typecheck/lint/format/test green (42 tasks); Codex security no findings. CI pipeline 1752 (pull_request, refs/pull/735/head, commit 7936e15d) = **SUCCESS**; head UNMOVED, mergeable=true. Independent non-author ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head 7936e15d (Gitea comment 17032) — verified MosCoordinationPort=handoff/observe/result only, gateway-derived authority, fail-closed denial coverage, native in-process port (no tmux/Mos consumer), command-authz byte-identical a9f829e7, no live creds, no Tess literal. ⚠️ HEAD MOVED 2026-07-13 (ROR 17032 INVALIDATED): coder0 pushed one post-ROR commit → new head **5022911f84dd7ac30f40df31a53f6cd31a51728f** (commit "docs(tess): record M4 verification", parent 7936e15d). Orchestrator-verified sole delta = a single scratchpad doc docs/scratchpads/tess-m4-001-mos-coordination.md, ZERO code/test diff. New CI pipeline 1754 (pull_request, refs/pull/735/head, commit 5022911f) = **SUCCESS**; mergeable=true, head now 5022911f. Comment 17032 @ 7936e15d no longer at exact head → re-serialize + re-ROR REQUIRED. Fast delta RE-ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head 5022911f (Gitea comment 17036) — confirmed 5022911f is direct child of prior-reviewed 7936e15d, sole two-dot delta = the 3-line scratchpad doc, no code/test diff, command-authz byte-identical a9f829e7, CI 1754 success. Head UNMOVED (5022911f), mergeable=true. **MERGEABLE at 5022911f — HARD STOP for Mos merge** (2026-07-13). MERGE = Mos ACCEPTING the native-in-process transport choice (contract stays transport-neutral; a fleet/tmux or native-queue/HTTP consumer can be added later without contract churn); if Mos wants a different FIRST adapter, hold merge + route rework to coder0. | +| TESS-M4-002 | done | Implement transitional Hermes runtime/capability adapter | #710 | coder3 | packages/agent, apps/gateway | feat/tess-hermes-adapter | TESS-M3-V | 40K | **MERGED by Mos** → main squash **9e5b9188** ("feat(agent): add transitional Hermes runtime adapter (#734)"), 2026-07-13 — Mos merge = **option (a) ACCEPTED**; post-merge main CI 1753. TESS-HRM-001; no legacy schema in core contracts. Mos-DISPATCHED 2026-07-13 to coder3 DESIGN-FIRST (contract sketch + questions to Mos before build). Goes in-progress as PR opens; PR-open-STOP → serialize CI + independent non-author ROR at exact head → Mos merges. UPDATE 2026-07-13: coder3 ACTIVE — fresh worktree/branch feat/tess-hermes-adapter off origin/main; boundary sketch at docs/tess/hermes-runtime-adapter-design.md. DESIGN QUESTION surfaced to Mos (coder3 HELD at design-only until ruling): AC-TESS-05 wants approved capability across Kanban/skills/memory/tools/cron, but AgentRuntimeProvider models only SESSION capabilities. (a) adapter-local Hermes inventory/health marks those as explicit UNSUPPORTED, real ops deferred to their Mosaic-owned plugin contracts (coder3 default, preserves hard no-legacy-core-contract rule); vs (b) an existing Mosaic-owned non-runtime capability contract this adapter must implement. Orchestrator recommends (a) to Mos as the conservative boundary-preserving path. UPDATE 2026-07-13: coder3 PROCEEDED WITH (a) and opened **PR #734** (base=main), head 47b8a145ac43688499d275a54b434a52551c1abd — ahead of the Mos (a/b) ruling (design-hold was placed; coder3's original msg said it would proceed with (a) unless directed). Hermes adapter normalized behind packages/agent boundary; core types unchanged, unsupported ops fail-closed, tests prove no legacy field leak; focused tests/typecheck/lint pass; cold-cache turbo typecheck+build 46/46 0-cached. mergeable=true. CI pipeline 1751 (pull_request, refs/pull/734/head, commit 47b8a145) = **SUCCESS**; head UNMOVED, mergeable=true. Independent non-author ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head 47b8a145 (Gitea comment 17027) — verified core AgentRuntimeProvider/runtime types UNCHANGED, adapter normalizes Hermes legacy shapes behind packages/agent boundary, unsupported runtime ops FAIL CLOSED via capability_unsupported BEFORE transport side effects (Kanban/skills/memory/tools/cron deferred under option (a)), no live creds, no hardcoded Tess agent identifier. Head UNMOVED, mergeable=true. **MERGEABLE — HARD STOP for Mos merge** (2026-07-13). ⚠️ MERGE GATED on Mos confirming option (a) is accepted (implementation == (a)); if Mos rules (b), #734 needs rework. HARD STOP for Mos merge. | +| TESS-M4-003 | done | Implement memory/retrieval, state/inbox, runtime bootstrap, fleet diagnostics and GitOps plugin foundations | #710 | coder0 | packages/memory, packages/agent, packages/mosaic | feat/tess-operator-plugins | TESS-M3-V | 40K | **MERGED by Mos** → main squash **2363f155** ("feat(memory): add operator retrieval plugin (#736)"), 2026-07-13. ⚠️ SCOPE GAP surfaced by Mos: #736 delivered ONLY the leaf @mosaicstack/memory operator-retrieval slice; **TESS-PLG-001 (packages/mosaic catalog/registration) was silently DEFERRED by the author and never surfaced in MISSION-MANIFEST/VERIFICATION-MATRIX** → now tracked explicitly as its own row (see TESS-PLG-001 below) and folded into TESS-M4-W-001. State/inbox/runtime-bootstrap/fleet-diagnostics/GitOps foundations remain follow-on (not in #736). TESS-MEM-001, TESS-PLG-001. Mos HELD 1 beat (2026-07-13) for a well-conditioned lane. UPDATE 2026-07-13: coder0 TOOK OVER M4-003 (preserved coder4 WIP first, then rebased on latest main) and opened **PR #736** (base=main), head a1d63ca8ed07610828e9c51a213fffe9123b3de4. ⚠️ AUTHORIZATION FLAG to Mos: M4-003 was on Mos 1-beat HOLD; confirm this takeover/dispatch was Mos-authorized before merge. Scope delivered: LEAF @mosaicstack/memory operator retrieval plugin — config-injected adapter/namespace, runtime-validated server-derived tenant/owner/session scope, redaction-before-persist, provenance, bounded startup prioritization, wildcard adapter contract, namespace/different-instance tests. NO gateway/catalog/durable-inbox or command-authorization changes. Forced cold-cache typecheck/lint/format/test green (42 tasks); Woodpecker 1756 green; Codex code+security clean. CI pipeline 1756 (pull_request, refs/pull/736/head, commit a1d63ca8) = **SUCCESS**; mergeable=true. Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head a1d63ca8 (Gitea comment 17044) — leaf packages/memory/doc only (no gateway/catalog/durable-inbox), command-authz byte-identical a9f829e7, runtime scope validation before storage keying, config-injected adapter/namespace/instance metadata, redaction-before-persist + provenance, scoped wildcard adapter contract, namespace/different-instance tests, no live creds, no Tess literal. Head verified UNMOVED at a1d63ca8, base main, mergeable=true. **MERGEABLE — reported to Mos, HARD STOP for Mos merge.** NOTE: M4-003 scope here is the memory-plugin slice; state/inbox/runtime-bootstrap/fleet-diagnostics/GitOps foundations may be follow-on slices — confirm with Mos whether #736 fully closes M4-003 or is slice 1. | +| TESS-M4-W-001 | in-progress | M4-V remediation — gateway reachability SPINE: register runtime provider into AGENT_RUNTIME_PROVIDER_REGISTRY + wire Mos-coordination consumer + wire operator-memory-plugin consumer (make merged M4 deliverables reachable end-to-end); FOLDS IN minimal TESS-PLG-001 catalog/registration | #710 | coder0 | apps/gateway, packages/mosaic, packages/agent | feat/tess-m4w-reachability-spine | TESS-M4-003 | 30K | **Mos-DISPATCHED 2026-07-13** (remediation). Root cause: M4-V holistic review @ origin/main **2363f155** found the three merged M4 deliverables unit-green but NOT reachable end-to-end (no gateway wiring/consumers; providers never registered into the registry). **SPLIT into 3 sub-parts by coder0 (integrity-honest):** **(#2 Mos-coordination consumer) = DELIVERED as PR #737** (head f7b95f60, base main, "feat(gateway): expose Mos coordination boundary") — real AuthGuard Mos handoff/observe/result consumer, authenticated actor/tenant + required correlation derivation, service authority unchanged, gateway target test/typecheck/lint pass; **CI 1758 SUCCESS** (repo 47, commit==head); head verified UNMOVED at f7b95f60666a4abbbad9a08669637b19fa87c430, base main, mergeable=true. **Independent non-author ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head f7b95f60 (Gitea comment 17054) — clean partial scope confirmed (AuthGuard Mos handoff/observe/result controller + module registration only; no runtime-provider registration / operator-memory consumer; actor/tenant from CurrentUser/scopeFromUser + required X-Correlation-Id before service invocation; MosCoordinationService unchanged; command-authz byte-identical a9f829e7; no live creds/no Tess literal). **#737 MERGEABLE — reported to Mos, HARD STOP for Mos merge (partial slice; land-vs-hold-for-full-spine is Mos's disposition call).** **(#1 runtime-provider registration) + (operator-memory consumer) = BLOCKED, NOT in #737.** coder0 could not truthfully complete them in this slice and REFUSED to fake with deny/unavailable stubs: gateway has **no concrete Hermes transport** and **no gateway-side tmux transport/authority wiring** to register a real provider; OperatorMemory consumer needs **session tenant/owner/session propagation currently ABSENT from AgentService's memory-tools boundary**. ⚠️ **DESIGN RULING ESCALATED TO MOS** (architecture, not resolvable from repo): how to wire provider-registration + memory-scope propagation when no concrete transport exists yet — new remediation slice / re-scope / accept #737 as incremental. TESS-PLG-001 (folded here) is part of the blocked #1 registration path. Command-authz byte-identical a9f829e7. **UPDATE 2026-07-13: #737 MERGED by Mos → main e2376190 ("feat(gateway): expose Mos coordination boundary (#737)").** **Operator-memory consumer sub-part UNBLOCKED + DELIVERED as PR #739** ("feat(memory): bind operator plugin to agent sessions", base main off e2376190, live head 31a59738089f0784428833fc5a0192c6c7c43261, mergeable=true) — coder0 resolved the session-scope-propagation blocker WITHOUT stubbing: gateway bootstrap configures plugin only with MOSAIC_OPERATOR_MEMORY_INSTANCE_ID + MOSAIC_OPERATOR_MEMORY_NAMESPACE, AgentService derives {tenantId,ownerId,sessionId} server-side and binds search/capture tools. Cold-cache root typecheck/lint/format/test (42 tasks) green; security review clean (Codex Optional-import finding = false positive, pre-existing, typecheck passed). **CI 1764 SUCCESS** (repo 47, commit==head); head verified UNMOVED at 31a59738089f0784428833fc5a0192c6c7c43261, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 31a59738089f** (coder0 authored → reviewer is non-author) — asked reviewer to confirm scope is server-derived/non-client-controllable + no cross-tenant leak, and to independently verify the Codex Optional-import finding is a false positive. (Head reconcile CLOSED: coder0 confirmed 31a597380c55… was a transcription typo; live+frozen head is 31a59738089f0784428833fc5a0192c6c7c43261, working tree clean.) **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 31a59738089f (Gitea comment 17069) — NOT mergeable.** Production code CONFIRMED correct (plugin route wired, config env namespace/instance only, no live creds/no Tess literal, command-authz byte-identical a9f829e7; Codex Optional-import finding = false positive, import present). **Two TEST-COVERAGE blockers:** (1) tests BYPASS production scope derivation — they call createMemoryTools with a PREBUILT scope, never exercising the real createSession→buildToolsForSandbox server-side {tenantId,ownerId,sessionId} derivation; (2) NO divergent cross-tenant/cross-owner ISOLATION/DENIAL test proving a foreign actor cannot reuse a session / reach another operator-memory scope before the plugin call. Routed back to coder0 (integrity: harden real coverage, do NOT weaken assertion). Any new commit MOVES head → invalidates ROR → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed hardened tests, NEW frozen head c26b3b775279575276c6ebe8955a9146bfc61413** — added createSession→buildToolsForSandbox PRODUCTION-PATH assertion of derived {tenantId,ownerId,sessionId}; added foreign-actor reuse DENIAL test asserting rejection occurs BEFORE scope/tool construction and before any plugin call. Cold-cache root typecheck/lint/format/test green (42 tasks). Old ROR at 31a59738089f + CI 1764 SUPERSEDED. Re-serialized: **CI 1765 SUCCESS** at c26b3b775279 (ref refs/pull/739/head, commit==head); head verified UNMOVED at c26b3b775279575276c6ebe8955a9146bfc61413, base main, mergeable=true. **Fresh independent non-author ROR RE-ROUTED to reviewer at exact head c26b3b775279** — asked reviewer to confirm both 17069 blockers genuinely closed (prod-path derivation exercised + cross-tenant denial before plugin call, assertion not weakened). **Independent non-author re-ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head c26b3b775279 (Gitea comment 17074) — both 17069 blockers CONFIRMED closed: production createSession→buildToolsForSandbox scope-derivation test asserts {tenantId,ownerId,sessionId}; foreign-scope reuse rejects BEFORE tool construction and BEFORE plugin search/capture; production wiring reachable via MemoryModule env-configured plugin → AgentService injection → memory_search/memory_save_insight plugin path; command-authz byte-identical a9f829e7; Optional import present; no live creds/no Tess literal. Head verified UNMOVED at c26b3b775279, base main, mergeable=true. **#739 MERGEABLE — reported to Mos, HARD STOP for Mos merge.** This lands the operator-memory-consumer sub-part of W-001; REMAINING W-001 gap = only (#1) runtime-provider registration. **REMAINING blocked: (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY** — still needs Mos A/B/C design ruling (no concrete Hermes transport yet). So after #739 lands, W-001 = Mos-consumer (#737 merged) + memory-consumer (#739) DONE; only the provider-registration linchpin remains. **UPDATE 2026-07-13: #739 MERGED by Mos → main squash 3378b857eb ("feat(memory): bind operator plugin to agent sessions (#739)"); post-merge main push pipeline 1766 running. W-001 spine now 2-of-3 sub-parts MERGED (Mos-consumer #737 e2376190 + operator-memory-consumer #739 3378b857); ONLY remaining W-001 gap = (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY — still BLOCKED on Mos A/B/C design ruling (no concrete Hermes transport; TESS-PLG-001 folded here). coder0 idle/ready to build #1 on ruling.** **UPDATE 2026-07-13: (#1) DELIVERED as PR #740 "feat(gateway): register Hermes runtime provider"** (base main 3378b857, exact live head 127a69ea11ccc36516c78c2007cbe52fbf63ad30 verified unmoved, mergeable=true). coder0 resolved the A/B/C escalation by BUILDING a concrete transport (⚠️ design-direction flagged to Mos for confirm-before-merge): agent.module.ts explicit registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport())); GatewayHermesRuntimeTransport = server-configured URL+service token, HTTPS-except-loopback, prefixed-URL preserving, forwards full scope incl channel; AuthGuard interaction transitional-capabilities route through RuntimeProviderService + live controller→service→registered-provider reachability test. Cold-cache typecheck/lint/format/test green (42 tasks); Codex path-prefix+channel-header findings remediated, security clean. **CI pipeline 1767 (repo 47, refs/pull/740/head, commit==head) = SUCCESS**; head verified UNMOVED at 127a69ea11ccc36516c78c2007cbe52fbf63ad30 post-CI, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 127a69ea11cc** (coder0 authored → reviewer non-author) — asked reviewer to verify REAL E2E reachability (provider actually in registry + reachability test exercises registered provider, not mock), transport security (HTTPS-except-loopback, no token leak), command-authz byte-identical a9f829e7, no live creds/no Tess literal, Codex findings genuinely remediated. Awaiting reviewer disposition; any new commit moves head → re-serialize + re-ROR. **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 127a69ea11cc (Gitea comment 17088) — NOT mergeable.** CI 1767 green; command-authz byte-identical a9f829e7 CONFIRMED; production positives CONFIRMED (module factory registers Hermes provider; concrete transport HTTPS/prefix/channel headers; no live creds/no Tess literal). **Blocker (reachability-integrity):** the required live-guarded reachability proof is MISSING — test directly calls controller.transitionalCapabilities + manually constructs RuntimeProviderService/createGatewayRuntimeProviderRegistry; it does NOT exercise live GET /api/interaction/:agentName/transitional-capabilities, Nest DI through AgentModule, or the AuthGuard request path, so it can pass even if injected gateway registry/route wiring is broken (defeats the M4-V E2E-reachability point). Routed back to coder0 (integrity: add genuine Nest-e2e live-guarded reachability test through real DI+route+AuthGuard asserting reach of the registered Hermes provider; do NOT weaken/stub/mock around it; keep command-authz a9f829e7). Old ROR 17088 + CI 1767 will be SUPERSEDED by the remediation head → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed the live-guarded reachability test, NEW frozen head a7e5d377e38b40275884a7df6ee35c55c5859e43** (live Gitea head independently verified, base main 3378b857, mergeable=true) — added apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts: imports REAL AgentModule (preserves actual AGENT_RUNTIME_PROVIDER_REGISTRY factory + RuntimeProviderService), boots Fastify/Nest, unauth HTTP GET /api/interaction/Nova/transitional-capabilities?provider=runtime.hermes asserts 401 via ACTUAL AuthGuard, authed GET asserts 200 + all five Hermes entries, asserts DI registry resolves HermesRuntimeProvider; only unrelated peripheral modules harness-replaced to avoid DB/queue startup — NO route/guard/DI-registry/runtime-service/provider mock; existing controller unit test retained; command-authz untouched (byte-identical a9f829e7 remains). Cold-cache root typecheck/lint/format/test green 42/42 (gateway 53 files/606 tests). Old ROR 17088 + CI 1767 SUPERSEDED. Re-serializing: **CI 1768 (repo 47, refs/pull/740/head, commit==head a7e5d377) running** — poll in flight; on green → re-route non-author ROR at exact head a7e5d377. **UPDATE 2026-07-13: CI 1768 SETTLED SUCCESS** (repo 47, refs/pull/740/head, commit==head a7e5d377e38b40275884a7df6ee35c55c5859e43); head independently verified UNMOVED at a7e5d377 (live Gitea, NOT worker-reported), base main 3378b857, mergeable=true. **Independent non-author re-ROR COMPLETE: reviewer VERIFIED APPROVE at exact head a7e5d377e38b40275884a7df6ee35c55c5859e43 (Gitea comment 17094).** Prior 17088 blocker CONFIRMED closed — the new hermes-runtime-reachability.e2e.test.ts boots real Nest/Fastify AgentModule and exercises unauth 401 via the ACTUAL AuthGuard + authed HTTP GET /api/interaction/:agentName/transitional-capabilities through the live route→controller→RuntimeProviderService→registered Hermes provider, and asserts DI registry resolves HermesRuntimeProvider (no route/guard/DI/service/provider mock); transport concrete, HTTPS-except-loopback, path-prefix + channel header covered; command-authz byte-identical a9f829e7 CONFIRMED; no live creds/no Tess literal. Head verified UNMOVED at a7e5d377, base main, mergeable=true. **#740 MERGEABLE — the (#1) runtime-provider-registration linchpin — reported to Mos, HARD STOP for Mos merge.** ⚠️ Design-direction (concrete GatewayHermesRuntimeTransport built to resolve the A/B/C escalation) flagged to Mos for confirm-before-merge. On #740 merge, W-001 spine = 3-of-3 sub-parts landed (Mos-consumer #737 + memory-consumer #739 + provider-registration #740) → M4-V re-fire eligible. **UPDATE 2026-07-13: #740 MERGED by Mos → main b7b0f508 ("feat(gateway): register Hermes runtime provider (#740)"). W-001 SPINE NOW 3-OF-3 MERGED (Mos-consumer #737 e2376190 + operator-memory-consumer #739 3378b857 + provider-registration linchpin #740 b7b0f508) — the M4-V reachability remediation code work is COMPLETE. GATE: TESS-M4-V re-fire is now eligible and Mos-owned — this row stays in-progress until M4-V re-fires green (unit-green was never the bar; end-to-end reachability is). ⚠️ main push pipeline 1772 (for the #740 merge to main) FAILED at the `build` step — quality gates (typecheck/lint/format/test) all GREEN, failure is downstream at build/publish (recurring infra/ENOSPC pattern); flagged to Mos as Mos-owned, does not block docs-only PRs. Prior doc-sync ledger PR #741 MERGED → main f40e6ba3 ("docs(tess): sync M4 tracking to merged reality (M4 in-progress / gate-pending)"); ledger writes resumed on fresh branch docs/tess-ledger-sync-m2 off f40e6ba3. **UPDATE 2026-07-13: consolidated ledger-sync PR #743 (branch docs/tess-ledger-sync-m2, head aa3925510d06, reviewer VERIFIED APPROVE 17123, CI 1775 SUCCESS) MERGED by Mos → main c6e3cfbdf... ; ledger writes resumed on fresh branch docs/tess-ledger-sync-m3 off main 6345dbfc (post-#744 merge). **UPDATE 2026-07-13: ledger-sync m3 PR #745 MERGED by Mos → main e72388b2 ("docs(tess): ledger sync m3 — M5-001 + M5-002 done (#745)"); mission issue #706 preserved OPEN (Refs #706 non-closing). Ledger writes resumed on fresh branch docs/tess-ledger-sync-m4 off main bc8016c8 (post-#746 merge) recording M5-003 done.** | +| TESS-M4-W-002 | done | M4-V remediation — Hermes capability MATRIX (AC-TESS-05): approved-capability coverage across Kanban/skills/memory/tools/cron for the Hermes adapter | #710 | coder3 | packages/agent, apps/gateway | feat/tess-m4w-hermes-matrix | TESS-M4-002 | 22K | **Mos-DISPATCHED 2026-07-13** (remediation, in flight). Extends the M4-002 option-(a) adapter (merged 9e5b9188) with the AC-TESS-05 capability matrix. UPDATE 2026-07-13: coder3 STARTED — fresh worktree off origin/main 2363f155, TDD failing-matrix-tests-first. Orchestrator TRACKS; on PR-open → serialize CI + independent non-author ROR at EXACT head → HARD STOP for Mos merge. No legacy schema into core contracts; command-authz byte-identical a9f829e7. UPDATE 2026-07-13: **PR #738 OPENED** (base main, head 582c6db2088223fd8dd2105005391b5034c992ac, "feat(agent): add Hermes transitional capability matrix") — normalized exhaustive five-entry matrix (kanban/skills/memory/tools/cron), all explicit unsupported, fails CLOSED before transport; tests 4/4, security review clean, cold-cache 46 successful/0 cached, normalized optional TransitionalCapabilityInventoryProvider (no legacy schema). **CI 1759 SUCCESS** (repo 47, commit==head); head verified UNMOVED at 582c6db2, base main, mergeable=true. **Independent non-author ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head 582c6db2 (Gitea comment 17057) — normalized exhaustive five-entry transitional matrix (kanban/skills/memory/tools/cron) all unsupported; assertTransitionalCapability fails CLOSED with capability_unsupported before Hermes transport; only normalized optional TransitionalCapabilityInventoryProvider added to core (no legacy schema leak); command-authz byte-identical a9f829e7; no live creds/no Tess literal. Head verified UNMOVED at 582c6db2, base main, mergeable=true. **#738 MERGEABLE — reported to Mos, HARD STOP for Mos merge.** This is the COMPLETE matrix deliverable (unlike #737's partial spine). **UPDATE 2026-07-13: #738 MERGED by Mos → merge_commit cca6aaf9. TESS-M4-W-002 DONE.** | +| TESS-PLG-001 | in-progress | packages/mosaic plugin catalog / registration (operator plugins registered + discoverable) — was silently deferred by M4-003 author; now VISIBLE | #710 | coder0 | packages/mosaic | feat/tess-m4w-reachability-spine | TESS-M4-003 | (folded) | ⚠️ Surfaced by Mos 2026-07-13 as an invisible gap: M4-003/#736 delivered the memory plugin but NOT its catalog/registration in packages/mosaic; never appeared in MISSION-MANIFEST/VERIFICATION-MATRIX. PLACEMENT DECISION (orchestrator, per Mos "your call"): **FOLD minimal registration into TESS-M4-W-001** (coder0's reachability spine already does registry wiring — same author closes their own gap, keeps it in one lane). This row exists for LEDGER VISIBILITY so the gap is tracked, not re-hidden. If M4-W-001 scope grows too large, split back out as a standalone lane. Manifest/matrix update to follow. | +| TESS-M4-V | failed | Cross-provider capability, privacy, authority and failure-path qualification | #710 | sonnet | apps/gateway/src/__tests__/integration, packages/agent | review/tess-m4 | TESS-M4-001,TESS-M4-002,TESS-M4-003,TESS-M4-W-001,TESS-M4-W-002 | 22K | **FAILED 2026-07-13** — independent holistic review @ origin/main **2363f155**: all three M4 deliverables (#734/#735/#736) unit-green but **NOT reachable end-to-end** (providers never registered into AGENT_RUNTIME_PROVIDER_REGISTRY; Mos-coordination + operator-memory consumers unwired; TESS-PLG-001 catalog/registration silently deferred). Remediation TESS-M4-W (W-001 spine coder0 + W-002 Hermes matrix coder3) now in flight. **Mos re-fires M4-V ONLY after the spine + matrix land.** Gate M5 (M5 stays behind M4-V; live-deploy = Jason-reserved). | +| TESS-M5-001 | done | Implement Matrix/native runtime provider behind common contracts and parity suite | #711 | coder0 | packages/mosaic, packages/agent | feat/tess-matrix-provider | TESS-M4-V | 30K | TESS-TRN-001. **Mos-DISPATCHED to coder0 2026-07-13** (advancing to M5, same M4-V dependency-reconciliation caveat as M5-002). Design sketch (branch feat/tess-matrix-provider off origin/main b7b0f508): MatrixNativeRuntimeProvider in packages/agent over a narrow MatrixRuntimeTransport contract + MatrixNativeRuntimeTransport in packages/mosaic (Mosaic adapter owns Matrix HTTP/auth/identity/room mechanics; agent provider owns common provider behavior only). Parity suite runs the SAME provider-contract scenarios against factory fixtures for existing tmux/fleet AND Matrix/native; Matrix native declares only operations concretely wired (no fake reachability, no Matrix default promotion); no gateway/Discord changes; command-authz to remain byte-identical a9f829e7. **In TDD — no PR yet.** On PR-open: freeze head → serialize CI (one-at-a-time on repo 47) → independent non-author ROR at exact head → HARD STOP for Mos merge. **UPDATE 2026-07-13: DELIVERED as PR #744 (7 files packages/agent + packages/mosaic only) frozen head b4fcf139a73678e9e59c8f6b63c108c095a87b3a; CI pipeline 1776 SUCCESS (repo 47, refs/pull/744/head, commit==head); independent non-author ROR COMPLETE — reviewer VERIFIED APPROVE at exact head b4fcf139a736 (Gitea comment 17126): Matrix stays NON-DEFAULT (no gateway/Discord/registry wiring), default-deny read/write authority, immutable read handles, control-attach rejection, parity suite runs same scenarios against tmux/fleet AND Matrix/native, concrete Matrix CS-API HTTPS transport with whoami/remote-identity-filter/deterministic txns, no live creds, command-authz byte-identical a9f829e7. #744 MERGED by Mos → main 6345dbfcf262. Deliverable code LANDED. GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending (this row was dispatched ahead of M4-V passing).** | +| TESS-M5-002 | done | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | coder3 | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001. **Mos-DISPATCHED to coder3 2026-07-13** ("M4 complete; advancing to M5") — dispatched AHEAD of TESS-M4-V passing; the M4-V-status-vs-#710-CLOSED dependency reconciliation is pending Mos ruling (tracked, not orchestrator-decided). **DELIVERED as PR #742** — 4 new files docs/tess/M5-MIGRATION-{INVENTORY,CUTOVER,ROLLBACK,RETENTION-DEPRECATION}.md, base main b7b0f508, frozen head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd. Docs-only; tracking-control trio (MISSION-MANIFEST/TASKS/VERIFICATION-MATRIX) UNTOUCHED; command-authz byte-identical a9f829e7; no live creds. **CI pipeline 1773 SUCCESS** (repo 47, refs/pull/742/head, commit==head). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd (Gitea comment 17108)** — evidence claims verified to trace to landed Hermes adapter / capability matrix, gateway registry/reachability, operator-memory scope path, Mos coordination boundary; docs do NOT over-claim transcript/profile import, schema migration, unsupported-capability enablement, production cutover, or deprecation completion. Head independently verified UNMOVED at b5e9d0e528a5 post-ROR (live Gitea), base main, mergeable=true. **#742 MERGED by Mos → main 5789711e. Deliverable docs LANDED. GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending (this row was dispatched ahead of M4-V passing).** | +| TESS-M5-003 | done | Complete OpenAPI, user/admin/developer/plugin/operations docs and checklist | #711 | codex | docs | feat/tess-docs | TESS-M5-001,TESS-M5-002 | 22K | Documentation hard gate. **DELIVERED as PR #746** (branch feat/tess-docs, base main, 7 docs-only files: docs/openapi-tess.yaml + docs/tess/{ADMIN,DEVELOPER,OPERATIONS,PLUGIN,USER}-GUIDE.md + M5-003-DOCUMENTATION-CHECKLIST.md). 4-round revise-loop (heads 470eb911→c9f69300→7aea94e2→25b9d642) converged: OpenAPI covers interaction routes + SSE /sessions/{sessionId}/stream + Mos coord (/api/coord/mos/handoff,/observe,/result) + memory preferences/insights/search; request-body schemas aligned to real DTOs (Send requires content+idempotencyKey, Stop requires approvalRef, Insight requires only content, MosHandoff body requires idempotencyKey+summary); checklist accurate. **CI pipeline 1786 SUCCESS** (repo 47, refs/pull/746/head, commit==head 25b9d642). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head 25b9d642b939014b3efd61826e7524cafc6ffc2e (Gitea comment 17170)** — docs-only, tracking-trio untouched, command-authz byte-identical a9f829e7, no false coverage claims. Head verified UNMOVED at 25b9d642 (live Gitea, not worker-reported), base main, mergeable=true. **#746 MERGED by Mos → main bc8016c8314ec3a4b6ebc2fec5d9f276fca3327a. Documentation gate LANDED.** GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending. | +| TESS-M5-V | not-started | Full baseline, contract, integration, Discord/CLI E2E, security review, recovery drill and rollback qualification | #711 | sonnet | apps/gateway, packages/agent, plugins/discord, packages/mosaic | review/tess-final | TESS-M5-003 | 35K | Maps AC-TESS-01..11 to evidence | +| MOS-PORT-M1-001 | in-progress | Implement logical Mos identity, PostgreSQL connector lease, monotonic fencing, server-bound execution grants, audit, migrations, concurrency/restart/abuse/integration tests | #755 | codex | packages/types, packages/agent, packages/db, apps/gateway | feat/mos-logical-identity-fencing | — | 38K | Requirements MOS-PORT-ID-001, MOS-PORT-LEASE-001, MOS-PORT-FENCE-001..002, MOS-PORT-OBS-001, MOS-PORT-ARCH-001. One Sol/Pi worker; TDD; PR-open STOP; worker must not edit this ledger. | diff --git a/docs/tess/THREAT-MODEL.md b/docs/tess/THREAT-MODEL.md new file mode 100644 index 00000000..fef2a4cb --- /dev/null +++ b/docs/tess/THREAT-MODEL.md @@ -0,0 +1,50 @@ +# Tess Threat Model + +## Assets and Trust Boundaries + +Assets: operator identity, tenant/project data, agent sessions, fleet control, approvals, credentials, memories, tool outputs, audit evidence, and provider transports. + +Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service identity, gateway→Pi/provider, Tess→Mos/fleet, Tess→Hermes, MCP→gateway, persistence, and tmux/Matrix transports. + +## Threat Matrix + +| ID | Severity | Threat | Required control | Required verification | +| ----- | -------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| TM-01 | critical | Client invokes admin/system command without role | Server-side scope/role enforcement in executor; durable approval for privileged/destructive commands | Authenticated non-admin and forged-scope tests deny and audit | +| TM-02 | critical | Cross-user/tenant list, attach, send, or terminate by guessed session ID | Owner/tenant binding on every session operation; admin override is explicit and audited | Cross-tenant matrix for REST, WS, CLI, Discord and provider methods | +| TM-03 | high | MCP caller supplies another `userId` | Remove actor IDs from schemas; derive actor/tenant from authenticated context; per-tool scopes | Forged actor/tool calls deny; no victim data returned | +| TM-04 | high | Discord ingress impersonates user/channel or bypasses gateway auth | Service-to-service identity, guild/channel/user allowlists, signed/correlated envelope, replay protection | Invalid service identity, unlisted IDs, replayed message IDs all deny | +| TM-05 | high | Secrets/PII leak in chat, auth links, tool args, logs, memory, or DB | Redact before persistence/egress; DM/out-of-band auth flow; short-lived hashed token state; output classification | Seeded secret/PII canary absent from durable stores/logs/public channel | +| TM-06 | high | Prompt/tool injection escalates from content to privileged action | Treat messages/files/tool output as untrusted data; structured proposals only; allowlisted tools; approval binds exact action digest | Injection corpus cannot invoke unapproved tools or alter authority | +| TM-07 | high | Approval forged, replayed, or applied to modified action | One-time approval with actor, tenant, action digest, expiry, correlation and consumption record | Forged/replayed/expired/mutated approvals deny and audit | +| TM-08 | medium | Restart causes message loss or duplicate side effects | Durable inbox/outbox/checkpoint; idempotency keys; transactional state transitions; bounded replay | Kill/restart at each state transition; exactly-once effect or safe dedupe | +| TM-09 | medium | Session GC/retention crosses tenant/session scope | Session/user-scoped GC or separately authorized global retention job | GC one session; unrelated logs/memory remain unchanged | +| TM-10 | high | tmux/Matrix transport target or identity spoofing | Exact target/socket binding, peer identity verification, Matrix whoami, authenticated transport metadata | Wrong socket/peer/room/identity refuses delivery/attach | +| TM-11 | medium | Hermes adapter exposes unsupported or broader legacy powers | Capability negotiation, default deny, normalized scopes, adapter sandbox/timeouts | Unsupported and over-scoped operations fail closed | +| TM-12 | medium | Tess competes with Mos or bypasses orchestration gates | Authority policy and correlated Mos handoff; no Tess worker-claim capability by default | Coding/decomposition intent produces handoff, not direct claim | + +## Security Invariants + +1. Authentication is not authorization; every command/tool/provider operation is authorized server-side. +2. Actor, tenant, roles, and channel bindings come only from authenticated gateway context. +3. No client-provided session ID grants ownership or attachment. +4. No privileged action executes without a matching, unexpired, one-time approval when policy requires it. +5. Redaction occurs before persistence and before channel egress. +6. Every externally caused operation is replay-safe and correlated. +7. Provider capability absence is a denial, not an invitation to shell around it. + +## Closed Prerequisite Findings + +The original M1 findings below are closed by landed controls and retained for audit traceability. + +| Former finding | Closed evidence | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Command scope/role enforcement | `apps/gateway/src/commands/command-authorization.service.ts` and its authorization tests enforce the server-side approval boundary. | +| Cross-owner session access | Gateway session ownership tests cover server-derived owner and tenant scope. | +| Caller-controlled MCP identity | MCP tools derive actor and tenant from authenticated gateway context. | +| Missing Discord ingress allowlists | `apps/gateway/src/plugin/plugin.module.ts` requires the guild, channel, and user allowlist environment values; `apps/gateway/src/plugin/discord-ingress.security.spec.ts` exercises denial and configured ingress. | +| Missing redaction before persistence/egress | Gateway and log redaction coverage verifies sensitive content is classified before durable storage or channel delivery. | +| In-memory-only restart safety | `packages/agent/src/durable-session.test.ts` reconstructs durable identity, inbox/outbox, checkpoints, and handoffs after simulated restart. | +| Globally scoped session GC | `apps/gateway/src/gc/session-gc.service.spec.ts` verifies session-only collection and the absence of automatic global collection entry points. | + +These controls remain subject to the runtime's independent review and release qualification gates. diff --git a/docs/tess/USER-GUIDE.md b/docs/tess/USER-GUIDE.md new file mode 100644 index 00000000..908ba61d --- /dev/null +++ b/docs/tess/USER-GUIDE.md @@ -0,0 +1,13 @@ +# Tess User Guide + +## Discord conversations + +In a configured Tess/interaction channel, an authorized untagged message is sent to the bound logical agent and its response appears in the channel. Mention the bot when starting a separate topic: Mosaic reuses a thread already attached to that same Discord message, or creates a new thread for the message, and responds there. Continue in that thread without tagging the bot again. Messages from unconfigured channels or users without an authorized pairing are ignored without creating a thread. + +`/approve` and `/stop ` operate on the current channel/thread session and do not open a new thread. The Discord connection is bound to the logical agent conversation, not Claude, Codex, Pi, OpenCode, or another harness; a runtime handoff behind Mosaic does not change where you continue the conversation. + +## CLI and HTTP interaction + +All HTTP interaction calls require authenticated session credentials and `X-Correlation-Id`. Use `GET /api/interaction/{agentName}/sessions?provider=...` to list only visible runtime sessions, then enroll with `POST .../sessions/{sessionId}/enroll` body `{providerId,runtimeSessionId}`. Attach uses `{mode:"read"}`; send uses `{content,idempotencyKey}`. Stop requires `{approvalRef}` and fails with 403 without the exact durable approval. Recovery only requeues interrupted durable work. + +Memory is user-scoped: preferences support list/get/upsert/delete; insights support list/get/create/delete; search body is `{query,limit?,maxDistance?}`. Mos work is handed off with `POST /api/coord/mos/handoff`; observe and result use the returned handoff ID. diff --git a/docs/tess/VERIFICATION-MATRIX.md b/docs/tess/VERIFICATION-MATRIX.md new file mode 100644 index 00000000..d89b23c2 --- /dev/null +++ b/docs/tess/VERIFICATION-MATRIX.md @@ -0,0 +1,30 @@ +# Tess Verification Matrix + +| Acceptance criterion | Requirements | Planned evidence | Gate | +| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V | +| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V | +| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V | +| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | M4 contract/gateway native-port handoff → observe → result round trip; configurable identity, target-drift and tenant-denial tests; M4-V fleet authority qualification | M4-001, M4-V | +| AC-TESS-05 | TESS-HRM-001, TESS-MEM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix; operator-memory plugin (TESS-MEM-001) reachable end-to-end — env-configured plugin registered + AgentService session-bound server-derived {tenantId,ownerId,sessionId} scoped search/capture, cross-tenant reuse denied before plugin call (M4-W-001 spine: #736 plugin + #739 consumer) | M4-V | +| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V | +| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V | +| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V | +| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone | +| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V | +| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V | + +## Security Abuse Suite Minimum + +- Role/scope matrix for every command and provider capability. +- Cross-tenant and cross-user session ID matrix across REST, WS, Discord, CLI, MCP, and providers. +- Discord service identity, guild/channel/user allowlist, replay, attachment, and mention/DM policy cases. +- Prompt/tool injection corpus and structured-proposal enforcement. +- Approval action-digest mutation, replay, expiry, tenant, and actor mismatch cases. +- Secret/PII canaries through message, attachment, tool args/output, logs, memory, audit, and error paths. +- Restart fault injection before/after enqueue, provider send, side effect, response persistence, and acknowledgement. +- Wrong tmux socket/target and Matrix identity/room/replay cases. + +## Evidence Rules + +Evidence must include command/test name, terminal result, CI run URL, PR/merge reference, environment, and artifact/log location. A worker self-report is not evidence until independently verified. diff --git a/docs/tess/hermes-runtime-adapter-design.md b/docs/tess/hermes-runtime-adapter-design.md new file mode 100644 index 00000000..4623c258 --- /dev/null +++ b/docs/tess/hermes-runtime-adapter-design.md @@ -0,0 +1,19 @@ +# TESS-HRM-001 — Hermes runtime adapter boundary + +## Normalized provider surface + +`HermesRuntimeProvider` implements the existing Mosaic-owned `AgentRuntimeProvider` unchanged. Its public surface is therefore `capabilities`, `health`, session list/tree, stream, send, attach/detach, and terminate, accepting only `RuntimeScope`, `RuntimeMessage`, `RuntimeSession`, `RuntimeStreamEvent`, and other types from `@mosaicstack/types`. Provider id is `runtime.hermes`. + +The provider receives a narrow injected `HermesRuntimeTransport`, whose method names and inputs may represent Hermes API operations but whose return values are explicitly private `HermesLegacy*` types defined only in `packages/agent/src/hermes-runtime-provider.ts`. Mapping functions convert those private values to Mosaic sessions, state, hierarchy, and stream events. Capability negotiation maps a supplied Hermes feature inventory onto the fixed Mosaic runtime capability vocabulary; no unknown/ambiguous legacy feature is advertised. Unsupported Mosaic operations throw the typed fail-closed `capability_unsupported` provider error before a transport call. + +## Boundary line + +**Hermes legacy schema ends at `HermesRuntimeTransport` and its private adapter-local `HermesLegacy*` definitions in `packages/agent`.** `packages/types` is never changed to contain a Hermes field, enum, identifier, session shape, status, or capability. `apps/gateway` registers/resolves the provider only through `AgentRuntimeProvider` and receives normalized values only. Identity remains server-derived `RuntimeScope` data and is passed to the injected transport as context, never reconstructed from a legacy response. + +## Initial mapping and safety posture + +- Hermes conversation/thread identifiers map to opaque Mosaic `RuntimeSession.id`; parent linkage maps only when a known parent exists. +- Hermes status strings map through a closed lookup to `RuntimeSessionState`; unknown statuses become `failed`, never a permissive active state. +- Legacy stream chunks map to `message.delta` / `message.complete`; malformed or unsupported events become a normalized `runtime.error` event. +- Send, attach, and terminate require the normalized capability first. `terminate` continues to be approval-bound by the gateway service; the adapter does not weaken gateway authority. +- Kanban, skills, memory, tools, and cron are capability-inventory entries for this transitional adapter, not additions to the core runtime contract. They are reported as explicitly unsupported until a Mosaic-owned capability contract exists. diff --git a/docs/tess/qualification/2026-07-14-option2-runtime-portability.md b/docs/tess/qualification/2026-07-14-option2-runtime-portability.md new file mode 100644 index 00000000..7af2cba3 --- /dev/null +++ b/docs/tess/qualification/2026-07-14-option2-runtime-portability.md @@ -0,0 +1,238 @@ +# Tess / Option 2 runtime-portability qualification — 2026-07-14 + +**Issue context:** #706–#711 and runtime-neutral Mos follow-up #754 + +**Qualified revision:** `d0771835542d` (`origin/main` at review time) + +**Reviewer/runtime:** Independent Pi lane requested as `openai-codex/gpt-5.6-sol:high` + +**Runtime resolution note:** Mosaic warned that `gpt-5.6-sol` was not present in the provider model catalog and proceeded with it as a custom model ID. This warning was part of the original qualification log and is material provenance; downstream claims must not treat catalog recognition as verified. + +**Verdict:** REQUEST CHANGES + +**Evidence type:** Point-in-time qualification; later commits and PR #757 must be reviewed separately + +## Purpose and provenance + +This report preserves the complete independent qualification that was previously available only in `/tmp/tess-option2-qualification.log`. It distinguishes passing component tests from the missing operational proof required for identity-continuous Mos failover. + +No credential values, OAuth tokens, Discord tokens, device codes, or auth-file contents are included. Commands and results are retained so another environment can reproduce or challenge the findings. + +--- + +# 1. Verdict + +## **REQUEST CHANGES** + +The current Option 2 implementation is a useful portability foundation, but it is **not qualified against AC-TESS-01..11** and is not equivalent to true same-Mos-identity failover. + +Primary blockers: + +1. **AC-TESS-01/02:** The required `mosaic tess` command does not exist; only `mosaic interaction` is registered (`packages/mosaic/src/commands/interaction.ts:60`). The cross-surface test proves CLI enrollment followed by Discord approval/stop, not bidirectional Discord/CLI chat streaming. +2. **AC-TESS-04:** Fleet/tmux and Matrix providers are implemented as libraries but are not registered in the production gateway. `AgentModule` registers only Hermes (`apps/gateway/src/agent/agent.module.ts:34`). +3. **Mos handoff is not operational or durable:** Production uses `InMemoryInteractionCoordinationPort` (`apps/gateway/src/coord/coord.module.ts:18`), with no Mos-side consumer. Restart loses handoff ownership, idempotency, activity, and results. +4. **AC-TESS-06/10:** Restart tests are good local persistence tests, but no real connector/harness failover or exercised rollback exists. Rollback is documentation-only. +5. **AC-TESS-08:** The parity suite validates a selected shared intersection using mocked transports. Matrix is not production-wired and tmux drops the runtime message idempotency key before delivery. +6. **AC-TESS-09:** M5 qualification remains `not-started`; no live Discord, Matrix homeserver, tmux/Mos consumer, Claude Code/Pi/Codex failover, or deployment rollback was tested. +7. **PR #750 mismatch:** Its description promises send-error coverage as HTTP 400, but both gateway and TUI test use HTTP 403 (`packages/mosaic/src/tui/gateway-api.interaction-errors.test.ts:25-34`). + +### AC disposition + +| AC | Result | Evidence | +| --- | ----------------- | ------------------------------------------------------------------------------- | +| 01 | **Fail** | No `mosaic tess`; no bidirectional same-session chat/stream E2E | +| 02 | **Fail** | Generic CLI exists, but fleet/Matrix providers are unreachable in production | +| 03 | Pass | Pi profile/model/reasoning/effective-policy tests passed | +| 04 | **Fail** | No registered fleet provider or real Mos consumer | +| 05 | Partial | Hermes normalization/fail-closed matrix passes; live capability path is limited | +| 06 | Partial | PGlite restart/idempotency passes; no actual harness failover | +| 07 | Partial | Focused denial/replay tests pass; full M5 abuse qualification absent | +| 08 | Partial | Mocked shared-intersection parity passes; Matrix not operationally wired | +| 09 | **Fail** | Baselines/CI green, but required E2E/security/rollback qualification absent | +| 10 | **Fail** | Inventory incomplete/inconsistent; rollback not exercised | +| 11 | Pass/ledger stale | Documentation and sitemap exist; plugin/catalog ledger remains unresolved | + +--- + +# 2. Exact test commands and results + +Initial focused attempts failed before collection because this detached worktree had no dependencies: + +```bash +pnpm --filter @mosaicstack/agent exec vitest run ... +``` + +Result: startup failure, `Cannot find module 'vitest/config'`. + +Setup used: + +```bash +corepack pnpm --store-dir /home/jarvis/.local/share/pnpm/store/v10 \ + install --frozen-lockfile --ignore-scripts +``` + +Result: PASS, 1,240 packages linked. + +```bash +corepack pnpm turbo run build \ + --filter='@mosaicstack/gateway^...' \ + --filter='@mosaicstack/mosaic^...' +``` + +Result: **17/17 dependency builds successful**. + +### Focused suites + +```bash +corepack pnpm --filter @mosaicstack/agent exec vitest run \ + src/runtime-provider-parity.test.ts \ + src/matrix-native-runtime-provider.test.ts \ + src/tmux-fleet-runtime-provider.test.ts \ + src/durable-session.test.ts \ + src/hermes-runtime-provider.test.ts +``` + +Result: **5 files, 39/39 tests passed**. + +```bash +corepack pnpm --filter @mosaicstack/gateway exec vitest run \ + src/agent/durable-session.repository.test.ts \ + src/__tests__/integration/tess-cross-surface.integration.test.ts \ + src/plugin/discord-ingress.security.spec.ts \ + src/coord/interaction-coordination.service.test.ts \ + src/coord/interaction-coordination.routing.e2e.test.ts \ + src/agent/hermes-runtime-reachability.e2e.test.ts +``` + +Result: **6 files, 36/36 tests passed**. PGlite close/reopen recovery passed in 504 ms. + +```bash +corepack pnpm --filter @mosaicstack/mosaic exec vitest run \ + src/fleet/matrix-native-runtime-transport.test.ts \ + src/fleet/tess-service-profile.test.ts \ + src/commands/interaction.test.ts \ + src/tui/gateway-api.interaction-errors.test.ts +``` + +Result: **4 files, 15/15 tests passed**. + +```bash +corepack pnpm --filter @mosaicstack/coord exec vitest run \ + src/__tests__/interaction-coordination.test.ts +``` + +Result: **1 file, 7/7 tests passed**. + +```bash +corepack pnpm --filter @mosaicstack/gateway exec vitest run \ + src/agent/interaction.controller.test.ts \ + src/commands/command-authorization.service.spec.ts \ + src/agent/__tests__/runtime-provider-registry.service.test.ts +``` + +Result: **3 files, 27/27 tests passed**. + +Focused total: **124/124 tests passed** after dependency setup. + +### Baselines + +```bash +TURBO_FORCE=true corepack pnpm typecheck +``` + +Result: **42/42 tasks successful**. + +```bash +TURBO_FORCE=true corepack pnpm lint +``` + +Result: **23/23 tasks successful**. + +```bash +corepack pnpm format:check +``` + +Result: **PASS — all files matched Prettier style**. + +```bash +~/.config/mosaic/tools/woodpecker/pipeline-status.sh \ + -r mosaicstack/stack -n 1796 +``` + +Result: **SUCCESS** at `d0771835542d`; all test, build, sanitization, typecheck, lint, format, and publish steps green. + +No tracked files outside the pre-existing `.mosaic/orchestrator/*` launcher changes were modified. + +--- + +# 3. Stale ledger inconsistencies + +1. `docs/tess/MISSION-MANIFEST.md` still says: + - current milestone M1; + - progress 0/5; + - M2/M3/M5 not started. +2. `docs/tess/TASKS.md` says: + - M4-V failed; + - M4-W-001 and TESS-PLG-001 in progress; + - M5-V not started. +3. Provider issue state conflicts: + - #707–#709 remain open although M1–M3 rows are recorded done/pass. + - #710 and #711 are closed although M4-V failed and M5-V is not started. +4. M5 work was marked done despite depending on failed M4-V. +5. TESS-M3-002 says `mosaic tess` is done, but only `mosaic interaction` exists. +6. PR #750 removed stale service references from operational docs, but `docs/tess/TASKS.md` still contains `MosCoordinationService` in historical notes. +7. `docs/tess/MIGRATION-INVENTORY.md` remains an “initial inventory” with several capabilities marked `adapt`; `M5-MIGRATION-INVENTORY.md` marks grouped capabilities deferred/fail-closed. Neither supplies the complete owner/evidence matrix AC-TESS-10 requires. +8. TESS-M2-FUP-001 remains real: the unkeyed SHA-256 compatibility branch still exists at `durable-session.repository.ts:427-431`. +9. TESS-PLG-001 claims catalog registration was folded into W-001, but production evidence shows provider registration in the gateway—not a completed `packages/mosaic` plugin catalog. + +--- + +# 4. Gap to true same-Mos-identity failover + +Current code can relaunch the same roster name under another runtime and can rebind a durable interaction session to another provider/runtime ID. That is **replacement**, not identity-continuous failover. + +Missing pieces: + +- No canonical logical Mos identity independent of harness-native session IDs. +- No exclusive connector lease or monotonic fencing epoch; session rebinding is effectively last-write-wins. +- No stale-holder rejection preventing the old harness from continuing side effects. +- No normalized Claude Code/Pi/Codex checkpoint/import/export adapters. +- No durable Mos coordination transport or Mos consumer. +- No canonical handoff containing mission/task refs, git state, causal sequence, pending operations, capability requirements, and acknowledgements. +- No end-to-end receipt journal across connectors. +- Matrix has deterministic transaction IDs, but tmux delivery discards `RuntimeMessage.idempotencyKey`. +- No fault-injection test transferring Mos among Claude Code, Pi, and Codex and then rolling back. + +--- + +# 5. Minimal follow-up issue decomposition + +| Order | Issue | Minimum acceptance criteria | +| ----- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | **Logical identity and security fencing** | Server-derived `{tenant, logicalAgentId, connectorId, harness, leaseEpoch, scopes, expiry}`; signed/fenced execution grant; stale/forged/cross-tenant grants denied and audited; no connector credential in handoffs | +| 2 | **Durable connector lease** | PostgreSQL-backed exclusive lease with CAS, monotonic epoch, TTL/heartbeat, explicit takeover, and gateway rejection of stale holders; connectors for Claude Code, Pi, and Codex | +| 3 | **Canonical handoff/checkpoint** | Versioned, sealed schema containing canonical mission/task/git references, checkpoint digest, causal sequence, required capabilities, pending/ambiguous operation references, and source/destination acknowledgement; no raw secrets or mandatory harness transcript | +| 4 | **Exactly-once connector journal** | Durable operation IDs and receipts; idempotency propagated through every adapter; Matrix transaction mapping; tmux replaced or wrapped with receiver-side durable dedupe; ambiguous effects remain held for authorized reconciliation | +| 5 | **Cross-harness failover and rollback E2E** | Real Mos identity moves Claude Code → Pi → Codex and back; inject crashes before/after lease transfer, handoff persistence, send, and acknowledgement; stale connector fenced; no duplicate side effects; canonical state preserved; rollback evidence published | +| 6 | **Generic gateway research ADR** | Evaluate LiteLLM subscription OAuth and Bifrost concepts without adding either to core; include terms/security review, credential lifecycle, tenant mapping, budgets, failover semantics, and adapter-only prototype | + +## Generic gateway placement + +Allowed topology: + +```text +Discord / CLI / web + ↓ +Mosaic Gateway: auth, tenant scope, policy, approvals, audit + ↓ +IProviderAdapter / AgentRuntimeProvider + ↓ +optional LiteLLM or Bifrost egress proxy + ↓ +upstream provider +``` + +- **LiteLLM ChatGPT subscription OAuth:** research-only, opt-in, behind an adapter. Subscription credentials require explicit terms, revocation, scope, token-storage, and audit review. They must never become Mosaic identity or core configuration. +- **Bifrost:** virtual keys are downstream proxy credentials, not Mosaic principals. Budget and failover concepts may inform Mosaic routing, but tenant policy, authorization, and audit remain in Mosaic. +- Neither product may introduce schemas into Mosaic core, receive direct calls from channels/agents, or bypass `IProviderAdapter`/`AgentRuntimeProvider`. +- Mosaic should also correct its existing “all providers unhealthy → use one anyway” fallback behavior before adopting more automatic failover (`routing-engine.service.ts:204-212`). diff --git a/eslint.config.mjs b/eslint.config.mjs index bc270143..bcfe1995 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,6 +28,8 @@ export default tseslint.config( 'apps/web/e2e/helpers/*.ts', 'apps/web/playwright.config.ts', 'apps/gateway/vitest.config.ts', + 'plugins/discord/vitest.config.ts', + 'packages/comms/vitest.config.ts', 'packages/db/vitest.config.ts', 'packages/storage/vitest.config.ts', 'packages/mosaic/vitest.config.ts', diff --git a/guides/BOOTSTRAP.md b/guides/BOOTSTRAP.md index b750eb45..f80fd5da 100755 --- a/guides/BOOTSTRAP.md +++ b/guides/BOOTSTRAP.md @@ -15,6 +15,22 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.) 7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges 8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention +## Agent Host Prerequisites + +Agent hosts must provide the Python runtime shape that runtime agents and +Mosaic automation assume is present. + +For Debian/Ubuntu hosts: + +```bash +sudo apt-get update +# #561: bare python invocations from agents must resolve. +sudo apt-get install -y python3 python-is-python3 +``` + +For non-Debian hosts, install the equivalent Python 3 runtime and ensure +`/usr/bin/python` resolves to `python3` (for example, via a managed symlink). + ## Quick Start ```bash diff --git a/infra/matrix/.gitignore b/infra/matrix/.gitignore new file mode 100644 index 00000000..fc4a97aa --- /dev/null +++ b/infra/matrix/.gitignore @@ -0,0 +1,3 @@ +# DEV runtime state: rendered config, sqlite db, signing key, self-signed +# certs, throwaway secrets. Never committed. +.data/ diff --git a/infra/matrix/README.md b/infra/matrix/README.md new file mode 100644 index 00000000..51b950e6 --- /dev/null +++ b/infra/matrix/README.md @@ -0,0 +1,53 @@ +# infra/matrix — DEV Synapse for RFC-001 P1 (presence) + +> **DEV-ONLY. LOCAL SANDBOX.** This stack is for local presence validation. It +> must **never** be pointed at, or run alongside, a live/production homeserver. +> It is single-instance, federation-OFF, self-signed TLS — the smallest slice +> RFC-002 §9 says P1 needs (Mode B single-domain, no federation, no IP-only, no +> secret-rotation story). + +## What this is + +A single Synapse homeserver rendered from committed **templates** (no hardcoded +topology — RFC-002 G2). Every topology fact is an environment variable with a +dev default: + +| Var | Default | Meaning | +| -------------------- | ------------------ | --------------------------------------------------- | +| `MATRIX_SERVER_NAME` | `matrix.localhost` | Synapse `server_name` (the `:suffix` of every MXID) | +| `MOSAIC_AS_ID` | `mosaic-as` | appservice id / registration filename | +| `MATRIX_HTTP_PORT` | `18008` | host port → Synapse 8008 (plain HTTP) | +| `MATRIX_TLS_PORT` | `18448` | host port → Synapse 8448 (self-signed TLS) | + +Secrets (`as_token`, `hs_token`, Synapse macaroon/form/registration secrets) +are **throwaway values generated at bring-up** into `.data/dev-secrets.env` +(gitignored). In production these are crown-jewel secrets held by the +SecretBackend (RFC-001 §8 / RFC-002 §4) — never committed. + +## Files + +- `docker-compose.dev.yml` — Synapse (+ optional Element under `--profile element`). +- `synapse/homeserver.dev.yaml.tpl` — rendered Synapse config (Mode B, `enable_registration: false`, appservice wired, native TLS). +- `synapse/log.config` — Synapse logging. +- `appservice/mosaic-as.dev.yaml.tpl` — minimal AS registration (declares the `@agent-*` user namespace). +- `dev-up.sh` / `dev-down.sh` — bring up / tear down (`--purge` wipes `.data`). +- `.data/` — **gitignored** runtime state (rendered config, sqlite db, signing key, self-signed certs, dev secrets). + +## Usage + +```bash +./dev-up.sh # render config, gen signing key + TLS cert, boot Synapse +# ... run the validation harness (tools/matrix-presence-harness/run.sh) ... +./dev-down.sh # stop, keep .data +./dev-down.sh --purge # stop and wipe .data for a pristine next boot + +# optional human view (A4) — Element pointed at the dev server: +docker compose -f docker-compose.dev.yml --profile element up -d element +# -> http://127.0.0.1:18080 +``` + +## Acceptance evidence (A1) + +- TLS (self-signed) reachable: `curl -sk https://127.0.0.1:18448/_matrix/client/versions` → `200`. +- Open registration OFF: `POST /_matrix/client/v3/register` → `M_FORBIDDEN "Registration has been disabled"`. +- AS-token registration bypasses the flag by design (that is how agents are provisioned). diff --git a/infra/matrix/appservice/mosaic-as.dev.yaml.tpl b/infra/matrix/appservice/mosaic-as.dev.yaml.tpl new file mode 100644 index 00000000..4fa0014c --- /dev/null +++ b/infra/matrix/appservice/mosaic-as.dev.yaml.tpl @@ -0,0 +1,30 @@ +# ============================================================================ +# mosaic-as.dev.yaml.tpl — Mosaic Appservice registration (DEV) +# ============================================================================ +# +# *** DEV-ONLY. The tokens below are throwaway placeholders rendered at +# bring-up by dev-up.sh. NEVER commit real hs_token/as_token — in +# production they are crown-jewel secrets held by the SecretBackend +# (RFC-001 §8, RFC-002 §4). *** +# +# This is the MINIMAL P1 registration: it declares the @agent-* user +# namespace so the P1 provisioner can register a few virtual agent MXIDs and +# carry heartbeats. It intentionally does NOT model the full P2 taxonomy / +# token-minting appservice. +# +# Rendered by infra/matrix/dev-up.sh (envsubst -> .data/${MOSAIC_AS_ID}.yaml). +# ---------------------------------------------------------------------------- +id: "${MOSAIC_AS_ID}" +url: null # P1: provisioner drives the AS API directly; Synapse pushes no txns. +as_token: "${MOSAIC_AS_TOKEN}" +hs_token: "${MOSAIC_HS_TOKEN}" +sender_localpart: "mosaic-as" +rate_limited: false +namespaces: + users: + - exclusive: true + regex: "@agent-.*:${MATRIX_SERVER_NAME}" + aliases: + - exclusive: false + regex: "#mosaic-.*:${MATRIX_SERVER_NAME}" + rooms: [] diff --git a/infra/matrix/dev-down.sh b/infra/matrix/dev-down.sh new file mode 100755 index 00000000..01eca367 --- /dev/null +++ b/infra/matrix/dev-down.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# dev-down.sh — tear down the DEV Synapse (matrix-p1-dev). DEV-ONLY. +# ./dev-down.sh # stop + remove containers, keep ./.data +# ./dev-down.sh --purge # also delete ./.data (fresh next bring-up) +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +docker compose -f "${HERE}/docker-compose.dev.yml" --profile element down -v --remove-orphans || true +if [[ "${1:-}" == "--purge" ]]; then + rm -rf "${HERE}/.data" + echo "[dev-down] purged ${HERE}/.data" +fi +echo "[dev-down] matrix-p1-dev stopped" diff --git a/infra/matrix/dev-up.sh b/infra/matrix/dev-up.sh new file mode 100755 index 00000000..a3e5316c --- /dev/null +++ b/infra/matrix/dev-up.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# ============================================================================ +# dev-up.sh — bring up the DEV Synapse for RFC-001 P1 (presence) +# ============================================================================ +# +# *** DEV-ONLY. LOCAL SANDBOX. This script must never be pointed at live +# infra. It writes only into infra/matrix/.data (gitignored) and drives +# a dedicated compose project (matrix-p1-dev). *** +# +# It renders the Synapse config + appservice registration from the committed +# templates (envsubst), generates a DEV signing key + self-signed TLS cert, +# and starts Synapse. All topology facts come from the environment with dev +# defaults (RFC-002 G2: nothing hardcoded). +# ---------------------------------------------------------------------------- +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DATA="${HERE}/.data" +COMPOSE=(docker compose -f "${HERE}/docker-compose.dev.yml") + +# ---- Topology inputs (dev defaults; override via env) ---------------------- +export MATRIX_SERVER_NAME="${MATRIX_SERVER_NAME:-matrix.localhost}" +export MOSAIC_AS_ID="${MOSAIC_AS_ID:-mosaic-as}" +export MATRIX_HTTP_PORT="${MATRIX_HTTP_PORT:-18008}" +export MATRIX_TLS_PORT="${MATRIX_TLS_PORT:-18448}" + +# ---- DEV secrets (throwaway; regenerated if absent) ------------------------ +SECRETS_ENV="${DATA}/dev-secrets.env" +mkdir -p "${DATA}" +if [[ ! -f "${SECRETS_ENV}" ]]; then + { + echo "MOSAIC_AS_TOKEN=devas_$(openssl rand -hex 16)" + echo "MOSAIC_HS_TOKEN=devhs_$(openssl rand -hex 16)" + echo "SYNAPSE_REG_SHARED_SECRET=devreg_$(openssl rand -hex 16)" + echo "SYNAPSE_MACAROON_SECRET=devmac_$(openssl rand -hex 16)" + echo "SYNAPSE_FORM_SECRET=devform_$(openssl rand -hex 16)" + } > "${SECRETS_ENV}" + echo "[dev-up] generated throwaway DEV secrets -> ${SECRETS_ENV}" +fi +# shellcheck disable=SC1090 +set -a; source "${SECRETS_ENV}"; set +a + +# DEV: let the synapse container user (uid 991) write the sqlite db / media. +chmod 0777 "${DATA}" || true + +# ---- Render config from templates ------------------------------------------ +envsubst < "${HERE}/synapse/homeserver.dev.yaml.tpl" > "${DATA}/homeserver.yaml" +envsubst < "${HERE}/appservice/mosaic-as.dev.yaml.tpl" > "${DATA}/${MOSAIC_AS_ID}.yaml" +cp "${HERE}/synapse/log.config" "${DATA}/log.config" +echo "[dev-up] rendered homeserver.yaml + ${MOSAIC_AS_ID}.yaml (server_name=${MATRIX_SERVER_NAME})" + +# ---- DEV signing key ------------------------------------------------------- +# Generate as the synapse container user (uid 991) so the running container +# can read it; owned-by-991 mode-600 is fine (the container IS 991). +SIGNING_KEY="${DATA}/${MATRIX_SERVER_NAME}.signing.key" +if [[ ! -f "${SIGNING_KEY}" ]]; then + docker run --rm --user 991:991 -v "${DATA}:/data" --entrypoint generate_signing_key \ + matrixdotorg/synapse:latest -o "/data/${MATRIX_SERVER_NAME}.signing.key" + echo "[dev-up] generated DEV signing key" +fi + +# ---- DEV self-signed TLS cert (A1) ----------------------------------------- +if [[ ! -f "${DATA}/dev-cert.pem" ]]; then + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "${DATA}/dev-key.pem" -out "${DATA}/dev-cert.pem" \ + -days 90 -subj "/CN=${MATRIX_SERVER_NAME}" \ + -addext "subjectAltName=DNS:${MATRIX_SERVER_NAME},DNS:localhost,IP:127.0.0.1" >/dev/null 2>&1 + echo "[dev-up] generated DEV self-signed TLS cert for ${MATRIX_SERVER_NAME}" +fi +# Files hermes owns must be world-readable so the synapse (uid 991) container +# can read them (DEV-only; the signing key stays owned by 991 from above). +chmod 0644 "${DATA}/dev-cert.pem" "${DATA}/dev-key.pem" \ + "${DATA}/homeserver.yaml" "${DATA}/${MOSAIC_AS_ID}.yaml" "${DATA}/log.config" || true + +# ---- Element config (optional human view, A4) ------------------------------ +cat > "${DATA}/element-config.json" </dev/null || true)" + if [[ "${status}" == "healthy" ]]; then echo " ... healthy"; break; fi + echo -n "."; sleep 2 +done + +if ! curl -fsS "http://127.0.0.1:${MATRIX_HTTP_PORT}/health" >/dev/null 2>&1; then + echo "[dev-up] ERROR: Synapse did not become healthy" >&2 + "${COMPOSE[@]}" logs --tail 60 synapse >&2 || true + exit 1 +fi + +echo "[dev-up] Synapse UP:" +echo " plain HTTP : http://127.0.0.1:${MATRIX_HTTP_PORT}" +echo " TLS : https://127.0.0.1:${MATRIX_TLS_PORT} (self-signed, DEV)" +echo " server_name: ${MATRIX_SERVER_NAME} registration: OFF" diff --git a/infra/matrix/docker-compose.dev.yml b/infra/matrix/docker-compose.dev.yml new file mode 100644 index 00000000..197270b3 --- /dev/null +++ b/infra/matrix/docker-compose.dev.yml @@ -0,0 +1,60 @@ +# ============================================================================ +# docker-compose.dev.yml — Synapse DEV homeserver for RFC-001 P1 (presence) +# ============================================================================ +# +# *** DEV-ONLY. LOCAL SANDBOX. Do NOT point this at, or run alongside, any +# live/production homeserver. *** +# +# Single-instance Synapse (RFC-002 Mode B, federation OFF) + an optional +# Element web client for the human view (A4). Brought up by ./dev-up.sh, which +# renders config into ./.data (gitignored) first. +# +# Isolated by design: dedicated project name + network + high host ports so it +# never collides with other stacks on the box (A5). +# +# Host ports: ${MATRIX_HTTP_PORT:-18008} -> Synapse 8008 (plain HTTP) +# ${MATRIX_TLS_PORT:-18448} -> Synapse 8448 (self-signed TLS) +# ${ELEMENT_PORT:-18080} -> Element web (profile: element) +# ---------------------------------------------------------------------------- +name: matrix-p1-dev + +services: + synapse: + image: matrixdotorg/synapse:latest + container_name: matrix-p1-synapse + restart: 'no' + # Run against the rendered config in /data (dev-up.sh puts it there). + environment: + SYNAPSE_CONFIG_DIR: /data + SYNAPSE_CONFIG_PATH: /data/homeserver.yaml + volumes: + - ./.data:/data + ports: + - '127.0.0.1:${MATRIX_HTTP_PORT:-18008}:8008' + - '127.0.0.1:${MATRIX_TLS_PORT:-18448}:8448' + networks: + - matrix-p1-net + healthcheck: + test: ['CMD-SHELL', 'curl -fsS http://localhost:8008/health || exit 1'] + interval: 3s + timeout: 5s + retries: 40 + start_period: 5s + + # Optional human view (A4). `docker compose --profile element up -d element`. + element: + image: vectorim/element-web:latest + container_name: matrix-p1-element + restart: 'no' + profiles: [element] + volumes: + - ./.data/element-config.json:/app/config.json:ro + ports: + - '127.0.0.1:${ELEMENT_PORT:-18080}:80' + networks: + - matrix-p1-net + +networks: + matrix-p1-net: + name: matrix-p1-net + driver: bridge diff --git a/infra/matrix/synapse/homeserver.dev.yaml.tpl b/infra/matrix/synapse/homeserver.dev.yaml.tpl new file mode 100644 index 00000000..28f88f1e --- /dev/null +++ b/infra/matrix/synapse/homeserver.dev.yaml.tpl @@ -0,0 +1,84 @@ +# ============================================================================ +# homeserver.dev.yaml.tpl — Synapse DEV homeserver config (RFC-002 Mode B) +# ============================================================================ +# +# *** DEV-ONLY. NOT FOR PRODUCTION. *** +# +# This is the RFC-001 P1 (presence) single-instance homeserver. It is +# RFC-002 "Mode B — single-domain" (server_name == homeserver host), with +# federation OFF (standalone), which is all P1 needs (RFC-002 §9 P1 row). +# +# NOTHING here is a production value. Every topology fact is a variable +# rendered by dev-up.sh from the environment; there is no baked-in fleet +# domain (RFC-002 G2 "zero hardcoded topology"). The secrets below are +# throwaway DEV placeholders rendered at bring-up — never reuse them. +# +# Rendered by: infra/matrix/dev-up.sh (envsubst -> .data/homeserver.yaml) +# Variables: MATRIX_SERVER_NAME, MOSAIC_AS_ID (+ dev secrets) +# ---------------------------------------------------------------------------- + +server_name: "${MATRIX_SERVER_NAME}" +pid_file: /data/homeserver.pid +report_stats: false +suppress_key_server_warning: true + +# --- Listeners ------------------------------------------------------------- +# 8008: plain HTTP (client + federation) for in-container/local tooling. +# 8448: TLS (self-signed in DEV) — satisfies A1 "reachable over TLS". +listeners: + - port: 8008 + type: http + tls: false + bind_addresses: ['0.0.0.0'] + x_forwarded: true + resources: + - names: [client, federation] + compress: false + + - port: 8448 + type: http + tls: true + bind_addresses: ['0.0.0.0'] + resources: + - names: [client, federation] + compress: false + +# --- DEV TLS (self-signed; generated by dev-up.sh) ------------------------- +tls_certificate_path: "/data/dev-cert.pem" +tls_private_key_path: "/data/dev-key.pem" + +# --- Store: sqlite is the simplest dev store (RFC-002 §1 allows it) -------- +database: + name: sqlite3 + args: + database: /data/homeserver.db + +log_config: "/data/log.config" +media_store_path: /data/media_store +signing_key_path: "/data/${MATRIX_SERVER_NAME}.signing.key" + +# --- Hardening (RFC-001 §8 / RFC-002 §8.5), rendered so a dev gets it ------ +# Registration is OFF: agents come ONLY via the appservice (A1). AS user +# registration bypasses this flag, which is exactly the design. +enable_registration: false +enable_registration_without_verification: false +registration_shared_secret: "${SYNAPSE_REG_SHARED_SECRET}" +macaroon_secret_key: "${SYNAPSE_MACAROON_SECRET}" +form_secret: "${SYNAPSE_FORM_SECRET}" + +# Presence EDUs ON so Element shows the native dot for humans (RFC-001 §4.5); +# the AUTHORITATIVE liveness is still the mosaic.presence heartbeat. +presence: + enabled: true + +# --- Federation: OFF for P1 (standalone). Empty whitelist = federate with +# nobody (RFC-002 §2.4 / NG5). No public-network federation. ---------------- +federation_domain_whitelist: [] +trusted_key_servers: [] + +# --- Appservice registration wired in (A1). The file is rendered next to +# this one by dev-up.sh. ----------------------------------------------------- +app_service_config_files: + - "/data/${MOSAIC_AS_ID}.yaml" + +# Keep default rate-limiting ON (RFC-001 §8). No overrides here. diff --git a/infra/matrix/synapse/log.config b/infra/matrix/synapse/log.config new file mode 100644 index 00000000..24d12232 --- /dev/null +++ b/infra/matrix/synapse/log.config @@ -0,0 +1,16 @@ +# Synapse DEV log config. DEV-ONLY. +version: 1 +formatters: + precise: + format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' +handlers: + console: + class: logging.StreamHandler + formatter: precise +loggers: + synapse.storage.SQL: + level: WARNING +root: + level: INFO + handlers: [console] +disable_existing_loggers: false diff --git a/package.json b/package.json index e24725f0..602bb7ee 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,15 @@ "build": "turbo run build", "dev": "turbo run dev", "lint": "turbo run lint", - "typecheck": "turbo run typecheck", - "test": "turbo run test && pnpm run test:installer", + "preflight": "node scripts/preflight.mjs", + "clean:generated": "node scripts/clean-generated.mjs", + "typecheck": "pnpm preflight && turbo run typecheck", + "test:checkout": "node --test scripts/*.test.mjs", + "test": "pnpm test:checkout && turbo run test && pnpm run test:installer", "test:installer": "bash tools/install-next-lane.test.sh", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", - "prepare": "husky" + "prepare": "node scripts/install-hooks.mjs" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0", diff --git a/packages/agent/src/connector-lease.test.ts b/packages/agent/src/connector-lease.test.ts new file mode 100644 index 00000000..2120eea7 --- /dev/null +++ b/packages/agent/src/connector-lease.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { + ConnectorExecutionContext, + ConnectorExecutionGrant, + ConnectorLease, + ConnectorLeaseAuditEvent, + ConnectorLeaseStore, + FencedConnectorAdapter, + LogicalAgentBinding, +} from '@mosaicstack/types'; +import { + ConnectorLeaseCoordinator, + MAX_CONNECTOR_GRANT_TTL_MS, + MAX_CONNECTOR_LEASE_TTL_MS, +} from './connector-lease.js'; +import type { ConnectorLeaseError } from './connector-lease.js'; + +const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const; +const binding: LogicalAgentBinding = { identity, bindingId: 'operator-chat' }; +const activeLease: ConnectorLease = { + ...binding, + leaseId: '00000000-0000-4000-8000-000000000001', + connectorId: 'connector-a', + scopes: ['runtime.send', 'tool.execute'], + leaseEpoch: '3', + acquiredAt: '2026-07-14T17:00:00.000Z', + heartbeatAt: '2026-07-14T17:00:00.000Z', + expiresAt: '2026-07-14T17:10:00.000Z', +}; + +class FakeLeaseStore implements ConnectorLeaseStore { + lease: ConnectorLease | null = activeLease; + readonly audits: ConnectorLeaseAuditEvent[] = []; + + async acquire(): Promise { + if (!this.lease) throw new Error('fixture has no lease'); + return this.lease; + } + + async takeover(): Promise { + if (!this.lease) throw new Error('fixture has no lease'); + return this.lease; + } + + async heartbeat(): Promise { + if (!this.lease) throw new Error('fixture has no lease'); + return this.lease; + } + + async release(): Promise {} + + async findCurrent(): Promise { + return this.lease; + } + + async recordAudit(event: ConnectorLeaseAuditEvent): Promise { + this.audits.push(event); + } +} + +describe('ConnectorLeaseCoordinator fencing', (): void => { + it('validates a server-minted grant immediately before invoking an adapter side effect', async (): Promise => { + const store = new FakeLeaseStore(); + const coordinator = new ConnectorLeaseCoordinator(store, { + now: (): Date => new Date('2026-07-14T17:01:00.000Z'), + }); + const grant = await coordinator.issueGrant({ + lease: activeLease, + scopes: ['runtime.send'], + ttlMs: 30_000, + correlationId: 'correlation-1', + }); + const execute = vi.fn(async (_input: string, context: ConnectorExecutionContext) => context); + const adapter: FencedConnectorAdapter = { execute }; + + const context = await coordinator.executeGrant(grant, 'runtime.send', 'hello', adapter); + + expect(execute).toHaveBeenCalledOnce(); + expect(context).toMatchObject({ + identity, + bindingId: 'operator-chat', + connectorId: 'connector-a', + leaseEpoch: '3', + scopes: ['runtime.send'], + }); + }); + + it('caps grant expiry to the durable current lease instead of submitted metadata', async (): Promise => { + const store = new FakeLeaseStore(); + store.lease = { ...activeLease, expiresAt: '2026-07-14T17:01:05.000Z' }; + const coordinator = new ConnectorLeaseCoordinator(store, { + now: (): Date => new Date('2026-07-14T17:01:00.000Z'), + }); + + const grant = await coordinator.issueGrant({ + lease: { ...activeLease, expiresAt: '2026-07-14T18:00:00.000Z' }, + scopes: ['runtime.send'], + ttlMs: 30_000, + correlationId: 'correlation-durable-expiry', + }); + + expect(grant.expiresAt).toBe('2026-07-14T17:01:05.000Z'); + }); + + it.each([ + ['forged clone', (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ ...grant })], + [ + 'cross-tenant clone', + (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ + ...grant, + identity: { ...grant.identity, tenantId: 'tenant-b' }, + }), + ], + [ + 'cross-agent clone', + (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ + ...grant, + identity: { ...grant.identity, logicalAgentId: 'other-agent' }, + }), + ], + [ + 'cross-binding clone', + (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ + ...grant, + bindingId: 'other-binding', + }), + ], + [ + 'cross-connector clone', + (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ + ...grant, + connectorId: 'connector-b', + }), + ], + ])('denies and audits a %s before adapter invocation', async (_label, forge): Promise => { + const store = new FakeLeaseStore(); + const coordinator = new ConnectorLeaseCoordinator(store, { + now: (): Date => new Date('2026-07-14T17:01:00.000Z'), + }); + const grant = await coordinator.issueGrant({ + lease: activeLease, + scopes: ['runtime.send'], + ttlMs: 30_000, + correlationId: 'correlation-forged', + }); + const adapter = { execute: vi.fn().mockResolvedValue(undefined) }; + + await expect( + coordinator.executeGrant(forge(grant), 'runtime.send', undefined, adapter), + ).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial); + expect(adapter.execute).not.toHaveBeenCalled(); + expect(store.audits.at(-1)).toMatchObject({ + event: 'reject', + outcome: 'denied', + reason: 'forged_grant', + correlationId: 'correlation-forged', + }); + }); + + it('denies malformed forged grants with sanitized audit metadata', async (): Promise => { + const store = new FakeLeaseStore(); + const coordinator = new ConnectorLeaseCoordinator(store, { + now: (): Date => new Date('2026-07-14T17:01:00.000Z'), + }); + const adapter = { execute: vi.fn().mockResolvedValue(undefined) }; + + await expect( + coordinator.executeGrant( + // @ts-expect-error Deliberately exercise malformed runtime input at the trust boundary. + {}, + 'runtime.send', + undefined, + adapter, + ), + ).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial); + expect(adapter.execute).not.toHaveBeenCalled(); + expect(store.audits).toContainEqual( + expect.objectContaining({ + identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' }, + bindingId: 'untrusted', + connectorId: 'untrusted', + correlationId: 'untrusted', + reason: 'forged_grant', + }), + ); + }); + + it('rejects lease and grant TTLs above server-side safety caps', async (): Promise => { + const store = new FakeLeaseStore(); + const coordinator = new ConnectorLeaseCoordinator(store, { + now: (): Date => new Date('2026-07-14T17:01:00.000Z'), + }); + expect( + () => + new ConnectorLeaseCoordinator(store, { + maxLeaseTtlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1, + }), + ).toThrow(/no greater than/); + + await expect( + coordinator.acquire({ + identity, + bindingId: 'operator-chat', + connectorId: 'connector-a', + scopes: ['runtime.send'], + ttlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1, + correlationId: 'correlation-ttl', + }), + ).rejects.toThrow(/no greater than/); + await expect( + coordinator.issueGrant({ + lease: activeLease, + scopes: ['runtime.send'], + ttlMs: MAX_CONNECTOR_GRANT_TTL_MS + 1, + correlationId: 'correlation-ttl', + }), + ).rejects.toThrow(/no greater than/); + }); + + it('denies stale epoch, expired grant, and unauthorized scope before side effects', async (): Promise => { + let now = new Date('2026-07-14T17:01:00.000Z'); + const store = new FakeLeaseStore(); + const coordinator = new ConnectorLeaseCoordinator(store, { now: (): Date => now }); + const stale = await coordinator.issueGrant({ + lease: activeLease, + scopes: ['runtime.send'], + ttlMs: 30_000, + correlationId: 'correlation-stale', + }); + store.lease = { ...activeLease, leaseEpoch: '4', connectorId: 'connector-b' }; + const adapter = { execute: vi.fn().mockResolvedValue(undefined) }; + + await expect( + coordinator.executeGrant(stale, 'runtime.send', undefined, adapter), + ).rejects.toMatchObject({ code: 'stale_epoch' } satisfies Partial); + + store.lease = activeLease; + const expiring = await coordinator.issueGrant({ + lease: activeLease, + scopes: ['runtime.send'], + ttlMs: 1_000, + correlationId: 'correlation-expired', + }); + now = new Date('2026-07-14T17:01:02.000Z'); + await expect( + coordinator.executeGrant(expiring, 'runtime.send', undefined, adapter), + ).rejects.toMatchObject({ code: 'grant_expired' } satisfies Partial); + + now = new Date('2026-07-14T17:01:00.000Z'); + const scoped = await coordinator.issueGrant({ + lease: activeLease, + scopes: ['runtime.send'], + ttlMs: 30_000, + correlationId: 'correlation-scope', + }); + await expect( + coordinator.executeGrant(scoped, 'tool.execute', undefined, adapter), + ).rejects.toMatchObject({ code: 'scope_denied' } satisfies Partial); + expect(adapter.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/agent/src/connector-lease.ts b/packages/agent/src/connector-lease.ts new file mode 100644 index 00000000..7283bdb9 --- /dev/null +++ b/packages/agent/src/connector-lease.ts @@ -0,0 +1,381 @@ +import { randomUUID } from 'node:crypto'; +import { + normalizeConnectorId, + normalizeConnectorScope, + normalizeConnectorScopes, + normalizeCorrelationId, + normalizeLeaseEpoch, + normalizeLogicalAgentIdentity, + normalizeLogicalBindingId, + type AcquireConnectorLeaseInput, + type ConnectorExecutionContext, + type ConnectorExecutionGrant, + type ConnectorLease, + type ConnectorLeaseAuditEvent, + type ConnectorLeaseRejectReason, + type ConnectorLeaseStore, + type FencedConnectorAdapter, + type HeartbeatConnectorLeaseInput, + type IssueConnectorExecutionGrantInput, + type LogicalAgentBinding, + type ReleaseConnectorLeaseInput, + type TakeoverConnectorLeaseInput, +} from '@mosaicstack/types'; + +export const MAX_CONNECTOR_LEASE_TTL_MS = 5 * 60 * 1000; +export const MAX_CONNECTOR_GRANT_TTL_MS = 30 * 1000; + +export interface ConnectorLeaseCoordinatorOptions { + readonly now?: () => Date; + readonly maxLeaseTtlMs?: number; + readonly maxGrantTtlMs?: number; +} + +export class ConnectorLeaseError extends Error { + constructor( + readonly code: ConnectorLeaseRejectReason, + message: string, + ) { + super(message); + this.name = ConnectorLeaseError.name; + } +} + +/** + * Runtime-neutral lease coordinator. Durable CAS lives in the store adapter; + * grant provenance remains process-local so a restart fails closed and mints + * fresh grants from the durable current lease. + */ +export class ConnectorLeaseCoordinator { + private readonly issuedGrants = new WeakSet(); + private readonly now: () => Date; + private readonly maxLeaseTtlMs: number; + private readonly maxGrantTtlMs: number; + + constructor( + private readonly store: ConnectorLeaseStore, + options: ConnectorLeaseCoordinatorOptions = {}, + ) { + this.now = options.now ?? (() => new Date()); + this.maxLeaseTtlMs = normalizeTtlLimit( + options.maxLeaseTtlMs ?? MAX_CONNECTOR_LEASE_TTL_MS, + MAX_CONNECTOR_LEASE_TTL_MS, + 'lease', + ); + this.maxGrantTtlMs = normalizeTtlLimit( + options.maxGrantTtlMs ?? MAX_CONNECTOR_GRANT_TTL_MS, + MAX_CONNECTOR_GRANT_TTL_MS, + 'grant', + ); + } + + async acquire(input: AcquireConnectorLeaseInput): Promise { + const command = normalizeAcquireInput(input, this.maxLeaseTtlMs); + const now = this.now(); + return this.store.acquire({ + ...command, + leaseId: randomUUID(), + now: now.toISOString(), + expiresAt: expiresAt(now, command.ttlMs), + }); + } + + async takeover(input: TakeoverConnectorLeaseInput): Promise { + const command = normalizeAcquireInput(input, this.maxLeaseTtlMs); + const now = this.now(); + return this.store.takeover({ + ...command, + expectedEpoch: normalizeLeaseEpoch(input.expectedEpoch), + leaseId: randomUUID(), + now: now.toISOString(), + expiresAt: expiresAt(now, command.ttlMs), + }); + } + + async heartbeat(input: HeartbeatConnectorLeaseInput): Promise { + const now = this.now(); + const lease = normalizeConnectorLease(input.lease); + const ttlMs = normalizeTtl(input.ttlMs, this.maxLeaseTtlMs, 'lease'); + return this.store.heartbeat({ + lease, + ttlMs, + correlationId: normalizeCorrelationId(input.correlationId), + now: now.toISOString(), + expiresAt: expiresAt(now, ttlMs), + }); + } + + async release(input: ReleaseConnectorLeaseInput): Promise { + await this.store.release({ + lease: normalizeConnectorLease(input.lease), + correlationId: normalizeCorrelationId(input.correlationId), + now: this.now().toISOString(), + }); + } + + async current(binding: LogicalAgentBinding): Promise { + return this.store.findCurrent(normalizeBinding(binding)); + } + + async issueGrant(input: IssueConnectorExecutionGrantInput): Promise { + const now = this.now(); + const lease = normalizeConnectorLease(input.lease); + const scopes = normalizeConnectorScopes(input.scopes); + const correlationId = normalizeCorrelationId(input.correlationId); + const ttlMs = normalizeTtl(input.ttlMs, this.maxGrantTtlMs, 'grant'); + const current = await this.store.findCurrent(lease); + await this.assertCurrentLease(current, lease, now, correlationId); + if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable'); + if (!isScopeSubset(scopes, lease.scopes) || !isScopeSubset(scopes, current.scopes)) { + await this.reject(lease, correlationId, now, 'scope_denied'); + } + const requestedExpiry = new Date(now.getTime() + ttlMs); + const leaseExpiry = new Date(current.expiresAt); + const grantExpiry = requestedExpiry < leaseExpiry ? requestedExpiry : leaseExpiry; + const grant: ConnectorExecutionGrant = Object.freeze({ + identity: current.identity, + bindingId: current.bindingId, + leaseId: current.leaseId, + connectorId: current.connectorId, + scopes, + leaseEpoch: current.leaseEpoch, + issuedAt: now.toISOString(), + expiresAt: grantExpiry.toISOString(), + correlationId, + }); + this.issuedGrants.add(grant); + return grant; + } + + async executeGrant( + grant: ConnectorExecutionGrant, + requiredScope: string, + input: TInput, + adapter: FencedConnectorAdapter, + ): Promise { + const now = this.now(); + const normalizedScope = normalizeConnectorScope(requiredScope); + if (!this.issuedGrants.has(grant)) { + await this.rejectForgedGrant(grant, now); + } + if (new Date(grant.expiresAt) <= now) { + await this.rejectGrant(grant, now, 'grant_expired'); + } + const current = await this.store.findCurrent(normalizeBinding(grant)); + await this.assertCurrentLease(current, grant, now, grant.correlationId); + if (!grant.scopes.includes(normalizedScope) || !current?.scopes.includes(normalizedScope)) { + await this.rejectGrant(grant, now, 'scope_denied'); + } + if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable'); + const context: ConnectorExecutionContext = Object.freeze({ + identity: current.identity, + bindingId: current.bindingId, + leaseId: current.leaseId, + connectorId: current.connectorId, + scopes: Object.freeze([...grant.scopes]), + leaseEpoch: current.leaseEpoch, + correlationId: grant.correlationId, + grantExpiresAt: grant.expiresAt, + }); + return adapter.execute(input, context); + } + + private async assertCurrentLease( + current: ConnectorLease | null, + authority: ConnectorLease | ConnectorExecutionGrant, + now: Date, + correlationId: string, + ): Promise { + if (!current) await this.reject(authority, correlationId, now, 'lease_missing'); + if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable'); + if (current.releasedAt) await this.reject(authority, correlationId, now, 'lease_released'); + if (new Date(current.expiresAt) <= now) { + await this.store.recordAudit(auditEvent(current, correlationId, now, 'expiry', 'succeeded')); + await this.reject(authority, correlationId, now, 'lease_expired'); + } + if (current.leaseEpoch !== authority.leaseEpoch) { + await this.reject(authority, correlationId, now, 'stale_epoch'); + } + if (current.leaseId !== authority.leaseId || current.connectorId !== authority.connectorId) { + await this.reject(authority, correlationId, now, 'connector_mismatch'); + } + } + + private async rejectGrant( + grant: ConnectorExecutionGrant, + now: Date, + reason: ConnectorLeaseRejectReason, + ): Promise { + return this.reject(grant, grant.correlationId, now, reason); + } + + private async rejectForgedGrant(grant: unknown, now: Date): Promise { + const event = safeForgedGrantAudit(grant, now); + await this.store.recordAudit(event); + throw new ConnectorLeaseError('forged_grant', safeReasonMessage('forged_grant')); + } + + private async reject( + authority: LogicalAgentBinding & { + readonly connectorId: string; + readonly leaseId?: string; + readonly leaseEpoch?: string; + }, + correlationId: string, + now: Date, + reason: ConnectorLeaseRejectReason, + ): Promise { + await this.store.recordAudit( + auditEvent(authority, correlationId, now, 'reject', 'denied', reason), + ); + throw new ConnectorLeaseError(reason, safeReasonMessage(reason)); + } +} + +function normalizeAcquireInput( + input: AcquireConnectorLeaseInput, + maxLeaseTtlMs: number, +): AcquireConnectorLeaseInput { + return { + identity: normalizeLogicalAgentIdentity(input.identity), + bindingId: normalizeLogicalBindingId(input.bindingId), + connectorId: normalizeConnectorId(input.connectorId), + scopes: normalizeConnectorScopes(input.scopes), + ttlMs: normalizeTtl(input.ttlMs, maxLeaseTtlMs, 'lease'), + correlationId: normalizeCorrelationId(input.correlationId), + }; +} + +function normalizeBinding(input: LogicalAgentBinding): LogicalAgentBinding { + return { + identity: normalizeLogicalAgentIdentity(input.identity), + bindingId: normalizeLogicalBindingId(input.bindingId), + }; +} + +export function normalizeConnectorLease(lease: ConnectorLease): ConnectorLease { + const binding = normalizeBinding(lease); + return Object.freeze({ + ...binding, + leaseId: lease.leaseId, + connectorId: normalizeConnectorId(lease.connectorId), + scopes: normalizeConnectorScopes(lease.scopes), + leaseEpoch: normalizeLeaseEpoch(lease.leaseEpoch), + acquiredAt: normalizeTimestamp(lease.acquiredAt, 'lease acquisition'), + heartbeatAt: normalizeTimestamp(lease.heartbeatAt, 'lease heartbeat'), + expiresAt: normalizeTimestamp(lease.expiresAt, 'lease expiry'), + ...(lease.releasedAt + ? { releasedAt: normalizeTimestamp(lease.releasedAt, 'lease release') } + : {}), + }); +} + +function normalizeTtl(ttlMs: number, maximum: number, kind: 'lease' | 'grant'): number { + if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > maximum) { + throw new Error( + `Connector ${kind} TTL must be a positive safe integer no greater than ${maximum}ms`, + ); + } + return ttlMs; +} + +function normalizeTtlLimit(ttlMs: number, hardMaximum: number, kind: 'lease' | 'grant'): number { + if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > hardMaximum) { + throw new Error( + `Maximum connector ${kind} TTL must be a positive safe integer no greater than ${hardMaximum}ms`, + ); + } + return ttlMs; +} + +function normalizeTimestamp(value: string, label: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) throw new Error(`${label} timestamp is invalid`); + return date.toISOString(); +} + +function expiresAt(now: Date, ttlMs: number): string { + const expiry = new Date(now.getTime() + ttlMs); + if (Number.isNaN(expiry.getTime())) throw new Error('Connector lease TTL exceeds date range'); + return expiry.toISOString(); +} + +function isScopeSubset(requested: readonly string[], allowed: readonly string[]): boolean { + return requested.every((scope) => allowed.includes(scope)); +} + +function auditEvent( + authority: LogicalAgentBinding & { + readonly connectorId: string; + readonly leaseId?: string; + readonly leaseEpoch?: string; + }, + correlationId: string, + now: Date, + event: ConnectorLeaseAuditEvent['event'], + outcome: ConnectorLeaseAuditEvent['outcome'], + reason?: ConnectorLeaseRejectReason, +): ConnectorLeaseAuditEvent { + return { + identity: authority.identity, + bindingId: authority.bindingId, + connectorId: authority.connectorId, + correlationId, + occurredAt: now.toISOString(), + event, + outcome, + ...(authority.leaseId ? { leaseId: authority.leaseId } : {}), + ...(authority.leaseEpoch ? { leaseEpoch: authority.leaseEpoch } : {}), + ...(reason ? { reason } : {}), + }; +} + +function safeForgedGrantAudit(grant: unknown, now: Date): ConnectorLeaseAuditEvent { + const fallback: ConnectorLeaseAuditEvent = { + identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' }, + bindingId: 'untrusted', + connectorId: 'untrusted', + correlationId: 'untrusted', + occurredAt: now.toISOString(), + event: 'reject', + outcome: 'denied', + reason: 'forged_grant', + }; + if (typeof grant !== 'object' || grant === null || !('identity' in grant)) return fallback; + const identity = grant.identity; + if (typeof identity !== 'object' || identity === null) return fallback; + if (!('tenantId' in identity) || !('logicalAgentId' in identity)) return fallback; + if (!('bindingId' in grant) || !('connectorId' in grant) || !('correlationId' in grant)) { + return fallback; + } + if ( + typeof identity.tenantId !== 'string' || + typeof identity.logicalAgentId !== 'string' || + typeof grant.bindingId !== 'string' || + typeof grant.connectorId !== 'string' || + typeof grant.correlationId !== 'string' + ) { + return fallback; + } + try { + return { + identity: normalizeLogicalAgentIdentity({ + tenantId: identity.tenantId, + logicalAgentId: identity.logicalAgentId, + }), + bindingId: normalizeLogicalBindingId(grant.bindingId), + connectorId: normalizeConnectorId(grant.connectorId), + correlationId: normalizeCorrelationId(grant.correlationId), + occurredAt: now.toISOString(), + event: 'reject', + outcome: 'denied', + reason: 'forged_grant', + }; + } catch { + return fallback; + } +} + +function safeReasonMessage(reason: ConnectorLeaseRejectReason): string { + return `Connector authority denied: ${reason}`; +} diff --git a/packages/agent/src/durable-session.test.ts b/packages/agent/src/durable-session.test.ts new file mode 100644 index 00000000..109702ee --- /dev/null +++ b/packages/agent/src/durable-session.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest'; +import { + DurableSessionCoordinator, + InMemoryDurableSessionStore, + type DurableSessionIdentity, +} from './durable-session.js'; + +const IDENTITY: DurableSessionIdentity = { + agentName: 'Nova', + sessionId: 'tess-session-1', + tenantId: 'tenant-1', + ownerId: 'owner-1', + providerId: 'fleet', + runtimeSessionId: 'nova', +}; + +describe('DurableSessionCoordinator', () => { + it('reconstructs an exact session identity, pending inbox/outbox, checkpoint, and handoff after a simulated process restart', async () => { + const store = new InMemoryDurableSessionStore(); + const beforeRestart = new DurableSessionCoordinator(store); + + await beforeRestart.create(IDENTITY); + await beforeRestart.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'ingress-1', + correlationId: 'correlation-1', + content: 'continue the session', + }); + await beforeRestart.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'outbox-1', + correlationId: 'correlation-1', + channelId: 'cli', + kind: 'provider.send', + content: 'resumable response', + }); + await beforeRestart.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-1', + cursor: 'cursor-42', + summary: 'operator asked for recovery proof', + compactionEpoch: 0, + }); + await beforeRestart.handoff({ + sessionId: IDENTITY.sessionId, + handoffId: 'handoff-1', + destination: 'mos', + correlationId: 'correlation-1', + checkpointId: 'checkpoint-1', + status: 'pending', + }); + + // Simulate an ungraceful process death: no in-memory coordinator state survives. + const afterRestart = new DurableSessionCoordinator(store); + const recovered = await afterRestart.recover(IDENTITY.sessionId); + + expect(recovered.identity).toEqual(IDENTITY); + expect(recovered.inbox).toMatchObject([{ idempotencyKey: 'ingress-1', status: 'pending' }]); + expect(recovered.outbox).toMatchObject([{ idempotencyKey: 'outbox-1', status: 'pending' }]); + expect(recovered.checkpoint).toMatchObject({ + checkpointId: 'checkpoint-1', + cursor: 'cursor-42', + }); + expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-1', status: 'pending' }]); + }); + + it('rebinds a recovered runtime while preserving the immutable conversation owner scope', async () => { + const coordinator = new DurableSessionCoordinator(new InMemoryDurableSessionStore()); + await coordinator.create(IDENTITY); + await coordinator.create({ + ...IDENTITY, + providerId: 'fleet-next', + runtimeSessionId: 'nova-next', + }); + + await expect(coordinator.snapshot(IDENTITY.sessionId)).resolves.toMatchObject({ + identity: { ...IDENTITY, providerId: 'fleet-next', runtimeSessionId: 'nova-next' }, + }); + await expect(coordinator.create({ ...IDENTITY, ownerId: 'other-owner' })).rejects.toThrow( + /identity conflict/, + ); + }); + + it('deduplicates duplicate ingress and never reprocesses an inbox record after restart or compaction', async () => { + const store = new InMemoryDurableSessionStore(); + const firstProcess = new DurableSessionCoordinator(store); + const handled: string[] = []; + + await firstProcess.create(IDENTITY); + await expect( + firstProcess.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'ingress-duplicate', + correlationId: 'correlation-2', + content: 'only process me once', + }), + ).resolves.toMatchObject({ accepted: true }); + await expect( + firstProcess.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'ingress-duplicate', + correlationId: 'correlation-2', + content: 'only process me once', + }), + ).resolves.toMatchObject({ accepted: false, status: 'pending' }); + await expect( + firstProcess.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'ingress-duplicate', + correlationId: 'forged-correlation', + content: 'only process me once', + }), + ).rejects.toThrow(/idempotency conflict/); + + await firstProcess.drainInbox(IDENTITY.sessionId, async (entry) => { + handled.push(entry.idempotencyKey); + }); + await firstProcess.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-after-inbox', + cursor: 'cursor-43', + summary: 'safe to compact', + compactionEpoch: 1, + }); + + const afterRestartAndCompaction = new DurableSessionCoordinator(store); + await afterRestartAndCompaction.recover(IDENTITY.sessionId); + await afterRestartAndCompaction.drainInbox(IDENTITY.sessionId, async (entry) => { + handled.push(entry.idempotencyKey); + }); + + expect(handled).toEqual(['ingress-duplicate']); + }); + + it('does not redispatch an already applied outbox side effect after replay, restart, or compaction', async () => { + const store = new InMemoryDurableSessionStore(); + const beforeRestart = new DurableSessionCoordinator(store); + const appliedEffects: string[] = []; + + await beforeRestart.create(IDENTITY); + await beforeRestart.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'effect-1', + correlationId: 'correlation-3', + channelId: 'cli', + kind: 'provider.send', + content: 'send exactly once', + }); + await expect( + beforeRestart.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'effect-1', + correlationId: 'correlation-3', + channelId: 'cli', + kind: 'provider.send', + content: 'send exactly once', + }), + ).resolves.toMatchObject({ accepted: false, status: 'pending' }); + + await beforeRestart.dispatchOutbox(IDENTITY.sessionId, async (entry) => { + appliedEffects.push(entry.idempotencyKey); + }); + await beforeRestart.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-after-effect', + cursor: 'cursor-44', + summary: 'effect persisted before compaction', + compactionEpoch: 1, + }); + + const afterRestartAndCompaction = new DurableSessionCoordinator(store); + await afterRestartAndCompaction.recover(IDENTITY.sessionId); + await afterRestartAndCompaction.dispatchOutbox(IDENTITY.sessionId, async (entry) => { + appliedEffects.push(entry.idempotencyKey); + }); + + expect(appliedEffects).toEqual(['effect-1']); + }); + + it('rejects outbox idempotency-key reuse when immutable effect data differs', async () => { + const store = new InMemoryDurableSessionStore(); + const coordinator = new DurableSessionCoordinator(store); + + await coordinator.create(IDENTITY); + await coordinator.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'outbox-conflict', + correlationId: 'correlation-outbox', + channelId: 'cli', + kind: 'provider.send', + content: 'original effect', + }); + await expect( + coordinator.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'outbox-conflict', + correlationId: 'correlation-outbox', + channelId: 'forged-channel', + kind: 'provider.send', + content: 'original effect', + }), + ).rejects.toThrow(/idempotency conflict/); + }); + + it('retains an ambiguous failed provider effect as processing until explicit recovery', async () => { + const store = new InMemoryDurableSessionStore(); + const beforeRestart = new DurableSessionCoordinator(store); + + await beforeRestart.create(IDENTITY); + await beforeRestart.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'ambiguous-effect', + correlationId: 'correlation-ambiguous', + channelId: 'cli', + kind: 'provider.send', + content: 'preserve this effect claim', + }); + await expect( + beforeRestart.dispatchOutbox(IDENTITY.sessionId, async (): Promise => { + throw new Error('provider connection dropped after submit'); + }), + ).rejects.toThrow(/connection dropped/); + + const afterRestart = new DurableSessionCoordinator(store); + await afterRestart.recover(IDENTITY.sessionId); + const calls: string[] = []; + await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise => { + calls.push(entry.idempotencyKey); + }); + + expect(calls).toEqual([]); + expect(await afterRestart.snapshot(IDENTITY.sessionId)).toMatchObject({ + outbox: [{ idempotencyKey: 'ambiguous-effect', status: 'processing' }], + }); + }); + + it('rejects a handoff-id replay with different immutable state', async () => { + const store = new InMemoryDurableSessionStore(); + const coordinator = new DurableSessionCoordinator(store); + + await coordinator.create(IDENTITY); + await coordinator.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-conflict', + cursor: 'cursor-conflict', + summary: 'handoff conflict proof', + compactionEpoch: 0, + }); + await coordinator.handoff({ + sessionId: IDENTITY.sessionId, + handoffId: 'handoff-conflict', + destination: 'mos', + correlationId: 'correlation-conflict', + checkpointId: 'checkpoint-conflict', + status: 'pending', + }); + + await expect( + coordinator.handoff({ + sessionId: IDENTITY.sessionId, + handoffId: 'handoff-conflict', + destination: 'forged-destination', + correlationId: 'correlation-conflict', + checkpointId: 'checkpoint-conflict', + status: 'pending', + }), + ).rejects.toThrow(/identity conflict/); + }); + + it('keeps a handoff portable and resumes it from its durable checkpoint without process-local references', async () => { + const store = new InMemoryDurableSessionStore(); + const source = new DurableSessionCoordinator(store); + + await source.create(IDENTITY); + await source.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-handoff', + cursor: 'cursor-45', + summary: 'portable state', + compactionEpoch: 2, + }); + await source.handoff({ + sessionId: IDENTITY.sessionId, + handoffId: 'handoff-portable', + destination: 'mos', + correlationId: 'correlation-4', + checkpointId: 'checkpoint-handoff', + status: 'pending', + }); + await source.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-later', + cursor: 'cursor-46', + summary: 'newer compacted state must not strand the handoff', + compactionEpoch: 3, + }); + + const receivingProcess = new DurableSessionCoordinator(store); + const handoff = await receivingProcess.resumeHandoff('handoff-portable'); + + expect(handoff.identity).toEqual(IDENTITY); + expect(handoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-handoff' }); + expect(handoff.handoff).toMatchObject({ handoffId: 'handoff-portable', destination: 'mos' }); + }); +}); diff --git a/packages/agent/src/durable-session.ts b/packages/agent/src/durable-session.ts new file mode 100644 index 00000000..a66e3a51 --- /dev/null +++ b/packages/agent/src/durable-session.ts @@ -0,0 +1,498 @@ +export interface DurableSessionIdentity { + /** Provisioned roster identity; isolates durable state between named agents. */ + agentName: string; + sessionId: string; + tenantId: string; + ownerId: string; + providerId: string; + runtimeSessionId: string; +} + +export type DurableInboxStatus = 'pending' | 'processing' | 'processed'; +export type DurableOutboxStatus = 'pending' | 'processing' | 'delivered'; + +export interface DurableInboxInput { + sessionId: string; + idempotencyKey: string; + correlationId: string; + content: string; +} + +export interface DurableInboxEntry extends DurableInboxInput { + status: DurableInboxStatus; +} + +export interface DurableOutboxInput { + sessionId: string; + idempotencyKey: string; + correlationId: string; + channelId: string; + kind: string; + content: string; +} + +export interface DurableOutboxEntry extends DurableOutboxInput { + status: DurableOutboxStatus; +} + +export interface DurableCheckpointInput { + sessionId: string; + checkpointId: string; + cursor: string; + summary: string; + compactionEpoch: number; +} + +export interface DurableCheckpoint extends DurableCheckpointInput {} + +export interface DurableHandoffInput { + sessionId: string; + handoffId: string; + destination: string; + correlationId: string; + checkpointId: string; + status: 'pending' | 'accepted'; +} + +export interface DurableHandoff extends DurableHandoffInput {} + +export interface DurableSessionSnapshot { + identity: DurableSessionIdentity; + inbox: DurableInboxEntry[]; + outbox: DurableOutboxEntry[]; + checkpoint?: DurableCheckpoint; + handoffs: DurableHandoff[]; +} + +export interface DurableHandoffRecovery { + identity: DurableSessionIdentity; + checkpoint: DurableCheckpoint; + handoff: DurableHandoff; +} + +export interface DurableEnqueueResult { + accepted: boolean; + status: TStatus; +} + +/** + * A durable-state port. Implementations must atomically claim and complete work + * records. Recovery may requeue interrupted inbox work, but never an + * externally visible outbox effect: an ambiguous provider result remains + * claimed until a separately authorized reconciliation proves it safe. + */ +export interface DurableSessionStore { + create(identity: DurableSessionIdentity): Promise; + snapshot(sessionId: string): Promise; + enqueueInbox(input: DurableInboxInput): Promise>; + claimInbox(sessionId: string): Promise; + completeInbox(sessionId: string, idempotencyKey: string): Promise; + releaseInbox(sessionId: string, idempotencyKey: string): Promise; + enqueueOutbox(input: DurableOutboxInput): Promise>; + claimOutbox(sessionId: string): Promise; + claimOutboxByKey(sessionId: string, idempotencyKey: string): Promise; + completeOutbox(sessionId: string, idempotencyKey: string): Promise; + releaseOutbox(sessionId: string, idempotencyKey: string): Promise; + checkpoint(input: DurableCheckpointInput): Promise; + findCheckpoint(sessionId: string, checkpointId: string): Promise; + handoff(input: DurableHandoffInput): Promise; + findHandoff(handoffId: string): Promise; + requeueInFlight(sessionId: string): Promise; +} + +export class DurableSessionNotFoundError extends Error { + constructor(sessionId: string) { + super(`Durable session not found: ${sessionId}`); + this.name = 'DurableSessionNotFoundError'; + } +} + +export class DurableSessionCoordinator { + constructor(private readonly store: DurableSessionStore) {} + + async create(identity: DurableSessionIdentity): Promise { + this.assertIdentity(identity); + await this.store.create(identity); + } + + async receive(input: DurableInboxInput): Promise> { + this.assertRecord(input.sessionId, input.idempotencyKey, input.correlationId, input.content); + return this.store.enqueueInbox(input); + } + + async enqueueOutbox( + input: DurableOutboxInput, + ): Promise> { + this.assertRecord( + input.sessionId, + input.idempotencyKey, + input.correlationId, + input.channelId, + input.content, + ); + if (input.kind.trim().length === 0) throw new Error('Durable outbox kind is required'); + return this.store.enqueueOutbox(input); + } + + async checkpoint(input: DurableCheckpointInput): Promise { + this.assertRecord(input.sessionId, input.checkpointId, input.cursor, input.summary); + if (!Number.isInteger(input.compactionEpoch) || input.compactionEpoch < 0) { + throw new Error('Durable checkpoint compaction epoch must be a non-negative integer'); + } + await this.store.checkpoint(input); + } + + async handoff(input: DurableHandoffInput): Promise { + this.assertRecord(input.sessionId, input.handoffId, input.destination, input.correlationId); + if (input.checkpointId.trim().length === 0) + throw new Error('Durable handoff checkpoint is required'); + await this.store.handoff(input); + } + + /** Read durable state without changing claim status; safe during normal operation. */ + async snapshot(sessionId: string): Promise { + const snapshot = await this.store.snapshot(sessionId); + if (!snapshot) throw new DurableSessionNotFoundError(sessionId); + return snapshot; + } + + /** Requeue interrupted inbox work during recovery; preserve ambiguous outbox claims. */ + async recover(sessionId: string): Promise { + await this.store.requeueInFlight(sessionId); + return this.snapshot(sessionId); + } + + async drainInbox( + sessionId: string, + handler: (entry: DurableInboxEntry) => Promise, + ): Promise { + for (;;) { + const entry = await this.store.claimInbox(sessionId); + if (!entry) return; + try { + await handler(entry); + } catch (error: unknown) { + await this.store.releaseInbox(sessionId, entry.idempotencyKey); + throw error; + } + // If this write fails after the handler succeeded, leave the record + // processing. A recovery path can retry it with its stable idempotency key. + await this.store.completeInbox(sessionId, entry.idempotencyKey); + } + } + + async dispatchOutbox( + sessionId: string, + dispatcher: (entry: DurableOutboxEntry) => Promise, + ): Promise { + for (;;) { + const entry = await this.store.claimOutbox(sessionId); + if (!entry) return; + // A provider failure can be ambiguous: it may occur after the receiver + // accepted the idempotency key. Preserve the claim for reconciliation. + await dispatcher(entry); + // Do not requeue an effect after it has been applied but before its + // terminal state could be persisted; recovery preserves the claim. + await this.store.completeOutbox(sessionId, entry.idempotencyKey); + } + } + + async dispatchOutboxEntry( + sessionId: string, + idempotencyKey: string, + dispatcher: (entry: DurableOutboxEntry) => Promise, + ): Promise { + const entry = await this.store.claimOutboxByKey(sessionId, idempotencyKey); + if (!entry) return; + // A provider failure can be ambiguous, so this stays processing until + // separately authorized reconciliation proves it safe to resolve. + await dispatcher(entry); + await this.store.completeOutbox(sessionId, entry.idempotencyKey); + } + + async resumeHandoff(handoffId: string): Promise { + const handoff = await this.store.findHandoff(handoffId); + if (!handoff) throw new Error(`Durable handoff not found: ${handoffId}`); + const snapshot = await this.snapshot(handoff.sessionId); + const checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId); + if (!checkpoint) { + throw new Error(`Durable handoff checkpoint is unavailable: ${handoff.checkpointId}`); + } + return { identity: snapshot.identity, checkpoint, handoff }; + } + + private assertIdentity(identity: DurableSessionIdentity): void { + this.assertRecord( + identity.agentName, + identity.sessionId, + identity.tenantId, + identity.ownerId, + identity.providerId, + ); + if (identity.runtimeSessionId.trim().length === 0) { + throw new Error('Durable runtime session identity is required'); + } + } + + private assertRecord(...values: string[]): void { + if (values.some((value: string): boolean => value.trim().length === 0)) { + throw new Error('Durable session records require non-empty fields'); + } + } +} + +interface InMemorySessionState { + identity: DurableSessionIdentity; + inbox: Map; + outbox: Map; + checkpoints: Map; + handoffs: Map; +} + +/** Reference store for deterministic domain tests; production uses the gateway DB adapter. */ +export class InMemoryDurableSessionStore implements DurableSessionStore { + private readonly sessions = new Map(); + + async create(identity: DurableSessionIdentity): Promise { + const existing = this.sessions.get(identity.sessionId); + if (existing) { + if (!sameEnrollmentScope(existing.identity, identity)) { + throw new Error(`Durable session identity conflict: ${identity.sessionId}`); + } + existing.identity.providerId = identity.providerId; + existing.identity.runtimeSessionId = identity.runtimeSessionId; + return; + } + this.sessions.set(identity.sessionId, { + identity: copyIdentity(identity), + inbox: new Map(), + outbox: new Map(), + checkpoints: new Map(), + handoffs: new Map(), + }); + } + + async snapshot(sessionId: string): Promise { + const state = this.sessions.get(sessionId); + if (!state) return null; + const checkpoint = latestCheckpoint(state.checkpoints); + return { + identity: copyIdentity(state.identity), + inbox: [...state.inbox.values()].map(copyInbox), + outbox: [...state.outbox.values()].map(copyOutbox), + ...(checkpoint ? { checkpoint: copyCheckpoint(checkpoint) } : {}), + handoffs: [...state.handoffs.values()].map(copyHandoff), + }; + } + + async enqueueInbox(input: DurableInboxInput): Promise> { + const state = this.require(input.sessionId); + const existing = state.inbox.get(input.idempotencyKey); + if (existing) { + if (!sameInbox(existing, input)) { + throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`); + } + return { accepted: false, status: existing.status }; + } + state.inbox.set(input.idempotencyKey, { ...input, status: 'pending' }); + return { accepted: true, status: 'pending' }; + } + + async claimInbox(sessionId: string): Promise { + const state = this.require(sessionId); + const entry = [...state.inbox.values()].find( + (candidate: DurableInboxEntry): boolean => candidate.status === 'pending', + ); + if (!entry) return null; + entry.status = 'processing'; + return copyInbox(entry); + } + + async completeInbox(sessionId: string, idempotencyKey: string): Promise { + this.requireEntry(this.require(sessionId).inbox, idempotencyKey, 'inbox').status = 'processed'; + } + + async releaseInbox(sessionId: string, idempotencyKey: string): Promise { + this.requireEntry(this.require(sessionId).inbox, idempotencyKey, 'inbox').status = 'pending'; + } + + async enqueueOutbox( + input: DurableOutboxInput, + ): Promise> { + const state = this.require(input.sessionId); + const existing = state.outbox.get(input.idempotencyKey); + if (existing) { + if (!sameOutbox(existing, input)) { + throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`); + } + return { accepted: false, status: existing.status }; + } + state.outbox.set(input.idempotencyKey, { ...input, status: 'pending' }); + return { accepted: true, status: 'pending' }; + } + + async claimOutbox(sessionId: string): Promise { + const state = this.require(sessionId); + const entry = [...state.outbox.values()].find( + (candidate: DurableOutboxEntry): boolean => candidate.status === 'pending', + ); + if (!entry) return null; + entry.status = 'processing'; + return copyOutbox(entry); + } + + async claimOutboxByKey( + sessionId: string, + idempotencyKey: string, + ): Promise { + const entry = this.require(sessionId).outbox.get(idempotencyKey); + if (!entry || entry.status !== 'pending') return null; + entry.status = 'processing'; + return copyOutbox(entry); + } + + async completeOutbox(sessionId: string, idempotencyKey: string): Promise { + this.requireEntry(this.require(sessionId).outbox, idempotencyKey, 'outbox').status = + 'delivered'; + } + + async releaseOutbox(sessionId: string, idempotencyKey: string): Promise { + this.requireEntry(this.require(sessionId).outbox, idempotencyKey, 'outbox').status = 'pending'; + } + + async checkpoint(input: DurableCheckpointInput): Promise { + const checkpoints = this.require(input.sessionId).checkpoints; + const existing = checkpoints.get(input.checkpointId); + if (existing && !sameCheckpoint(existing, input)) { + throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`); + } + if (!existing) checkpoints.set(input.checkpointId, { ...input }); + } + + async findCheckpoint(sessionId: string, checkpointId: string): Promise { + const checkpoint = this.require(sessionId).checkpoints.get(checkpointId); + return checkpoint ? copyCheckpoint(checkpoint) : null; + } + + async handoff(input: DurableHandoffInput): Promise { + const state = this.require(input.sessionId); + if (!state.checkpoints.has(input.checkpointId)) { + throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`); + } + const existing = state.handoffs.get(input.handoffId); + if (existing && !sameHandoff(existing, input)) { + throw new Error(`Durable handoff identity conflict: ${input.handoffId}`); + } + if (!existing) state.handoffs.set(input.handoffId, { ...input }); + } + + async findHandoff(handoffId: string): Promise { + for (const state of this.sessions.values()) { + const handoff = state.handoffs.get(handoffId); + if (handoff) return copyHandoff(handoff); + } + return null; + } + + async requeueInFlight(sessionId: string): Promise { + const state = this.require(sessionId); + for (const entry of state.inbox.values()) { + if (entry.status === 'processing') entry.status = 'pending'; + } + } + + private require(sessionId: string): InMemorySessionState { + const state = this.sessions.get(sessionId); + if (!state) throw new DurableSessionNotFoundError(sessionId); + return state; + } + + private requireEntry( + records: Map, + idempotencyKey: string, + kind: string, + ): T { + const entry = records.get(idempotencyKey); + if (!entry) throw new Error(`Durable ${kind} entry not found: ${idempotencyKey}`); + return entry; + } +} + +function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean { + return ( + left.agentName === right.agentName && + left.sessionId === right.sessionId && + left.tenantId === right.tenantId && + left.ownerId === right.ownerId + ); +} + +function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean { + return ( + left.sessionId === right.sessionId && + left.idempotencyKey === right.idempotencyKey && + left.correlationId === right.correlationId && + left.content === right.content + ); +} + +function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean { + return ( + left.sessionId === right.sessionId && + left.idempotencyKey === right.idempotencyKey && + left.correlationId === right.correlationId && + left.channelId === right.channelId && + left.kind === right.kind && + left.content === right.content + ); +} + +function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean { + return ( + left.sessionId === right.sessionId && + left.checkpointId === right.checkpointId && + left.cursor === right.cursor && + left.summary === right.summary && + left.compactionEpoch === right.compactionEpoch + ); +} + +function latestCheckpoint( + checkpoints: Map, +): DurableCheckpoint | undefined { + return [...checkpoints.values()].sort( + (left: DurableCheckpoint, right: DurableCheckpoint): number => + right.compactionEpoch - left.compactionEpoch, + )[0]; +} + +function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean { + return ( + left.sessionId === right.sessionId && + left.handoffId === right.handoffId && + left.destination === right.destination && + left.correlationId === right.correlationId && + left.checkpointId === right.checkpointId && + left.status === right.status + ); +} + +function copyIdentity(identity: DurableSessionIdentity): DurableSessionIdentity { + return { ...identity }; +} + +function copyInbox(entry: DurableInboxEntry): DurableInboxEntry { + return { ...entry }; +} + +function copyOutbox(entry: DurableOutboxEntry): DurableOutboxEntry { + return { ...entry }; +} + +function copyCheckpoint(checkpoint: DurableCheckpoint): DurableCheckpoint { + return { ...checkpoint }; +} + +function copyHandoff(handoff: DurableHandoff): DurableHandoff { + return { ...handoff }; +} diff --git a/packages/agent/src/hermes-runtime-provider.test.ts b/packages/agent/src/hermes-runtime-provider.test.ts new file mode 100644 index 00000000..e5577ee2 --- /dev/null +++ b/packages/agent/src/hermes-runtime-provider.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { RuntimeScope } from '@mosaicstack/types'; +import { HermesRuntimeProvider, type HermesRuntimeTransport } from './hermes-runtime-provider.js'; + +const scope: RuntimeScope = { actorId: 'a', tenantId: 't', channelId: 'c', correlationId: 'r' }; +const transport = (capabilities = ['session.list', 'session.tree']): HermesRuntimeTransport => ({ + capabilities: vi.fn(async () => capabilities), + health: vi.fn(async () => ({ status: 'healthy' })), + sessions: vi.fn(async () => [ + { + conversation_id: 'child', + agent_id: 'hermes-a', + parent_conversation_id: 'parent', + status: 'running', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + { + conversation_id: 'parent', + agent_id: 'hermes-a', + status: 'unknown', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ]), + stream: async function* () {}, + send: vi.fn(), + attach: vi.fn(), + detach: vi.fn(), + terminate: vi.fn(), +}); +describe('HermesRuntimeProvider normalization boundary', () => { + it('returns an exhaustive fail-closed transitional capability matrix', async () => { + const provider = new HermesRuntimeProvider(transport()); + + await expect(provider.transitionalCapabilityMatrix(scope)).resolves.toEqual([ + { capability: 'kanban', status: 'unsupported' }, + { capability: 'skills', status: 'unsupported' }, + { capability: 'memory', status: 'unsupported' }, + { capability: 'tools', status: 'unsupported' }, + { capability: 'cron', status: 'unsupported' }, + ]); + }); + + it('denies unsupported transitional capabilities without calling Hermes', async () => { + const hermes = transport(); + const provider = new HermesRuntimeProvider(hermes); + + await expect(provider.assertTransitionalCapability('memory', scope)).rejects.toMatchObject({ + code: 'capability_unsupported', + }); + expect(hermes.capabilities).not.toHaveBeenCalled(); + }); + + it('normalizes legacy sessions without exposing legacy fields', async () => { + const provider = new HermesRuntimeProvider(transport()); + await expect(provider.listSessions(scope)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'child', runtimeId: 'hermes-a', state: 'active' }), + ]), + ); + const result = await provider.listSessions(scope); + expect(result[0]).not.toHaveProperty('conversation_id'); + }); + it('forms normalized hierarchy and fails closed for unbridged operations', async () => { + const provider = new HermesRuntimeProvider(transport()); + await expect(provider.getSessionTree(scope)).resolves.toEqual([ + expect.objectContaining({ + session: expect.objectContaining({ id: 'parent', state: 'failed' }), + children: [expect.objectContaining({ session: expect.objectContaining({ id: 'child' }) })], + }), + ]); + await expect( + provider.sendMessage('parent', { content: 'x', idempotencyKey: 'i' }, scope), + ).rejects.toMatchObject({ code: 'capability_unsupported' }); + }); +}); diff --git a/packages/agent/src/hermes-runtime-provider.ts b/packages/agent/src/hermes-runtime-provider.ts new file mode 100644 index 00000000..8e4bb51f --- /dev/null +++ b/packages/agent/src/hermes-runtime-provider.ts @@ -0,0 +1,217 @@ +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapability, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionState, + RuntimeSessionTree, + RuntimeStreamEvent, + TransitionalCapabilityInventoryEntry, + TransitionalCapabilityInventoryProvider, + TransitionalRuntimeCapability, +} from '@mosaicstack/types'; + +const HERMES_PROVIDER_ID = 'runtime.hermes'; +const TRANSITIONAL_CAPABILITIES: readonly TransitionalRuntimeCapability[] = [ + 'kanban', + 'skills', + 'memory', + 'tools', + 'cron', +]; +const RUNTIME_CAPABILITIES: readonly RuntimeCapability[] = [ + 'session.list', + 'session.tree', + 'session.stream', + 'session.send', + 'session.attach', + 'session.terminate', +]; + +/** Legacy transport boundary. These shapes are intentionally adapter-local. */ +export interface HermesLegacySession { + conversation_id: string; + agent_id: string; + parent_conversation_id?: string; + status: string; + created_at: string; + updated_at: string; +} +export interface HermesRuntimeTransport { + capabilities(scope: RuntimeScope): Promise; + health(scope: RuntimeScope): Promise<{ status: string; detail?: string }>; + sessions(scope: RuntimeScope): Promise; + stream( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable; + send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise; + attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise; + detach(attachmentId: string, scope: RuntimeScope): Promise; + terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise; +} + +export class HermesRuntimeProviderError extends Error { + constructor( + readonly code: 'capability_unsupported' | 'invalid_request', + message: string, + ) { + super(message); + this.name = HermesRuntimeProviderError.name; + } +} + +/** + * Transitional Hermes adapter. Legacy identifiers and schemas do not cross this + * boundary: callers only observe Mosaic AgentRuntimeProvider contracts. + */ +export class HermesRuntimeProvider + implements AgentRuntimeProvider, TransitionalCapabilityInventoryProvider +{ + readonly id = HERMES_PROVIDER_ID; + + constructor(private readonly transport: HermesRuntimeTransport) {} + + /** + * Full AC-TESS-05 migration inventory. These operations are deliberately + * unsupported until their Mosaic-owned plugin contracts exist. + */ + async transitionalCapabilityMatrix( + _scope: RuntimeScope, + ): Promise { + return TRANSITIONAL_CAPABILITIES.map((capability) => ({ capability, status: 'unsupported' })); + } + + async assertTransitionalCapability( + capability: TransitionalRuntimeCapability, + scope: RuntimeScope, + ): Promise { + const entry = (await this.transitionalCapabilityMatrix(scope)).find( + (candidate) => candidate.capability === capability, + ); + if (!entry || entry.status !== 'supported') { + throw new HermesRuntimeProviderError( + 'capability_unsupported', + `Hermes transitional capability is unsupported: ${capability}`, + ); + } + } + + async capabilities(scope: RuntimeScope): Promise { + const legacyCapabilities = await this.transport.capabilities(scope); + return { + supported: RUNTIME_CAPABILITIES.filter((capability) => + legacyCapabilities.includes(capability), + ), + }; + } + + async health(scope: RuntimeScope): Promise { + const health = await this.transport.health(scope); + return { + status: health.status === 'healthy' || health.status === 'degraded' ? health.status : 'down', + checkedAt: new Date().toISOString(), + ...(health.detail ? { detail: health.detail } : {}), + }; + } + + async listSessions(scope: RuntimeScope): Promise { + await this.requireCapability('session.list', scope); + return (await this.transport.sessions(scope)).map((session) => this.session(session)); + } + + async getSessionTree(scope: RuntimeScope): Promise { + await this.requireCapability('session.tree', scope); + const sessions = (await this.transport.sessions(scope)).map((session) => this.session(session)); + const nodes = new Map( + sessions.map((session) => [session.id, { session, children: [] }]), + ); + const roots: RuntimeSessionTree[] = []; + for (const session of sessions) { + const node = nodes.get(session.id)!; + const parent = session.parentSessionId ? nodes.get(session.parentSessionId) : undefined; + if (parent) parent.children.push(node); + else roots.push(node); + } + return roots; + } + + async *streamSession( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable { + await this.requireCapability('session.stream', scope); + yield* this.transport.stream(sessionId, cursor, scope); + } + async sendMessage( + sessionId: string, + message: RuntimeMessage, + scope: RuntimeScope, + ): Promise { + await this.requireCapability('session.send', scope); + if (!message.content.trim()) + throw new HermesRuntimeProviderError('invalid_request', 'Message content is required'); + await this.transport.send(sessionId, message, scope); + } + async attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise { + await this.requireCapability('session.attach', scope); + return this.transport.attach(sessionId, mode, scope); + } + async detach(attachmentId: string, scope: RuntimeScope): Promise { + await this.transport.detach(attachmentId, scope); + } + async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise { + await this.requireCapability('session.terminate', scope); + if (!approvalRef.trim()) + throw new HermesRuntimeProviderError('invalid_request', 'Termination approval is required'); + await this.transport.terminate(sessionId, approvalRef, scope); + } + + private async requireCapability( + capability: RuntimeCapability, + scope: RuntimeScope, + ): Promise { + if (!(await this.capabilities(scope)).supported.includes(capability)) { + throw new HermesRuntimeProviderError( + 'capability_unsupported', + `Hermes does not bridge ${capability}`, + ); + } + } + private session(value: HermesLegacySession): RuntimeSession { + return { + id: value.conversation_id, + providerId: this.id, + runtimeId: value.agent_id, + ...(value.parent_conversation_id ? { parentSessionId: value.parent_conversation_id } : {}), + state: state(value.status), + createdAt: value.created_at, + updatedAt: value.updated_at, + }; + } +} +function state(value: string): RuntimeSessionState { + return ( + ( + { running: 'active', waiting: 'idle', starting: 'starting', stopped: 'stopped' } as Record< + string, + RuntimeSessionState + > + )[value] ?? 'failed' + ); +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 0c18d5d9..3eabafb4 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1 +1,8 @@ export const VERSION = '0.0.0'; + +export * from './runtime-provider-registry.js'; +export * from './tmux-fleet-runtime-provider.js'; +export * from './hermes-runtime-provider.js'; +export * from './matrix-native-runtime-provider.js'; +export * from './durable-session.js'; +export * from './connector-lease.js'; diff --git a/packages/agent/src/matrix-native-runtime-provider.test.ts b/packages/agent/src/matrix-native-runtime-provider.test.ts new file mode 100644 index 00000000..01631dc0 --- /dev/null +++ b/packages/agent/src/matrix-native-runtime-provider.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { RuntimeScope, RuntimeStreamEvent } from '@mosaicstack/types'; +import { + type MatrixRuntimeSession, + type MatrixRuntimeTransport, + MatrixNativeRuntimeProvider, + type MatrixReadAuthority, + type MatrixWriteAuthority, +} from './matrix-native-runtime-provider.js'; + +const scope: RuntimeScope = { + actorId: 'operator-1', + tenantId: 'tenant-a', + channelId: 'matrix-control', + correlationId: 'corr-1', +}; + +const session: MatrixRuntimeSession = { + id: 'native-1', + runtimeId: '@worker:example.test', + state: 'active', + createdAt: '2026-07-13T00:00:00.000Z', + updatedAt: '2026-07-13T00:00:00.000Z', +}; + +function transport(): MatrixRuntimeTransport { + return { + health: vi.fn(async () => ({ + status: 'healthy' as const, + checkedAt: '2026-07-13T00:00:00.000Z', + })), + listSessions: vi.fn(async () => [session]), + verifySession: vi.fn(async (sessionId) => { + if (sessionId !== session.id) throw new Error('unexpected session'); + return session; + }), + stream: vi.fn(async function* (): AsyncIterable { + yield { + type: 'message.delta', + sessionId: 'native-1', + cursor: 'cursor-1', + occurredAt: '2026-07-13T00:00:00.000Z', + content: 'hello', + }; + }), + send: vi.fn(async () => undefined), + terminate: vi.fn(async () => undefined), + }; +} + +function readAuthority(): MatrixReadAuthority { + return { canRead: vi.fn(async () => true) }; +} + +function writeAuthority(): MatrixWriteAuthority { + return { + canWrite: vi.fn(async () => true), + assertAuthorized: vi.fn(async () => undefined), + }; +} + +describe('MatrixNativeRuntimeProvider contract boundary', (): void => { + it('advertises the concrete Matrix operations and returns only normalized sessions', async (): Promise => { + const provider = new MatrixNativeRuntimeProvider({ + transport: transport(), + readAuthority: readAuthority(), + }); + + await expect(provider.capabilities(scope)).resolves.toEqual({ + supported: [ + 'session.list', + 'session.tree', + 'session.stream', + 'session.send', + 'session.attach', + 'session.terminate', + ], + }); + await expect(provider.listSessions(scope)).resolves.toEqual([ + expect.objectContaining({ id: 'native-1', providerId: 'runtime.matrix' }), + ]); + }); + + it('binds read attachments to immutable scope and rejects control mode before transport access', async (): Promise => { + const matrix = transport(); + const provider = new MatrixNativeRuntimeProvider({ + transport: matrix, + readAuthority: readAuthority(), + attachmentIdFactory: () => 'attachment-1', + now: () => new Date('2026-07-13T00:00:00.000Z'), + }); + + await expect(provider.attach('native-1', 'control', scope)).rejects.toMatchObject({ + code: 'forbidden', + }); + expect(matrix.verifySession).not.toHaveBeenCalled(); + + await provider.attach('native-1', 'read', scope); + await expect( + provider.detach('attachment-1', { ...scope, tenantId: 'other' }), + ).rejects.toMatchObject({ + code: 'forbidden', + }); + }); + + it('validates messages and applies exact Matrix authority after bound-session verification', async (): Promise => { + const matrix = transport(); + const writes = writeAuthority(); + const provider = new MatrixNativeRuntimeProvider({ + transport: matrix, + readAuthority: readAuthority(), + writeAuthority: writes, + }); + + await expect( + provider.sendMessage('native-1', { content: '', idempotencyKey: 'msg-1' }, scope), + ).rejects.toMatchObject({ + code: 'invalid_request', + }); + expect(matrix.verifySession).not.toHaveBeenCalled(); + + await provider.sendMessage('native-1', { content: 'hello', idempotencyKey: 'msg-1' }, scope); + expect(writes.assertAuthorized).toHaveBeenCalledWith({ + operation: 'session.send', + sessionId: 'native-1', + scope, + }); + expect(matrix.send).toHaveBeenCalledWith( + 'native-1', + { content: 'hello', idempotencyKey: 'msg-1' }, + scope, + ); + }); + + it('streams only after exact read authorization', async (): Promise => { + const matrix = transport(); + const provider = new MatrixNativeRuntimeProvider({ + transport: matrix, + readAuthority: readAuthority(), + }); + + await expect(collect(provider.streamSession('native-1', 'cursor-0', scope))).resolves.toEqual([ + expect.objectContaining({ type: 'message.delta', sessionId: 'native-1' }), + ]); + expect(matrix.stream).toHaveBeenCalledWith('native-1', 'cursor-0', scope); + }); +}); + +async function collect(stream: AsyncIterable): Promise { + const values: RuntimeStreamEvent[] = []; + for await (const value of stream) values.push(value); + return values; +} diff --git a/packages/agent/src/matrix-native-runtime-provider.ts b/packages/agent/src/matrix-native-runtime-provider.ts new file mode 100644 index 00000000..c32a1366 --- /dev/null +++ b/packages/agent/src/matrix-native-runtime-provider.ts @@ -0,0 +1,351 @@ +import { randomUUID } from 'node:crypto'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; + +const MATRIX_PROVIDER_ID = 'runtime.matrix'; +const ATTACHMENT_TTL_MS = 5 * 60 * 1_000; + +/** A verified native runtime session. Matrix room and event details remain transport-local. */ +export interface MatrixRuntimeSession { + id: string; + runtimeId: string; + parentSessionId?: string; + state: RuntimeSession['state']; + createdAt: string; + updatedAt: string; +} + +/** + * Narrow native Matrix boundary. The concrete Mosaic transport owns homeserver + * authentication, exact room mapping, Matrix identity checks, and replay cursors. + */ +export interface MatrixRuntimeTransport { + health(scope: RuntimeScope): Promise; + listSessions(scope: RuntimeScope): Promise; + verifySession(sessionId: string, scope: RuntimeScope): Promise; + stream( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable; + send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise; + terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise; +} + +export type MatrixRuntimeProviderErrorCode = + | 'capability_unsupported' + | 'forbidden' + | 'invalid_request' + | 'not_found'; + +export class MatrixRuntimeProviderError extends Error { + constructor( + readonly code: MatrixRuntimeProviderErrorCode, + message: string, + ) { + super(message); + this.name = MatrixRuntimeProviderError.name; + } +} + +export type MatrixReadOperation = + | 'runtime.health' + | 'session.list' + | 'session.tree' + | 'session.stream' + | 'session.attach'; + +export interface MatrixReadAuthority { + canRead(input: { + operation: MatrixReadOperation; + scope: RuntimeScope; + sessionId?: string; + }): Promise; +} + +export interface MatrixWriteAuthority { + canWrite(input: { + operation: 'session.send' | 'session.terminate'; + sessionId: string; + scope: RuntimeScope; + approvalRef?: string; + }): Promise; + assertAuthorized(input: { + operation: 'session.send' | 'session.terminate'; + sessionId: string; + scope: RuntimeScope; + approvalRef?: string; + }): Promise; +} + +export interface MatrixNativeRuntimeProviderOptions { + transport: MatrixRuntimeTransport; + readAuthority?: MatrixReadAuthority; + writeAuthority?: MatrixWriteAuthority; + attachmentIdFactory?: () => string; + now?: () => Date; + attachmentTtlMs?: number; +} + +interface Attachment { + sessionId: string; + scope: RuntimeScope; + expiresAtMs: number; +} + +class DenyMatrixReadAuthority implements MatrixReadAuthority { + async canRead(): Promise { + return false; + } +} + +class DenyMatrixWriteAuthority implements MatrixWriteAuthority { + async canWrite(): Promise { + return false; + } + + async assertAuthorized(): Promise { + throw new MatrixRuntimeProviderError( + 'forbidden', + 'Matrix runtime writes require orchestrator authority', + ); + } +} + +/** + * Native Matrix adapter behind the Mosaic runtime contract. It accepts only + * stable session IDs; room identifiers, Matrix event schemas, and credentials + * are deliberately confined to the concrete transport implementation. + */ +export class MatrixNativeRuntimeProvider implements AgentRuntimeProvider { + readonly id = MATRIX_PROVIDER_ID; + private readonly readAuthority: MatrixReadAuthority; + private readonly writeAuthority: MatrixWriteAuthority; + private readonly attachmentIdFactory: () => string; + private readonly now: () => Date; + private readonly attachmentTtlMs: number; + private readonly attachments = new Map(); + + constructor(private readonly options: MatrixNativeRuntimeProviderOptions) { + this.readAuthority = options.readAuthority ?? new DenyMatrixReadAuthority(); + this.writeAuthority = options.writeAuthority ?? new DenyMatrixWriteAuthority(); + this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID; + this.now = options.now ?? (() => new Date()); + this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS; + } + + async capabilities(_scope: RuntimeScope): Promise { + return { + supported: [ + 'session.list', + 'session.tree', + 'session.stream', + 'session.send', + 'session.attach', + 'session.terminate', + ], + }; + } + + async health(scope: RuntimeScope): Promise { + await this.assertRead('runtime.health', undefined, scope); + return this.options.transport.health(scope); + } + + async listSessions(scope: RuntimeScope): Promise { + await this.assertRead('session.list', undefined, scope); + const sessions = await this.options.transport.listSessions(scope); + const visible = await Promise.all( + sessions.map((session) => + this.readAuthority.canRead({ operation: 'session.list', sessionId: session.id, scope }), + ), + ); + return sessions + .filter((_session, index) => visible[index] === true) + .map((session) => this.runtimeSession(session)); + } + + async getSessionTree(scope: RuntimeScope): Promise { + await this.assertRead('session.tree', undefined, scope); + const sessions = await this.options.transport.listSessions(scope); + const visible = await Promise.all( + sessions.map((session) => + this.readAuthority.canRead({ operation: 'session.tree', sessionId: session.id, scope }), + ), + ); + const runtimeSessions = sessions + .filter((_session, index) => visible[index] === true) + .map((session) => this.runtimeSession(session)); + const nodes = new Map( + runtimeSessions.map((session) => [session.id, { session, children: [] }]), + ); + const roots: RuntimeSessionTree[] = []; + for (const session of runtimeSessions) { + const node = nodes.get(session.id)!; + const parent = session.parentSessionId ? nodes.get(session.parentSessionId) : undefined; + if (parent) parent.children.push(node); + else roots.push(node); + } + return roots; + } + + async *streamSession( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable { + await this.assertRead('session.stream', sessionId, scope); + const session = await this.options.transport.verifySession(sessionId, scope); + await this.assertRead('session.stream', session.id, scope); + yield* this.options.transport.stream(session.id, cursor, scope); + } + + async sendMessage( + sessionId: string, + message: RuntimeMessage, + scope: RuntimeScope, + ): Promise { + if (!message.content.trim()) { + throw new MatrixRuntimeProviderError( + 'invalid_request', + 'Matrix runtime message content is required', + ); + } + await this.assertWritePermitted('session.send', sessionId, scope); + const session = await this.options.transport.verifySession(sessionId, scope); + await this.assertWriteAuthorized('session.send', session.id, scope); + await this.options.transport.send(session.id, message, scope); + } + + async attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise { + if (mode !== 'read') { + throw new MatrixRuntimeProviderError('forbidden', 'Matrix control attach is not permitted'); + } + await this.assertRead('session.attach', sessionId, scope); + const session = await this.options.transport.verifySession(sessionId, scope); + await this.assertRead('session.attach', session.id, scope); + const nowMs = this.now().getTime(); + this.pruneExpired(nowMs); + const attachmentId = this.attachmentIdFactory(); + const expiresAtMs = nowMs + this.attachmentTtlMs; + this.attachments.set(attachmentId, { + sessionId: session.id, + scope: snapshotScope(scope), + expiresAtMs, + }); + return { + attachmentId, + sessionId: session.id, + mode, + expiresAt: new Date(expiresAtMs).toISOString(), + }; + } + + async detach(attachmentId: string, scope: RuntimeScope): Promise { + const attachment = this.attachments.get(attachmentId); + if (!attachment) + throw new MatrixRuntimeProviderError('not_found', 'Matrix attachment is not active'); + if (this.now().getTime() >= attachment.expiresAtMs) { + this.attachments.delete(attachmentId); + throw new MatrixRuntimeProviderError('forbidden', 'Matrix attachment has expired'); + } + if (!sameScope(attachment.scope, scope)) { + throw new MatrixRuntimeProviderError('forbidden', 'Matrix attachment scope does not match'); + } + this.attachments.delete(attachmentId); + } + + async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise { + if (!approvalRef.trim()) { + throw new MatrixRuntimeProviderError( + 'invalid_request', + 'Matrix termination approval is required', + ); + } + await this.assertWritePermitted('session.terminate', sessionId, scope, approvalRef); + const session = await this.options.transport.verifySession(sessionId, scope); + await this.assertWriteAuthorized('session.terminate', session.id, scope, approvalRef); + await this.options.transport.terminate(session.id, approvalRef, scope); + } + + private runtimeSession(session: MatrixRuntimeSession): RuntimeSession { + return { ...session, providerId: this.id }; + } + + private async assertRead( + operation: MatrixReadOperation, + sessionId: string | undefined, + scope: RuntimeScope, + ): Promise { + const allowed = await this.readAuthority.canRead({ + operation, + scope, + ...(sessionId ? { sessionId } : {}), + }); + if (!allowed) + throw new MatrixRuntimeProviderError('forbidden', 'Matrix runtime read is not authorized'); + } + + private async assertWritePermitted( + operation: 'session.send' | 'session.terminate', + sessionId: string, + scope: RuntimeScope, + approvalRef?: string, + ): Promise { + const allowed = await this.writeAuthority.canWrite({ + operation, + sessionId, + scope, + ...(approvalRef ? { approvalRef } : {}), + }); + if (!allowed) + throw new MatrixRuntimeProviderError('forbidden', 'Matrix runtime write is not authorized'); + } + + private async assertWriteAuthorized( + operation: 'session.send' | 'session.terminate', + sessionId: string, + scope: RuntimeScope, + approvalRef?: string, + ): Promise { + await this.writeAuthority.assertAuthorized({ + operation, + sessionId, + scope, + ...(approvalRef ? { approvalRef } : {}), + }); + } + + private pruneExpired(nowMs: number): void { + for (const [id, attachment] of this.attachments) { + if (attachment.expiresAtMs <= nowMs) this.attachments.delete(id); + } + } +} + +function snapshotScope(scope: RuntimeScope): RuntimeScope { + return Object.freeze({ ...scope }); +} + +function sameScope(left: RuntimeScope, right: RuntimeScope): boolean { + return ( + left.actorId === right.actorId && + left.tenantId === right.tenantId && + left.channelId === right.channelId && + left.correlationId === right.correlationId + ); +} diff --git a/packages/agent/src/runtime-provider-parity.test.ts b/packages/agent/src/runtime-provider-parity.test.ts new file mode 100644 index 00000000..e99a76f9 --- /dev/null +++ b/packages/agent/src/runtime-provider-parity.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AgentRuntimeProvider, RuntimeScope } from '@mosaicstack/types'; +import { + MatrixNativeRuntimeProvider, + type MatrixRuntimeTransport, +} from './matrix-native-runtime-provider.js'; +import { + TmuxFleetRuntimeProvider, + type FleetRuntimeTransport, +} from './tmux-fleet-runtime-provider.js'; + +const scope: RuntimeScope = { + actorId: 'operator-1', + tenantId: 'tenant-a', + channelId: 'cli', + correlationId: 'corr-1', +}; + +interface ProviderFixture { + name: string; + provider: AgentRuntimeProvider; + providerId: string; + verifySession: unknown; + send: unknown; +} + +function fixtures(): ProviderFixture[] { + const fleetTransport: FleetRuntimeTransport = { + verifySession: vi.fn(async () => ({ + id: 'session-1', + runtimeId: 'native-1', + socketName: 'fleet', + })), + listSessions: vi.fn(async () => [ + { id: 'session-1', runtimeId: 'native-1', socketName: 'fleet' }, + ]), + sendMessage: vi.fn(async () => undefined), + terminate: vi.fn(async () => undefined), + }; + const fleet = new TmuxFleetRuntimeProvider({ + transport: fleetTransport, + readAuthority: { canRead: vi.fn(async () => true) }, + writeAuthority: { + canWrite: vi.fn(async () => true), + assertAuthorized: vi.fn(async () => undefined), + }, + attachmentIdFactory: () => 'attachment-1', + now: () => new Date('2026-07-13T00:00:00.000Z'), + }); + + const matrixTransport: MatrixRuntimeTransport = { + health: vi.fn(async () => ({ + status: 'healthy' as const, + checkedAt: '2026-07-13T00:00:00.000Z', + })), + verifySession: vi.fn(async () => ({ + id: 'session-1', + runtimeId: 'native-1', + state: 'active' as const, + createdAt: '2026-07-13T00:00:00.000Z', + updatedAt: '2026-07-13T00:00:00.000Z', + })), + listSessions: vi.fn(async () => [ + { + id: 'session-1', + runtimeId: 'native-1', + state: 'active' as const, + createdAt: '2026-07-13T00:00:00.000Z', + updatedAt: '2026-07-13T00:00:00.000Z', + }, + ]), + stream: async function* () {}, + send: vi.fn(async () => undefined), + terminate: vi.fn(async () => undefined), + }; + const matrix = new MatrixNativeRuntimeProvider({ + transport: matrixTransport, + readAuthority: { canRead: vi.fn(async () => true) }, + writeAuthority: { + canWrite: vi.fn(async () => true), + assertAuthorized: vi.fn(async () => undefined), + }, + attachmentIdFactory: () => 'attachment-1', + now: () => new Date('2026-07-13T00:00:00.000Z'), + }); + + return [ + { + name: 'tmux/fleet', + provider: fleet, + providerId: 'fleet.tmux', + verifySession: fleetTransport.verifySession, + send: fleetTransport.sendMessage, + }, + { + name: 'Matrix/native', + provider: matrix, + providerId: 'runtime.matrix', + verifySession: matrixTransport.verifySession, + send: matrixTransport.send, + }, + ]; +} + +/** Shared contract tests for the migration-safe provider intersection. */ +describe('tmux/fleet and Matrix/native provider parity', (): void => { + it.each(fixtures())( + '%s exposes the shared runtime operations', + async (fixture): Promise => { + await expect(fixture.provider.capabilities(scope)).resolves.toEqual( + expect.objectContaining({ + supported: expect.arrayContaining([ + 'session.list', + 'session.tree', + 'session.send', + 'session.attach', + 'session.terminate', + ]), + }), + ); + await expect(fixture.provider.listSessions(scope)).resolves.toEqual([ + expect.objectContaining({ + id: 'session-1', + providerId: fixture.providerId, + runtimeId: 'native-1', + }), + ]); + }, + ); + + it.each(fixtures())( + '%s rejects empty messages before touching its transport', + async (fixture): Promise => { + await expect( + fixture.provider.sendMessage( + 'session-1', + { content: '', idempotencyKey: 'message-1' }, + scope, + ), + ).rejects.toMatchObject({ code: 'invalid_request' }); + expect(fixture.verifySession).not.toHaveBeenCalled(); + expect(fixture.send).not.toHaveBeenCalled(); + }, + ); + + it.each(fixtures())( + '%s rejects an empty termination approval before touching its transport', + async (fixture): Promise => { + await expect(fixture.provider.terminate('session-1', '', scope)).rejects.toMatchObject({ + code: 'invalid_request', + }); + expect(fixture.verifySession).not.toHaveBeenCalled(); + }, + ); + + it.each(fixtures())( + '%s creates a read-only handle bound to immutable scope', + async (fixture): Promise => { + await expect(fixture.provider.attach('session-1', 'control', scope)).rejects.toMatchObject({ + code: 'forbidden', + }); + expect(fixture.verifySession).not.toHaveBeenCalled(); + + await expect(fixture.provider.attach('session-1', 'read', scope)).resolves.toEqual( + expect.objectContaining({ + attachmentId: 'attachment-1', + sessionId: 'session-1', + mode: 'read', + }), + ); + await expect( + fixture.provider.detach('attachment-1', { ...scope, actorId: 'operator-2' }), + ).rejects.toMatchObject({ code: 'forbidden' }); + }, + ); +}); diff --git a/packages/agent/src/runtime-provider-registry.test.ts b/packages/agent/src/runtime-provider-registry.test.ts new file mode 100644 index 00000000..4e88f080 --- /dev/null +++ b/packages/agent/src/runtime-provider-registry.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; +import { AgentRuntimeProviderRegistry } from './runtime-provider-registry.js'; + +class TestRuntimeProvider implements AgentRuntimeProvider { + readonly id = 'test-runtime'; + + async capabilities(_scope: RuntimeScope): Promise { + return { supported: ['session.list'] }; + } + + async health(_scope: RuntimeScope): Promise { + return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' }; + } + + async listSessions(_scope: RuntimeScope): Promise { + return []; + } + + async getSessionTree(_scope: RuntimeScope): Promise { + return []; + } + + async *streamSession( + _sessionId: string, + _cursor: string | undefined, + _scope: RuntimeScope, + ): AsyncIterable { + return; + } + + async sendMessage( + _sessionId: string, + _message: RuntimeMessage, + _scope: RuntimeScope, + ): Promise {} + + async attach( + _sessionId: string, + _mode: RuntimeAttachMode, + _scope: RuntimeScope, + ): Promise { + return { + attachmentId: 'attachment-1', + sessionId: 'session-1', + mode: 'read', + expiresAt: '2026-07-12T00:00:00.000Z', + }; + } + + async detach(_attachmentId: string, _scope: RuntimeScope): Promise {} + + async terminate(_sessionId: string, _approvalRef: string, _scope: RuntimeScope): Promise {} +} + +describe('AgentRuntimeProviderRegistry', (): void => { + it('resolves only registered runtime providers', (): void => { + const registry = new AgentRuntimeProviderRegistry(); + const provider = new TestRuntimeProvider(); + + registry.register(provider); + + expect(registry.get(provider.id)).toBe(provider); + expect(registry.get('unknown-runtime')).toBeUndefined(); + expect(registry.list()).toEqual([provider]); + }); + + it('rejects duplicate and blank provider identities rather than silently replacing a runtime', (): void => { + const registry = new AgentRuntimeProviderRegistry(); + const provider = new TestRuntimeProvider(); + + registry.register(provider); + + expect((): void => { + registry.register(provider); + }).toThrow(/already registered/); + expect((): void => { + registry.require(''); + }).toThrow(/provider ID is required/); + expect((): void => { + registry.require('unknown-runtime'); + }).toThrow(/not registered/); + }); +}); diff --git a/packages/agent/src/runtime-provider-registry.ts b/packages/agent/src/runtime-provider-registry.ts new file mode 100644 index 00000000..16cb7d90 --- /dev/null +++ b/packages/agent/src/runtime-provider-registry.ts @@ -0,0 +1,40 @@ +import type { AgentRuntimeProvider } from '@mosaicstack/types'; + +/** + * Registry for runtime providers. Registration is explicit and replacement is + * forbidden so a provider identity cannot be silently hijacked at runtime. + */ +export class AgentRuntimeProviderRegistry { + private readonly providers = new Map(); + + register(provider: AgentRuntimeProvider): void { + const providerId = provider.id.trim(); + if (providerId.length === 0) { + throw new Error('Runtime provider ID is required'); + } + if (this.providers.has(providerId)) { + throw new Error(`Runtime provider is already registered: ${providerId}`); + } + this.providers.set(providerId, provider); + } + + get(providerId: string): AgentRuntimeProvider | undefined { + return this.providers.get(providerId); + } + + require(providerId: string): AgentRuntimeProvider { + const normalizedProviderId = providerId.trim(); + if (normalizedProviderId.length === 0) { + throw new Error('Runtime provider ID is required'); + } + const provider = this.providers.get(normalizedProviderId); + if (!provider) { + throw new Error(`Runtime provider is not registered: ${normalizedProviderId}`); + } + return provider; + } + + list(): AgentRuntimeProvider[] { + return Array.from(this.providers.values()); + } +} diff --git a/packages/agent/src/tmux-fleet-runtime-provider.test.ts b/packages/agent/src/tmux-fleet-runtime-provider.test.ts new file mode 100644 index 00000000..2031572c --- /dev/null +++ b/packages/agent/src/tmux-fleet-runtime-provider.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { RuntimeScope } from '@mosaicstack/types'; +import { + type FleetReadAuthority, + type FleetWriteAuthority, + TmuxFleetRuntimeProvider, +} from './tmux-fleet-runtime-provider.js'; +import type { + FleetRuntimeProviderError, + FleetRuntimeTarget, + FleetRuntimeTransport, +} from './tmux-fleet-runtime-provider.js'; + +const scope: RuntimeScope = { + actorId: 'operator-1', + tenantId: 'tenant-a', + channelId: 'discord-1', + correlationId: 'corr-1', +}; + +const target: FleetRuntimeTarget = { + id: 'coder0', + runtimeId: 'codex', + socketName: 'tess-fleet', +}; + +function transport(): FleetRuntimeTransport { + return { + verifySession: vi.fn(async (): Promise => target), + listSessions: vi.fn(async (): Promise => [target]), + sendMessage: vi.fn(async (): Promise => undefined), + terminate: vi.fn(async (): Promise => undefined), + }; +} + +function readAuthority(): FleetReadAuthority { + return { canRead: vi.fn(async (): Promise => true) }; +} + +describe('TmuxFleetRuntimeProvider security policy', (): void => { + it('advertises only fleet operations it can safely implement', async (): Promise => { + const provider = new TmuxFleetRuntimeProvider({ transport: transport() }); + + await expect(provider.capabilities(scope)).resolves.toEqual({ + supported: [ + 'session.list', + 'session.tree', + 'session.send', + 'session.attach', + 'session.terminate', + ], + }); + }); + + it('rejects control attach without consulting the tmux transport', async (): Promise => { + const fleet = transport(); + const provider = new TmuxFleetRuntimeProvider({ transport: fleet }); + + await expect(provider.attach('coder0', 'control', scope)).rejects.toMatchObject({ + code: 'forbidden', + } satisfies Partial); + expect(fleet.verifySession).not.toHaveBeenCalled(); + }); + + it('denies fleet listing without an exact-scope read authority decision', async (): Promise => { + const fleet = transport(); + const provider = new TmuxFleetRuntimeProvider({ transport: fleet }); + + await expect(provider.listSessions(scope)).rejects.toMatchObject({ + code: 'forbidden', + } satisfies Partial); + expect(fleet.listSessions).not.toHaveBeenCalled(); + }); + + it('denies read attachment without an exact-scope read authority decision', async (): Promise => { + const fleet = transport(); + const provider = new TmuxFleetRuntimeProvider({ transport: fleet }); + + await expect(provider.attach('coder0', 'read', scope)).rejects.toMatchObject({ + code: 'forbidden', + } satisfies Partial); + expect(fleet.verifySession).not.toHaveBeenCalled(); + }); + + it('creates a read-only attachment only after exact target verification', async (): Promise => { + const fleet = transport(); + const provider = new TmuxFleetRuntimeProvider({ + transport: fleet, + readAuthority: readAuthority(), + attachmentIdFactory: (): string => 'attachment-1', + now: (): Date => new Date('2026-07-12T00:00:00.000Z'), + }); + + await expect(provider.attach('coder0', 'read', scope)).resolves.toEqual({ + attachmentId: 'attachment-1', + sessionId: 'coder0', + mode: 'read', + expiresAt: '2026-07-12T00:05:00.000Z', + }); + expect(fleet.verifySession).toHaveBeenCalledWith('coder0'); + }); + + it('denies attachment-handle replay from another immutable actor scope', async (): Promise => { + const fleet = transport(); + const provider = new TmuxFleetRuntimeProvider({ + transport: fleet, + readAuthority: readAuthority(), + attachmentIdFactory: (): string => 'attachment-1', + now: (): Date => new Date('2026-07-12T00:00:00.000Z'), + }); + await provider.attach('coder0', 'read', scope); + + await expect( + provider.detach('attachment-1', { ...scope, actorId: 'operator-2' }), + ).rejects.toMatchObject({ + code: 'forbidden', + } satisfies Partial); + }); + + it('keeps attachment scope immutable after caller-side scope mutation', async (): Promise => { + const mutableScope = { ...scope }; + const provider = new TmuxFleetRuntimeProvider({ + transport: transport(), + readAuthority: readAuthority(), + attachmentIdFactory: (): string => 'attachment-1', + now: (): Date => new Date('2026-07-12T00:00:00.000Z'), + }); + await provider.attach('coder0', 'read', mutableScope); + mutableScope.actorId = 'operator-2'; + + await expect(provider.detach('attachment-1', scope)).resolves.toBeUndefined(); + }); + + it('denies expired attachment handles and removes them', async (): Promise => { + let now = new Date('2026-07-12T00:00:00.000Z'); + const provider = new TmuxFleetRuntimeProvider({ + transport: transport(), + readAuthority: readAuthority(), + attachmentIdFactory: (): string => 'attachment-1', + now: (): Date => now, + }); + await provider.attach('coder0', 'read', scope); + now = new Date('2026-07-12T00:05:00.001Z'); + + await expect(provider.detach('attachment-1', scope)).rejects.toMatchObject({ + code: 'forbidden', + } satisfies Partial); + await expect(provider.detach('attachment-1', scope)).rejects.toMatchObject({ + code: 'not_found', + } satisfies Partial); + }); + + it('prunes expired attachment handles before creating a new handle', async (): Promise => { + let now = new Date('2026-07-12T00:00:00.000Z'); + let attachmentSequence = 0; + const provider = new TmuxFleetRuntimeProvider({ + transport: transport(), + readAuthority: readAuthority(), + attachmentIdFactory: (): string => `attachment-${++attachmentSequence}`, + now: (): Date => now, + }); + await provider.attach('coder0', 'read', scope); + now = new Date('2026-07-12T00:05:00.001Z'); + await provider.attach('coder0', 'read', scope); + + await expect(provider.detach('attachment-1', scope)).rejects.toMatchObject({ + code: 'not_found', + } satisfies Partial); + }); + + it('rejects an empty message before consulting write authority or tmux', async (): Promise => { + const fleet = transport(); + const writeAuthority: FleetWriteAuthority = { + canWrite: vi.fn(async (): Promise => true), + assertAuthorized: vi.fn(async (): Promise => undefined), + }; + const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority }); + + await expect( + provider.sendMessage('coder0', { content: '', idempotencyKey: 'message-1' }, scope), + ).rejects.toMatchObject({ + code: 'invalid_request', + } satisfies Partial); + expect(writeAuthority.canWrite).not.toHaveBeenCalled(); + expect(writeAuthority.assertAuthorized).not.toHaveBeenCalled(); + expect(fleet.verifySession).not.toHaveBeenCalled(); + expect(fleet.sendMessage).not.toHaveBeenCalled(); + }); + + it('denies fleet writes by default before probing the tmux transport', async (): Promise => { + const fleet = transport(); + const provider = new TmuxFleetRuntimeProvider({ transport: fleet }); + + await expect( + provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope), + ).rejects.toMatchObject({ code: 'forbidden' } satisfies Partial); + await expect(provider.terminate('coder0', 'approval-1', scope)).rejects.toMatchObject({ + code: 'forbidden', + } satisfies Partial); + expect(fleet.verifySession).not.toHaveBeenCalled(); + expect(fleet.sendMessage).not.toHaveBeenCalled(); + expect(fleet.terminate).not.toHaveBeenCalled(); + }); + + it('rejects an unverified target before consulting orchestrator write authority', async (): Promise => { + const fleet = transport(); + fleet.verifySession = vi.fn(async (): Promise => { + throw new Error('target identity mismatch'); + }); + const writeAuthority: FleetWriteAuthority = { + canWrite: vi.fn(async (): Promise => true), + assertAuthorized: vi.fn(async (): Promise => undefined), + }; + const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority }); + + await expect( + provider.sendMessage('coder', { content: 'hello', idempotencyKey: 'message-1' }, scope), + ).rejects.toThrow('target identity mismatch'); + expect(writeAuthority.assertAuthorized).not.toHaveBeenCalled(); + expect(fleet.sendMessage).not.toHaveBeenCalled(); + }); + + it('uses the role-neutral interaction source label by default', async (): Promise => { + const fleet = transport(); + const writeAuthority: FleetWriteAuthority = { + canWrite: vi.fn(async (): Promise => true), + assertAuthorized: vi.fn(async (): Promise => undefined), + }; + const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority }); + + await provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope); + + expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'interaction'); + }); + + it('passes an exact session ID to the fleet transport only through orchestrator-authorized writes', async (): Promise => { + const fleet = transport(); + const writeAuthority: FleetWriteAuthority = { + canWrite: vi.fn(async (): Promise => true), + assertAuthorized: vi.fn(async (): Promise => undefined), + }; + const provider = new TmuxFleetRuntimeProvider({ + transport: fleet, + sourceLabel: 'operator', + writeAuthority, + }); + + await provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope); + await provider.terminate('coder0', 'approval-1', scope); + + expect(writeAuthority.assertAuthorized).toHaveBeenCalledWith({ + operation: 'session.send', + sessionId: 'coder0', + scope, + }); + expect(writeAuthority.assertAuthorized).toHaveBeenCalledWith({ + operation: 'session.terminate', + sessionId: 'coder0', + scope, + approvalRef: 'approval-1', + }); + expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'operator'); + expect(fleet.terminate).toHaveBeenCalledWith('coder0'); + }); + + it('fails closed when consumers ask for session streaming', async (): Promise => { + const provider = new TmuxFleetRuntimeProvider({ transport: transport() }); + const stream = provider.streamSession('coder0', undefined, scope)[Symbol.asyncIterator](); + + await expect(stream.next()).rejects.toMatchObject({ + code: 'capability_unsupported', + } satisfies Partial); + }); +}); diff --git a/packages/agent/src/tmux-fleet-runtime-provider.ts b/packages/agent/src/tmux-fleet-runtime-provider.ts new file mode 100644 index 00000000..0f0c67ba --- /dev/null +++ b/packages/agent/src/tmux-fleet-runtime-provider.ts @@ -0,0 +1,368 @@ +import { randomUUID } from 'node:crypto'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; +const FLEET_PROVIDER_ID = 'fleet.tmux'; +const ATTACHMENT_TTL_MS = 5 * 60 * 1_000; + +export type FleetRuntimeProviderErrorCode = + | 'capability_unsupported' + | 'forbidden' + | 'invalid_request' + | 'not_found'; + +/** A roster-bound target verified by the concrete fleet transport. */ +export interface FleetRuntimeTarget { + id: string; + runtimeId: string; + socketName: string; +} + +/** + * Narrow transport boundary implemented by the Mosaic tmux adapter. Keeping it + * here prevents the runtime package from depending on the Mosaic CLI package. + */ +export interface FleetRuntimeTransport { + verifySession(sessionId: string): Promise; + listSessions(): Promise; + sendMessage(sessionId: string, message: string, sourceLabel: string): Promise; + terminate(sessionId: string): Promise; +} + +export type FleetReadOperation = + | 'runtime.health' + | 'session.list' + | 'session.tree' + | 'session.attach'; + +export interface FleetReadAuthorization { + operation: FleetReadOperation; + scope: RuntimeScope; + sessionId?: string; +} + +/** Authorization for fleet inspection and read-only attachments. */ +export interface FleetReadAuthority { + canRead(authorization: FleetReadAuthorization): Promise; +} + +export interface FleetWriteAuthorization { + operation: 'session.send' | 'session.terminate'; + sessionId: string; + scope: RuntimeScope; + /** Present only for terminate; authority adapters bind it to the exact action. */ + approvalRef?: string; +} + +/** + * The orchestrator is the only authority that may permit interaction-plane write/control requests to a + * fleet peer. Gateway records the request and denial/success around provider + * invocation; the default authority prevents direct interaction-plane writes by design. + */ +export interface FleetWriteAuthority { + /** Non-consuming preflight used before probing the fleet transport. */ + canWrite(authorization: FleetWriteAuthorization): Promise; + /** Final exact-target authorization; may consume an orchestrator grant. */ + assertAuthorized(authorization: FleetWriteAuthorization): Promise; +} + +export interface TmuxFleetRuntimeProviderOptions { + transport: FleetRuntimeTransport; + readAuthority?: FleetReadAuthority; + writeAuthority?: FleetWriteAuthority; + sourceLabel?: string; + attachmentIdFactory?: () => string; + now?: () => Date; + attachmentTtlMs?: number; +} + +interface FleetAttachment { + sessionId: string; + scope: RuntimeScope; + expiresAtMs: number; +} + +/** A typed, fail-closed provider error that callers can normalize at the boundary. */ +export class FleetRuntimeProviderError extends Error { + constructor( + readonly code: FleetRuntimeProviderErrorCode, + message: string, + ) { + super(message); + this.name = FleetRuntimeProviderError.name; + } +} + +class DenyFleetReadAuthority implements FleetReadAuthority { + async canRead(_authorization: FleetReadAuthorization): Promise { + return false; + } +} + +class DenyFleetWriteAuthority implements FleetWriteAuthority { + async canWrite(_authorization: FleetWriteAuthorization): Promise { + return false; + } + + async assertAuthorized(_authorization: FleetWriteAuthorization): Promise { + throw new FleetRuntimeProviderError( + 'forbidden', + 'Fleet writes require an explicit orchestrator authority decision', + ); + } +} + +/** + * A capability-limited provider for rostered local fleet peers. It never + * permits raw tmux socket/target selection, interactive control attach, or + * direct interaction-plane writes; all side effects pass through exact transport checks. + */ +export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider { + readonly id = FLEET_PROVIDER_ID; + private readonly attachments = new Map(); + private readonly readAuthority: FleetReadAuthority; + private readonly writeAuthority: FleetWriteAuthority; + private readonly sourceLabel: string; + private readonly attachmentIdFactory: () => string; + private readonly now: () => Date; + private readonly attachmentTtlMs: number; + + constructor(private readonly options: TmuxFleetRuntimeProviderOptions) { + this.readAuthority = options.readAuthority ?? new DenyFleetReadAuthority(); + this.writeAuthority = options.writeAuthority ?? new DenyFleetWriteAuthority(); + this.sourceLabel = options.sourceLabel ?? 'interaction'; + this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID; + this.now = options.now ?? (() => new Date()); + this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS; + } + + async capabilities(_scope: RuntimeScope): Promise { + return { + supported: [ + 'session.list', + 'session.tree', + 'session.send', + 'session.attach', + 'session.terminate', + ], + }; + } + + async health(scope: RuntimeScope): Promise { + const targets = await this.readTargets('runtime.health', scope); + return { + status: targets.length > 0 ? 'healthy' : 'down', + checkedAt: this.now().toISOString(), + detail: + targets.length > 0 + ? 'Authorized rostered fleet peers are reachable' + : 'No authorized rostered fleet peers are reachable', + }; + } + + async listSessions(scope: RuntimeScope): Promise { + const targets = await this.readTargets('session.list', scope); + return this.toRuntimeSessions(targets); + } + + async getSessionTree(scope: RuntimeScope): Promise { + const targets = await this.readTargets('session.tree', scope); + return this.toRuntimeSessions(targets).map( + (session): RuntimeSessionTree => ({ + session, + children: [], + }), + ); + } + + async *streamSession( + _sessionId: string, + _cursor: string | undefined, + _scope: RuntimeScope, + ): AsyncIterable { + throw new FleetRuntimeProviderError( + 'capability_unsupported', + 'Fleet session streaming is not supported by the tmux provider', + ); + } + + async sendMessage( + sessionId: string, + message: RuntimeMessage, + scope: RuntimeScope, + ): Promise { + if (message.content.length === 0) { + throw new FleetRuntimeProviderError('invalid_request', 'Fleet message content is required'); + } + await this.assertWritePermitted('session.send', sessionId, scope); + const target = await this.options.transport.verifySession(sessionId); + await this.assertWriteAuthorized('session.send', target.id, scope); + await this.options.transport.sendMessage(target.id, message.content, this.sourceLabel); + } + + async attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise { + if (mode !== 'read') { + throw new FleetRuntimeProviderError('forbidden', 'Fleet control attach is not permitted'); + } + await this.assertReadAuthorized('session.attach', sessionId, scope); + const target = await this.options.transport.verifySession(sessionId); + await this.assertReadAuthorized('session.attach', target.id, scope); + const attachmentId = this.attachmentIdFactory(); + const nowMs = this.now().getTime(); + this.pruneExpiredAttachments(nowMs); + const expiresAtMs = nowMs + this.attachmentTtlMs; + this.attachments.set(attachmentId, { + sessionId: target.id, + scope: snapshotScope(scope), + expiresAtMs, + }); + return { + attachmentId, + sessionId: target.id, + mode, + expiresAt: new Date(expiresAtMs).toISOString(), + }; + } + + async detach(attachmentId: string, scope: RuntimeScope): Promise { + const attachment = this.attachments.get(attachmentId); + if (!attachment) { + throw new FleetRuntimeProviderError('not_found', 'Fleet attachment is not active'); + } + if (this.now().getTime() >= attachment.expiresAtMs) { + this.attachments.delete(attachmentId); + throw new FleetRuntimeProviderError('forbidden', 'Fleet attachment has expired'); + } + if (!sameScope(attachment.scope, scope)) { + throw new FleetRuntimeProviderError('forbidden', 'Fleet attachment scope does not match'); + } + this.attachments.delete(attachmentId); + } + + async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise { + if (approvalRef.trim().length === 0) { + throw new FleetRuntimeProviderError( + 'invalid_request', + 'Fleet termination approval is required', + ); + } + await this.assertWritePermitted('session.terminate', sessionId, scope, approvalRef); + const target = await this.options.transport.verifySession(sessionId); + await this.assertWriteAuthorized('session.terminate', target.id, scope, approvalRef); + await this.options.transport.terminate(target.id); + } + + private async readTargets( + operation: FleetReadOperation, + scope: RuntimeScope, + ): Promise { + await this.assertReadAuthorized(operation, undefined, scope); + const targets = await this.options.transport.listSessions(); + const authorization = await Promise.all( + targets.map( + async (target): Promise => + this.readAuthority.canRead({ operation, sessionId: target.id, scope }), + ), + ); + return targets.filter((_target, index): boolean => authorization[index] === true); + } + + private toRuntimeSessions(targets: FleetRuntimeTarget[]): RuntimeSession[] { + const timestamp = this.now().toISOString(); + return targets.map( + (target): RuntimeSession => ({ + id: target.id, + providerId: this.id, + runtimeId: target.runtimeId, + state: 'active', + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + } + + private async assertReadAuthorized( + operation: FleetReadOperation, + sessionId: string | undefined, + scope: RuntimeScope, + ): Promise { + const allowed = await this.readAuthority.canRead({ + operation, + scope, + ...(sessionId ? { sessionId } : {}), + }); + if (!allowed) { + throw new FleetRuntimeProviderError('forbidden', 'Fleet read is not authorized'); + } + } + + private pruneExpiredAttachments(nowMs: number): void { + for (const [attachmentId, attachment] of this.attachments) { + if (attachment.expiresAtMs <= nowMs) { + this.attachments.delete(attachmentId); + } + } + } + + private async assertWritePermitted( + operation: FleetWriteAuthorization['operation'], + sessionId: string, + scope: RuntimeScope, + approvalRef?: string, + ): Promise { + const permitted = await this.writeAuthority.canWrite({ + operation, + sessionId, + scope, + ...(approvalRef ? { approvalRef } : {}), + }); + if (!permitted) { + throw new FleetRuntimeProviderError('forbidden', 'Fleet write is not authorized'); + } + } + + private async assertWriteAuthorized( + operation: FleetWriteAuthorization['operation'], + sessionId: string, + scope: RuntimeScope, + approvalRef?: string, + ): Promise { + await this.writeAuthority.assertAuthorized({ + operation, + sessionId, + scope, + ...(approvalRef ? { approvalRef } : {}), + }); + } +} + +function snapshotScope(scope: RuntimeScope): RuntimeScope { + return Object.freeze({ + actorId: scope.actorId, + tenantId: scope.tenantId, + channelId: scope.channelId, + correlationId: scope.correlationId, + }); +} + +function sameScope(left: RuntimeScope, right: RuntimeScope): boolean { + return ( + left.actorId === right.actorId && + left.tenantId === right.tenantId && + left.channelId === right.channelId && + left.correlationId === right.correlationId + ); +} diff --git a/packages/comms/README.md b/packages/comms/README.md new file mode 100644 index 00000000..ef7bd5c6 --- /dev/null +++ b/packages/comms/README.md @@ -0,0 +1,41 @@ +# @mosaicstack/comms + +MACP presence SDK — the **P1 (presence)** slice of RFC-001 (§4.5 liveness, +§4.2 event envelope). Minimal by design: set Matrix presence, run the +`mosaic.presence` heartbeat, and compute **deterministic** fleet liveness. + +Out of P1 scope (later phases): enrollment/auto-detect, room taxonomy, +per-agent token minting, signed-authorship, federation. + +## API + +- `classifyLiveness(ageMs, policy)` / `computeFleetLiveness(observations, now, policy)` + — pure, deterministic online/away/offline from heartbeat age. The + authoritative liveness source (RFC-001 §4.5): native Matrix presence is _not_ + relied upon. +- `HeartbeatEmitter` / `startHeartbeatLoop(...)` — build and drive the + `mosaic.presence` heartbeat (monotonic `seq`, `interval_ms`). +- `MinimalMatrixClient` — tiny C-S client: `setPresence`, `sendHeartbeat`, + `readHeartbeats`, `joinRoom`. Supports Application-Service masquerade + (`actAsUserId`) for the P1 provisioner, or a per-agent `accessToken`. +- `PresenceAgent` — high-level: join the fleet room, go present, heartbeat. + `pauseHeartbeat()` models a crash (no graceful signal). +- `FleetLivenessReader` — reads the fleet room and computes the liveness board + (`read()` / `formatBoard()`), the surface a human or watchdog reads. + +## Liveness policy (RFC-001 §4.5) + +``` +online : age <= heartbeatIntervalMs * missTolerance +away : age < darkThresholdMs +offline: otherwise (or never-seen / non-finite age -> fail safe to offline) +``` + +Defaults: interval 30s, miss-tolerance 2, dark-threshold 10min +(`DEFAULT_LIVENESS_POLICY`). All runtime-tunable per RFC-002 §5.3. + +## Tests + +`pnpm --filter @mosaicstack/comms test` — the liveness core is written +RED-FIRST; an end-to-end proof against a real Synapse lives in +`tools/matrix-presence-harness`. diff --git a/packages/comms/package.json b/packages/comms/package.json new file mode 100644 index 00000000..3ca2f244 --- /dev/null +++ b/packages/comms/package.json @@ -0,0 +1,37 @@ +{ + "name": "@mosaicstack/comms", + "version": "0.0.1", + "type": "module", + "repository": { + "type": "git", + "url": "https://git.mosaicstack.dev/mosaicstack/stack.git", + "directory": "packages/comms" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^2.0.0", + "typescript": "^5.8.0", + "vitest": "^2.0.0" + }, + "publishConfig": { + "registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/", + "access": "public" + }, + "files": [ + "dist" + ] +} diff --git a/packages/comms/src/__tests__/heartbeat.test.ts b/packages/comms/src/__tests__/heartbeat.test.ts new file mode 100644 index 00000000..bef29f84 --- /dev/null +++ b/packages/comms/src/__tests__/heartbeat.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { HeartbeatEmitter, startHeartbeatLoop } from '../heartbeat.js'; +import type { PresenceHeartbeatContent } from '../types.js'; + +const agent = { mxid: '@agent-alpha:matrix.localhost', slug: 'alpha', harness: 'claude-code' }; + +describe('HeartbeatEmitter', () => { + it('increments seq starting at 1 and stamps the envelope', () => { + let t = 1000; + const em = new HeartbeatEmitter({ agent, intervalMs: 5000, now: () => t }); + const a = em.next(); + t = 6000; + const b = em.next('away'); + + expect(a.seq).toBe(1); + expect(a.ts).toBe(1000); + expect(a.status).toBe('online'); + expect(a.macp_type).toBe('presence'); + expect(a.msgtype).toBe('mosaic.presence'); + expect(a.macp_version).toBe('1.0'); + expect(a.interval_ms).toBe(5000); + expect(a.agent).toEqual(agent); + expect(a.body).toContain('alpha'); + + expect(b.seq).toBe(2); + expect(b.ts).toBe(6000); + expect(b.status).toBe('away'); + expect(em.currentSeq).toBe(2); + }); + + it('includes mission_id only when provided', () => { + const withMission = new HeartbeatEmitter({ + agent, + intervalMs: 1000, + missionId: 'KBN-101', + }).next(); + const without = new HeartbeatEmitter({ agent, intervalMs: 1000 }).next(); + expect(withMission.mission_id).toBe('KBN-101'); + expect(without.mission_id).toBeUndefined(); + }); +}); + +describe('startHeartbeatLoop', () => { + afterEach(() => vi.useRealTimers()); + + it('emits immediately, then once per interval, until stopped', () => { + vi.useFakeTimers(); + const sent: PresenceHeartbeatContent[] = []; + const em = new HeartbeatEmitter({ agent, intervalMs: 1000, now: () => Date.now() }); + const loop = startHeartbeatLoop({ + emitter: em, + intervalMs: 1000, + send: (c) => { + sent.push(c); + }, + }); + + expect(sent).toHaveLength(1); // immediate beat + vi.advanceTimersByTime(3000); + expect(sent).toHaveLength(4); // +3 beats + expect(sent.map((s) => s.seq)).toEqual([1, 2, 3, 4]); + + loop.stop(); + vi.advanceTimersByTime(5000); + expect(sent).toHaveLength(4); // no more after stop + loop.stop(); // idempotent + }); + + it('routes a rejected async send to onError without killing the loop', async () => { + vi.useFakeTimers(); + const onError = vi.fn(); + let n = 0; + const em = new HeartbeatEmitter({ agent, intervalMs: 1000 }); + const loop = startHeartbeatLoop({ + emitter: em, + intervalMs: 1000, + onError, + send: () => { + n += 1; + return Promise.reject(new Error(`boom ${n}`)); + }, + }); + + await vi.advanceTimersByTimeAsync(2000); // immediate + 2 + expect(n).toBe(3); + expect(onError).toHaveBeenCalledTimes(3); + loop.stop(); + }); +}); diff --git a/packages/comms/src/__tests__/liveness.test.ts b/packages/comms/src/__tests__/liveness.test.ts new file mode 100644 index 00000000..58d8b5e2 --- /dev/null +++ b/packages/comms/src/__tests__/liveness.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { classifyLiveness, computeFleetLiveness } from '../liveness.js'; +import type { HeartbeatObservation, LivenessPolicy } from '../types.js'; + +// Small, dev-scale policy so the arithmetic is obvious: +// online window = interval * missTolerance = 1000 * 2 = 2000ms +// dark threshold = 5000ms +const policy: LivenessPolicy = { + heartbeatIntervalMs: 1000, + missTolerance: 2, + darkThresholdMs: 5000, +}; + +describe('classifyLiveness (deterministic, heartbeat-age based — RFC-001 §4.5)', () => { + it('is online when age is within interval * missTolerance', () => { + expect(classifyLiveness(0, policy)).toBe('online'); + expect(classifyLiveness(1999, policy)).toBe('online'); + expect(classifyLiveness(2000, policy)).toBe('online'); // inclusive boundary + }); + + it('is away when past the online window but before dark threshold', () => { + expect(classifyLiveness(2001, policy)).toBe('away'); + expect(classifyLiveness(4999, policy)).toBe('away'); + }); + + it('is offline/dark at or past the dark threshold', () => { + expect(classifyLiveness(5000, policy)).toBe('offline'); + expect(classifyLiveness(50_000, policy)).toBe('offline'); + }); + + it('treats a never-seen agent (Infinity age) as offline', () => { + expect(classifyLiveness(Number.POSITIVE_INFINITY, policy)).toBe('offline'); + }); + + it('never returns online for a negative-but-huge misconfig (guards NaN)', () => { + // A NaN age must fail safe to offline, not silently report online. + expect(classifyLiveness(Number.NaN, policy)).toBe('offline'); + }); +}); + +describe('computeFleetLiveness (A2/A3 core)', () => { + const now = 100_000; + const obs = (slug: string, lastSeenTs: number, lastSeq = 1): HeartbeatObservation => ({ + slug, + mxid: `@agent-${slug}:matrix.localhost`, + lastSeenTs, + lastSeq, + assertedStatus: 'online', + }); + + it('classifies a live fleet: fresh=online, stale=away, dark=offline', () => { + const result = computeFleetLiveness( + [ + obs('alpha', now - 500), // 500ms old -> online + obs('bravo', now - 3000), // 3000ms old -> away + obs('charlie', now - 8000), // 8000ms old -> offline + ], + now, + policy, + ); + const byslug = Object.fromEntries(result.map((r) => [r.slug, r.status])); + expect(byslug).toEqual({ alpha: 'online', bravo: 'away', charlie: 'offline' }); + }); + + it('A3: a previously-online agent flips to offline once age crosses dark threshold', () => { + const lastBeat = 100_000; // agent was hard-killed right after this beat + // Just before the threshold it is still merely "away"... + const justBefore = computeFleetLiveness([obs('victim', lastBeat, 7)], lastBeat + 4999, policy); + expect(justBefore[0]?.status).toBe('away'); + // ...and the instant age reaches darkThresholdMs it is deterministically offline, + // with no dependence on native Matrix presence timeouts. + const atThreshold = computeFleetLiveness([obs('victim', lastBeat, 7)], lastBeat + 5000, policy); + expect(atThreshold[0]?.status).toBe('offline'); + expect(atThreshold[0]?.ageMs).toBe(5000); + expect(atThreshold[0]?.lastSeq).toBe(7); + }); + + it('reports ageMs and preserves mxid/slug/seq for the human view', () => { + const [row] = computeFleetLiveness([obs('alpha', now - 1200, 42)], now, policy); + expect(row).toMatchObject({ + slug: 'alpha', + mxid: '@agent-alpha:matrix.localhost', + ageMs: 1200, + lastSeq: 42, + status: 'online', + }); + }); +}); diff --git a/packages/comms/src/__tests__/matrix-client.test.ts b/packages/comms/src/__tests__/matrix-client.test.ts new file mode 100644 index 00000000..45f8c967 --- /dev/null +++ b/packages/comms/src/__tests__/matrix-client.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { MatrixError, MinimalMatrixClient, toMatrixPresence } from '../matrix-client.js'; + +const jsonResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +// A fetch mock typed with the (URL, RequestInit?) shape the client actually +// calls, so mock.calls has a proper tuple type under noUncheckedIndexedAccess. +const mkFetch = (impl: (url: URL, init?: RequestInit) => Promise) => vi.fn(impl); + +const cfg = { + homeserverUrl: 'https://matrix.localhost:8448', + accessToken: 'as-secret', + actAsUserId: '@agent-alpha:matrix.localhost', +}; + +describe('toMatrixPresence', () => { + it('maps liveness states to native presence EDU values', () => { + expect(toMatrixPresence('online')).toBe('online'); + expect(toMatrixPresence('away')).toBe('unavailable'); + expect(toMatrixPresence('offline')).toBe('offline'); + }); +}); + +describe('MinimalMatrixClient', () => { + it('setPresence PUTs native presence and masquerades via user_id', async () => { + const fetchMock = mkFetch(async () => jsonResponse(200, {})); + const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch); + await client.setPresence('@agent-alpha:matrix.localhost', 'away', 'hb'); + + const [url, init] = fetchMock.mock.calls[0]!; + const u = new URL((url as URL).toString()); + expect(u.pathname).toBe('/_matrix/client/v3/presence/%40agent-alpha%3Amatrix.localhost/status'); + expect(u.searchParams.get('user_id')).toBe('@agent-alpha:matrix.localhost'); + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + presence: 'unavailable', + status_msg: 'hb', + }); + expect((init as RequestInit).method).toBe('PUT'); + }); + + it('sendHeartbeat posts an m.room.message and returns the event_id', async () => { + const fetchMock = mkFetch(async () => jsonResponse(200, { event_id: '$evt1' })); + const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch); + const id = await client.sendHeartbeat('!room:matrix.localhost', { + macp_version: '1.0', + macp_type: 'presence', + msgtype: 'mosaic.presence', + agent: { mxid: cfg.actAsUserId, slug: 'alpha', harness: 'claude-code' }, + ts: 1, + body: 'alpha online (seq 1)', + status: 'online', + seq: 1, + interval_ms: 1000, + }); + expect(id).toBe('$evt1'); + const [url] = fetchMock.mock.calls[0]!; + expect((url as URL).pathname).toContain('/rooms/!room%3Amatrix.localhost/send/m.room.message/'); + }); + + it('throws a MatrixError carrying errcode on a non-2xx', async () => { + const fetchMock = mkFetch(async () => + jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'nope' }), + ); + const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch); + await expect(client.whoami()).rejects.toMatchObject({ + name: 'MatrixError', + status: 403, + errcode: 'M_FORBIDDEN', + }); + await expect(client.whoami()).rejects.toBeInstanceOf(MatrixError); + }); + + it('readHeartbeats reduces the timeline to the latest beat per agent', async () => { + // Timeline (dir=b => most-recent first). alpha has two beats; keep highest seq. + const chunk = [ + { + sender: '@agent-bravo:matrix.localhost', + origin_server_ts: 9000, + content: { + msgtype: 'mosaic.presence', + agent: { slug: 'bravo', mxid: '@agent-bravo:matrix.localhost' }, + seq: 5, + status: 'online', + ts: 8999, + }, + }, + { + sender: '@agent-alpha:matrix.localhost', + origin_server_ts: 8000, + content: { + msgtype: 'mosaic.presence', + agent: { slug: 'alpha', mxid: '@agent-alpha:matrix.localhost' }, + seq: 12, + status: 'online', + ts: 7999, + }, + }, + { + // an ordinary chat message must be ignored + sender: '@human:matrix.localhost', + origin_server_ts: 7000, + content: { msgtype: 'm.text', body: 'hi' }, + }, + { + sender: '@agent-alpha:matrix.localhost', + origin_server_ts: 6000, + content: { + msgtype: 'mosaic.presence', + agent: { slug: 'alpha', mxid: '@agent-alpha:matrix.localhost' }, + seq: 11, + status: 'online', + ts: 5999, + }, + }, + ]; + const fetchMock = mkFetch(async () => jsonResponse(200, { chunk })); + const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch); + const obs = await client.readHeartbeats('!room:matrix.localhost'); + + const bySlug = Object.fromEntries(obs.map((o) => [o.slug, o])); + expect(Object.keys(bySlug).sort()).toEqual(['alpha', 'bravo']); + expect(bySlug.alpha).toMatchObject({ lastSeq: 12, lastSeenTs: 8000 }); // highest seq wins, server ts + expect(bySlug.bravo).toMatchObject({ lastSeq: 5, lastSeenTs: 9000 }); + + const [url] = fetchMock.mock.calls[0]!; + const u = new URL((url as URL).toString()); + expect(u.searchParams.get('dir')).toBe('b'); + }); +}); diff --git a/packages/comms/src/__tests__/presence-flow.test.ts b/packages/comms/src/__tests__/presence-flow.test.ts new file mode 100644 index 00000000..bf226e52 --- /dev/null +++ b/packages/comms/src/__tests__/presence-flow.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { FleetLivenessReader } from '../liveness-reader.js'; +import type { MinimalMatrixClient } from '../matrix-client.js'; +import { PresenceAgent } from '../presence-agent.js'; +import type { HeartbeatObservation, LivenessPolicy, PresenceStatus } from '../types.js'; + +/** + * An in-memory fake homeserver room: records heartbeats with a controllable + * server clock and reduces them exactly like the real readHeartbeats. Lets us + * prove the PresenceAgent -> room -> FleetLivenessReader flow (including the A3 + * hard-kill -> offline transition) deterministically, with no network. + */ +class FakeRoomClient { + readonly beats: Array<{ + slug: string; + mxid: string; + seq: number; + ts: number; + status: PresenceStatus; + }> = []; + presence: Record = {}; + + constructor(private readonly clock: () => number) {} + + async joinRoom(roomId: string): Promise { + return roomId; + } + async setPresence(userId: string, status: PresenceStatus): Promise { + this.presence[userId] = status; + } + async sendHeartbeat( + _roomId: string, + content: { agent: { slug: string; mxid: string }; seq: number; status: PresenceStatus }, + ): Promise { + this.beats.push({ + slug: content.agent.slug, + mxid: content.agent.mxid, + seq: content.seq, + ts: this.clock(), // server receive time + status: content.status, + }); + return `$evt${this.beats.length}`; + } + async readHeartbeats(): Promise { + const bySlug = new Map(); + for (const b of this.beats) { + const prev = bySlug.get(b.slug); + if (!prev || b.seq > prev.lastSeq) { + bySlug.set(b.slug, { + slug: b.slug, + mxid: b.mxid, + lastSeenTs: b.ts, + lastSeq: b.seq, + assertedStatus: b.status, + }); + } + } + return [...bySlug.values()]; + } +} + +const policy: LivenessPolicy = { + heartbeatIntervalMs: 1000, + missTolerance: 2, + darkThresholdMs: 5000, +}; + +describe('presence flow (A2 + A3 at unit level)', () => { + afterEach(() => vi.useRealTimers()); + + it('shows agents online while beating, then A3: a hard-killed agent goes offline within dark_threshold', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + + const fake = new FakeRoomClient(() => Date.now()); + const client = fake as unknown as MinimalMatrixClient; + const reader = new FleetLivenessReader({ + client, + roomId: '!fleet', + policy, + now: () => Date.now(), + }); + + const mk = (slug: string) => + new PresenceAgent({ + client, + agent: { mxid: `@agent-${slug}:matrix.localhost`, slug, harness: 'claude-code' }, + roomId: '!fleet', + intervalMs: 1000, + policy, + }); + + const alpha = mk('alpha'); + const bravo = mk('bravo'); + const charlie = mk('charlie'); + + for (const a of [alpha, bravo, charlie]) { + await a.connect(); + a.start(); + } + // native presence set online for all three (Element dot) + expect(fake.presence['@agent-alpha:matrix.localhost']).toBe('online'); + + // let a couple of beats flow — all three fresh => online (A2) + await vi.advanceTimersByTimeAsync(1500); + const board1 = Object.fromEntries((await reader.read()).map((r) => [r.slug, r.status])); + expect(board1).toEqual({ alpha: 'online', bravo: 'online', charlie: 'online' }); + + // HARD-KILL charlie: stop its loop, no more beats. alpha/bravo keep beating. + charlie.pauseHeartbeat(); // hard-kill: no graceful presence signal + + // advance to just before dark threshold from charlie's last beat... + await vi.advanceTimersByTimeAsync(3000); + const mid = Object.fromEntries((await reader.read()).map((r) => [r.slug, r.status])); + expect(mid.alpha).toBe('online'); + expect(mid.charlie).not.toBe('online'); // already stale (away) + + // ...advance past dark_threshold: charlie is deterministically offline. + await vi.advanceTimersByTimeAsync(4000); + const final = await reader.read(); + const byslug = Object.fromEntries(final.map((r) => [r.slug, r])); + expect(byslug.charlie!.status).toBe('offline'); + expect(byslug.alpha!.status).toBe('online'); + expect(byslug.bravo!.status).toBe('online'); + + for (const a of [alpha, bravo]) await a.stop(); + }); + + it('formatBoard renders a human-readable liveness board (A4)', async () => { + const fake = new FakeRoomClient(() => 10_000); + fake.beats.push({ + slug: 'alpha', + mxid: '@agent-alpha:matrix.localhost', + seq: 3, + ts: 9_500, + status: 'online', + }); + const reader = new FleetLivenessReader({ + client: fake as unknown as MinimalMatrixClient, + roomId: '!fleet', + policy, + now: () => 10_000, + }); + const board = await reader.formatBoard(); + expect(board).toContain('Fleet presence'); + expect(board).toContain('alpha'); + expect(board).toContain('online'); + expect(board).toContain('online=1'); + }); +}); diff --git a/packages/comms/src/heartbeat.ts b/packages/comms/src/heartbeat.ts new file mode 100644 index 00000000..dd798dca --- /dev/null +++ b/packages/comms/src/heartbeat.ts @@ -0,0 +1,124 @@ +/** + * `mosaic.presence` heartbeat construction and loop (RFC-001 §4.2/§4.5). + * + * The emitter is deterministic and side-effect free (easy to unit test): it + * owns the monotonic `seq` and stamps each beat. The loop wires the emitter to + * a sender on an interval; timers are injectable so the loop is testable with + * fake clocks. + */ + +import { MACP_VERSION, type PresenceHeartbeatContent, type PresenceStatus } from './types.js'; + +export interface HeartbeatAgentIdentity { + mxid: string; + slug: string; + harness: string; +} + +export interface HeartbeatEmitterOptions { + agent: HeartbeatAgentIdentity; + /** Nominal interval advertised in each beat (interval_ms). */ + intervalMs: number; + /** Optional mission correlation (RFC-001 §4.2 envelope). */ + missionId?: string; + /** Injectable clock for deterministic tests. Default Date.now. */ + now?: () => number; +} + +/** + * Produces successive heartbeat contents with a monotonically increasing seq. + * The first `next()` returns seq=1. + */ +export class HeartbeatEmitter { + private seq = 0; + private readonly now: () => number; + + constructor(private readonly opts: HeartbeatEmitterOptions) { + this.now = opts.now ?? Date.now; + } + + /** Current sequence number (0 before the first beat). */ + get currentSeq(): number { + return this.seq; + } + + /** Build the next heartbeat content, advancing the sequence. */ + next(status: PresenceStatus = 'online'): PresenceHeartbeatContent { + this.seq += 1; + const ts = this.now(); + const content: PresenceHeartbeatContent = { + macp_version: MACP_VERSION, + macp_type: 'presence', + msgtype: 'mosaic.presence', + agent: { + mxid: this.opts.agent.mxid, + slug: this.opts.agent.slug, + harness: this.opts.agent.harness, + }, + ts, + body: `${this.opts.agent.slug} ${status} (seq ${this.seq})`, + status, + seq: this.seq, + interval_ms: this.opts.intervalMs, + }; + if (this.opts.missionId !== undefined) { + content.mission_id = this.opts.missionId; + } + return content; + } +} + +export type HeartbeatSender = (content: PresenceHeartbeatContent) => void | Promise; + +export interface HeartbeatLoopOptions { + emitter: HeartbeatEmitter; + send: HeartbeatSender; + intervalMs: number; + /** Status supplier evaluated each beat. Default: always 'online'. */ + status?: () => PresenceStatus; + /** Called if a beat's send rejects (so a transient failure doesn't kill the loop). */ + onError?: (err: unknown) => void; + /** Injectable timer (tests). Defaults to global setInterval/clearInterval. */ + setIntervalFn?: (cb: () => void, ms: number) => unknown; + clearIntervalFn?: (handle: unknown) => void; +} + +/** A running heartbeat loop; call stop() to end it. */ +export interface HeartbeatLoopHandle { + stop: () => void; +} + +/** + * Start a heartbeat loop: emits one beat immediately, then every intervalMs. + * Returns a handle whose `stop()` is idempotent. + */ +export function startHeartbeatLoop(opts: HeartbeatLoopOptions): HeartbeatLoopHandle { + const status = opts.status ?? (() => 'online' as PresenceStatus); + const onError = opts.onError ?? (() => {}); + const setIntervalFn = opts.setIntervalFn ?? ((cb, ms) => setInterval(cb, ms)); + const clearIntervalFn = + opts.clearIntervalFn ?? ((h) => clearInterval(h as ReturnType)); + + const beat = (): void => { + try { + const result = opts.send(opts.emitter.next(status())); + if (result instanceof Promise) { + result.catch(onError); + } + } catch (err) { + onError(err); + } + }; + + beat(); // immediate first beat so liveness is fresh at once + const handle = setIntervalFn(beat, opts.intervalMs); + + let stopped = false; + return { + stop: () => { + if (stopped) return; + stopped = true; + clearIntervalFn(handle); + }, + }; +} diff --git a/packages/comms/src/index.ts b/packages/comms/src/index.ts new file mode 100644 index 00000000..ba4c2cc0 --- /dev/null +++ b/packages/comms/src/index.ts @@ -0,0 +1,42 @@ +/** + * @mosaicstack/comms — MACP presence SDK (RFC-001 P1). + * + * Minimal, dev-validated slice: set Matrix presence, run the `mosaic.presence` + * heartbeat, and compute deterministic fleet liveness. Enrollment, room + * taxonomy, token minting and signed-authorship are explicitly out of P1. + */ + +export { classifyLiveness, computeFleetLiveness } from './liveness.js'; + +export { + HeartbeatEmitter, + startHeartbeatLoop, + type HeartbeatAgentIdentity, + type HeartbeatEmitterOptions, + type HeartbeatSender, + type HeartbeatLoopOptions, + type HeartbeatLoopHandle, +} from './heartbeat.js'; + +export { + MinimalMatrixClient, + MatrixError, + toMatrixPresence, + type MatrixClientConfig, +} from './matrix-client.js'; + +export { FleetLivenessReader, type FleetLivenessReaderOptions } from './liveness-reader.js'; + +export { PresenceAgent, type PresenceAgentOptions } from './presence-agent.js'; + +export { + DEFAULT_LIVENESS_POLICY, + MACP_VERSION, + type AgentLiveness, + type HeartbeatObservation, + type LivenessPolicy, + type MacpEnvelope, + type MatrixPresence, + type PresenceHeartbeatContent, + type PresenceStatus, +} from './types.js'; diff --git a/packages/comms/src/liveness-reader.ts b/packages/comms/src/liveness-reader.ts new file mode 100644 index 00000000..58211152 --- /dev/null +++ b/packages/comms/src/liveness-reader.ts @@ -0,0 +1,58 @@ +/** + * Fleet liveness reader (RFC-001 §4.5, A2/A4). + * + * Reads `mosaic.presence` heartbeats from the fleet presence room and computes + * deterministic online/away/offline for every agent. This is the surface a + * human (or the escalation watchdog, P2+) reads to answer "who's alive?". + */ + +import { computeFleetLiveness } from './liveness.js'; +import type { MinimalMatrixClient } from './matrix-client.js'; +import { DEFAULT_LIVENESS_POLICY, type AgentLiveness, type LivenessPolicy } from './types.js'; + +export interface FleetLivenessReaderOptions { + client: MinimalMatrixClient; + /** The fleet presence room (id or resolved id). */ + roomId: string; + policy?: LivenessPolicy; + /** Injectable clock for tests. Default Date.now. */ + now?: () => number; + /** How many timeline events to scan back. Default 200. */ + scanLimit?: number; +} + +export class FleetLivenessReader { + private readonly policy: LivenessPolicy; + private readonly now: () => number; + + constructor(private readonly opts: FleetLivenessReaderOptions) { + this.policy = opts.policy ?? DEFAULT_LIVENESS_POLICY; + this.now = opts.now ?? Date.now; + } + + /** Read the room and compute current liveness for every seen agent. */ + async read(): Promise { + const observations = await this.opts.client.readHeartbeats( + this.opts.roomId, + this.opts.scanLimit ?? 200, + ); + return computeFleetLiveness(observations, this.now(), this.policy); + } + + /** A compact human-readable liveness board (A4 CLI view). */ + async formatBoard(): Promise { + const rows = await this.read(); + rows.sort((a, b) => a.slug.localeCompare(b.slug)); + const dot: Record = { online: '🟢', away: '🟡', offline: '🔴' }; + const lines = rows.map( + (r) => + `${dot[r.status] ?? '⚪'} ${r.slug.padEnd(16)} ${r.status.padEnd(8)} ` + + `age=${(r.ageMs / 1000).toFixed(1)}s seq=${r.lastSeq} ${r.mxid}`, + ); + const summary = + `online=${rows.filter((r) => r.status === 'online').length} ` + + `away=${rows.filter((r) => r.status === 'away').length} ` + + `offline=${rows.filter((r) => r.status === 'offline').length}`; + return [`Fleet presence — ${summary}`, ...lines].join('\n'); + } +} diff --git a/packages/comms/src/liveness.ts b/packages/comms/src/liveness.ts new file mode 100644 index 00000000..18494c58 --- /dev/null +++ b/packages/comms/src/liveness.ts @@ -0,0 +1,63 @@ +/** + * Deterministic liveness computation (RFC-001 §4.5). + * + * The authoritative liveness signal is the `mosaic.presence` heartbeat, NOT + * native Matrix presence. Given the age of an agent's last heartbeat and a + * policy, these pure functions classify online/away/offline the same way every + * time — which is exactly what makes the A3 "hard-killed agent flips to + * offline within dark_threshold" guarantee deterministic and testable without + * standing up a homeserver. + */ + +import type { + AgentLiveness, + HeartbeatObservation, + LivenessPolicy, + PresenceStatus, +} from './types.js'; + +/** + * Classify a single agent from the age (ms) of its last heartbeat. + * + * - `age <= heartbeatIntervalMs * missTolerance` → **online** + * - `age < darkThresholdMs` → **away** + * - otherwise (or non-finite age) → **offline / dark** + * + * A non-finite age (never seen / NaN) fails safe to `offline`: we never assert + * a liveness we cannot substantiate. + */ +export function classifyLiveness(ageMs: number, policy: LivenessPolicy): PresenceStatus { + if (!Number.isFinite(ageMs)) { + return 'offline'; + } + const onlineWindowMs = policy.heartbeatIntervalMs * policy.missTolerance; + if (ageMs <= onlineWindowMs) { + return 'online'; + } + if (ageMs < policy.darkThresholdMs) { + return 'away'; + } + return 'offline'; +} + +/** + * Compute liveness for every observed agent at wall-clock `nowMs`. + * The result order mirrors the input order (stable for display). + */ +export function computeFleetLiveness( + observations: readonly HeartbeatObservation[], + nowMs: number, + policy: LivenessPolicy, +): AgentLiveness[] { + return observations.map((o) => { + const ageMs = nowMs - o.lastSeenTs; + return { + slug: o.slug, + mxid: o.mxid, + status: classifyLiveness(ageMs, policy), + lastSeenTs: o.lastSeenTs, + ageMs, + lastSeq: o.lastSeq, + }; + }); +} diff --git a/packages/comms/src/matrix-client.ts b/packages/comms/src/matrix-client.ts new file mode 100644 index 00000000..67f7bdd3 --- /dev/null +++ b/packages/comms/src/matrix-client.ts @@ -0,0 +1,204 @@ +/** + * Minimal Matrix Client-Server API client for the P1 presence slice. + * + * Deliberately tiny: only the calls presence needs (whoami, set native + * presence, send a timeline event, read recent timeline). Auth is a single + * bearer token; an optional `actAsUserId` enables Application-Service + * masquerade (`?user_id=`) so the P1 provisioner can drive several virtual + * agents with one as_token in dev (RFC-001 §2.2 step 4/Appendix A). Agents + * holding their own access_token simply omit `actAsUserId`. + * + * `fetch` is injectable for unit tests. + */ + +import crypto from 'node:crypto'; + +import type { + HeartbeatObservation, + MatrixPresence, + PresenceHeartbeatContent, + PresenceStatus, +} from './types.js'; + +export interface MatrixClientConfig { + /** Client-Server API base, e.g. https://matrix.localhost:8448 */ + homeserverUrl: string; + /** Bearer token (a per-agent access_token, or an as_token for masquerade). */ + accessToken: string; + /** If set, all calls masquerade as this MXID via ?user_id= (AS mode). */ + actAsUserId?: string; +} + +export class MatrixError extends Error { + constructor( + readonly status: number, + readonly errcode: string | undefined, + message: string, + ) { + super(message); + this.name = 'MatrixError'; + } +} + +type FetchLike = typeof fetch; + +/** Map our authoritative liveness state to the native Matrix presence EDU. */ +export function toMatrixPresence(status: PresenceStatus): MatrixPresence { + switch (status) { + case 'online': + return 'online'; + case 'away': + return 'unavailable'; + case 'offline': + return 'offline'; + } +} + +export class MinimalMatrixClient { + private readonly fetchImpl: FetchLike; + + constructor( + private readonly cfg: MatrixClientConfig, + fetchImpl?: FetchLike, + ) { + this.fetchImpl = fetchImpl ?? fetch; + } + + private async request( + method: string, + path: string, + options: { query?: Record; body?: unknown } = {}, + ): Promise> { + const url = new URL(this.cfg.homeserverUrl.replace(/\/$/, '') + path); + if (this.cfg.actAsUserId) { + url.searchParams.set('user_id', this.cfg.actAsUserId); + } + for (const [k, v] of Object.entries(options.query ?? {})) { + url.searchParams.set(k, v); + } + const res = await this.fetchImpl(url, { + method, + headers: { + Authorization: `Bearer ${this.cfg.accessToken}`, + 'Content-Type': 'application/json', + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + const text = await res.text(); + const data = (text ? JSON.parse(text) : {}) as Record; + if (!res.ok) { + throw new MatrixError( + res.status, + typeof data.errcode === 'string' ? data.errcode : undefined, + `${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`, + ); + } + return data; + } + + /** GET /account/whoami — resolves the acting MXID. */ + async whoami(): Promise { + const data = await this.request('GET', '/_matrix/client/v3/account/whoami'); + if (typeof data.user_id !== 'string') { + throw new MatrixError(500, undefined, 'whoami returned no user_id'); + } + return data.user_id; + } + + /** + * Set the native Matrix presence EDU (so Element shows the right dot for + * humans). NOT the authoritative liveness signal — the heartbeat is. + */ + async setPresence(userId: string, status: PresenceStatus, statusMsg?: string): Promise { + const user = encodeURIComponent(userId); + await this.request('PUT', `/_matrix/client/v3/presence/${user}/status`, { + body: { + presence: toMatrixPresence(status), + ...(statusMsg ? { status_msg: statusMsg } : {}), + }, + }); + } + + /** Send an arbitrary timeline event; returns its event_id. */ + async sendEvent( + roomId: string, + eventType: string, + content: Record, + ): Promise { + const room = encodeURIComponent(roomId); + const txn = `mosaic-comms-${crypto.randomUUID()}`; + const data = await this.request( + 'PUT', + `/_matrix/client/v3/rooms/${room}/send/${encodeURIComponent(eventType)}/${txn}`, + { body: content }, + ); + if (typeof data.event_id !== 'string') { + throw new MatrixError(500, undefined, 'send returned no event_id'); + } + return data.event_id; + } + + /** Post a `mosaic.presence` heartbeat (m.room.message carrier) to the room. */ + async sendHeartbeat(roomId: string, content: PresenceHeartbeatContent): Promise { + return this.sendEvent(roomId, 'm.room.message', content as unknown as Record); + } + + /** Join a room (by id or alias). Idempotent on the server. */ + async joinRoom(roomIdOrAlias: string): Promise { + const data = await this.request( + 'POST', + `/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}`, + { body: {} }, + ); + if (typeof data.room_id !== 'string') { + throw new MatrixError(500, undefined, 'join returned no room_id'); + } + return data.room_id; + } + + /** + * Read recent `mosaic.presence` heartbeats from a room and reduce them to the + * latest observation per agent. Walks the timeline backwards (most-recent + * first) and keeps, per slug, the beat with the highest seq. + * + * `lastSeenTs` uses the server's `origin_server_ts` (honest "when we last + * heard from it"), falling back to the agent-stamped envelope `ts`. + */ + async readHeartbeats(roomId: string, limit = 200): Promise { + const room = encodeURIComponent(roomId); + const data = await this.request('GET', `/_matrix/client/v3/rooms/${room}/messages`, { + query: { dir: 'b', limit: String(limit) }, + }); + const chunk = Array.isArray(data.chunk) ? (data.chunk as Array>) : []; + const bySlug = new Map(); + + for (const ev of chunk) { + const content = ev.content as Record | undefined; + if (!content || content.msgtype !== 'mosaic.presence') continue; + const agent = content.agent as Record | undefined; + const slug = agent && typeof agent.slug === 'string' ? agent.slug : undefined; + const mxid = + agent && typeof agent.mxid === 'string' + ? agent.mxid + : typeof ev.sender === 'string' + ? ev.sender + : undefined; + if (!slug || !mxid) continue; + + const seq = typeof content.seq === 'number' ? content.seq : 0; + const serverTs = typeof ev.origin_server_ts === 'number' ? ev.origin_server_ts : undefined; + const envelopeTs = typeof content.ts === 'number' ? content.ts : undefined; + const lastSeenTs = serverTs ?? envelopeTs ?? 0; + const assertedStatus = + content.status === 'online' || content.status === 'away' || content.status === 'offline' + ? (content.status as PresenceStatus) + : 'offline'; + + const prev = bySlug.get(slug); + if (!prev || seq > prev.lastSeq) { + bySlug.set(slug, { slug, mxid, lastSeenTs, lastSeq: seq, assertedStatus }); + } + } + return [...bySlug.values()]; + } +} diff --git a/packages/comms/src/presence-agent.ts b/packages/comms/src/presence-agent.ts new file mode 100644 index 00000000..fbd7e20a --- /dev/null +++ b/packages/comms/src/presence-agent.ts @@ -0,0 +1,92 @@ +/** + * High-level presence agent (RFC-001 §4.1 steps 10–11, §4.5). + * + * Ties the pieces together for one agent: join the fleet presence room, set + * native Matrix presence online (for Element's dot), and run the authoritative + * `mosaic.presence` heartbeat loop. This is the P1 slice of what a harness does + * on spin — no enrollment/token-minting/introductions (those are P2). + */ + +import { + HeartbeatEmitter, + startHeartbeatLoop, + type HeartbeatAgentIdentity, + type HeartbeatLoopHandle, +} from './heartbeat.js'; +import type { MinimalMatrixClient } from './matrix-client.js'; +import { DEFAULT_LIVENESS_POLICY, type LivenessPolicy, type PresenceStatus } from './types.js'; + +export interface PresenceAgentOptions { + client: MinimalMatrixClient; + agent: HeartbeatAgentIdentity; + /** Fleet presence room id (or alias) to heartbeat into. */ + roomId: string; + /** Heartbeat cadence; defaults to the policy interval. */ + intervalMs?: number; + policy?: LivenessPolicy; + missionId?: string; + onError?: (err: unknown) => void; +} + +export class PresenceAgent { + private readonly intervalMs: number; + private readonly emitter: HeartbeatEmitter; + private loop: HeartbeatLoopHandle | undefined; + private resolvedRoomId: string | undefined; + + constructor(private readonly opts: PresenceAgentOptions) { + const policy = opts.policy ?? DEFAULT_LIVENESS_POLICY; + this.intervalMs = opts.intervalMs ?? policy.heartbeatIntervalMs; + this.emitter = new HeartbeatEmitter({ + agent: opts.agent, + intervalMs: this.intervalMs, + missionId: opts.missionId, + }); + } + + /** Join the fleet room and go present. Returns the resolved room id. */ + async connect(): Promise { + this.resolvedRoomId = await this.opts.client.joinRoom(this.opts.roomId); + await this.opts.client.setPresence(this.opts.agent.mxid, 'online', 'mosaic.presence heartbeat'); + return this.resolvedRoomId; + } + + /** Start the heartbeat loop (emits immediately, then every intervalMs). */ + start(status: () => PresenceStatus = () => 'online'): void { + const roomId = this.resolvedRoomId ?? this.opts.roomId; + this.loop = startHeartbeatLoop({ + emitter: this.emitter, + intervalMs: this.intervalMs, + status, + onError: this.opts.onError, + send: async (content) => { + await this.opts.client.sendHeartbeat(roomId, content); + }, + }); + } + + get currentSeq(): number { + return this.emitter.currentSeq; + } + + /** + * Stop only the heartbeat loop, sending NO graceful signal. This models a + * hard crash/kill: the authoritative liveness path must detect it purely from + * the absence of heartbeats (RFC-001 §4.5, A3), not from any native presence + * change. Idempotent. + */ + pauseHeartbeat(): void { + this.loop?.stop(); + this.loop = undefined; + } + + /** Graceful stop: stop heartbeating and drop native presence to offline. */ + async stop(): Promise { + this.pauseHeartbeat(); + try { + await this.opts.client.setPresence(this.opts.agent.mxid, 'offline'); + } catch (err) { + this.opts.onError?.(err); + } + } +} diff --git a/packages/comms/src/types.ts b/packages/comms/src/types.ts new file mode 100644 index 00000000..49605258 --- /dev/null +++ b/packages/comms/src/types.ts @@ -0,0 +1,99 @@ +/** + * @mosaicstack/comms — MACP P1 (presence) types. + * + * Implements the presence/liveness slice of RFC-001 §4.5 and the MACP event + * envelope of RFC-001 §4.2. P1 scope only: presence heartbeat + deterministic + * liveness. No enrollment, room-taxonomy, token-minting or signed-authorship + * (those are P2+). + */ + +/** The three human-visible liveness states (RFC-001 §4.5). */ +export type PresenceStatus = 'online' | 'away' | 'offline'; + +/** + * Native Matrix presence EDU states. We still emit these (so Element shows the + * right dot for humans, RFC-001 §4.5) but they are NOT the authoritative + * liveness source — the heartbeat is. + */ +export type MatrixPresence = 'online' | 'unavailable' | 'offline'; + +/** + * Common MACP event envelope carried in `content` on every custom event + * (RFC-001 §4.2). P1 uses only the fields the presence heartbeat needs; the + * `signature` field (gate actions, §4.4) is intentionally absent in P1. + */ +export interface MacpEnvelope { + macp_version: string; + macp_type: string; + agent: { + mxid: string; + slug: string; + harness: string; + }; + ts: number; + mission_id?: string; +} + +/** + * `mosaic.presence` heartbeat content (RFC-001 §4.2 "presence" row + §4.5). + * Carried as an `m.room.message` with `msgtype: "mosaic.presence"` and a + * human-visible `body` fallback, posted into the fleet presence room. + */ +export interface PresenceHeartbeatContent extends MacpEnvelope { + macp_type: 'presence'; + msgtype: 'mosaic.presence'; + /** Human-visible fallback so the event renders in a stock client. */ + body: string; + /** Liveness state the agent asserts about itself. */ + status: PresenceStatus; + /** Monotonic per-agent sequence number, increments once per beat. */ + seq: number; + /** The agent's configured heartbeat interval, so readers can reason. */ + interval_ms: number; +} + +/** + * Deterministic liveness policy (RFC-001 §4.5). Defaults per §4.5/§5.3: + * interval 30s, miss-tolerance 2, dark threshold a policy value (10 min in + * prod §5; small in dev harness). + */ +export interface LivenessPolicy { + /** Nominal heartbeat interval in ms. Default 30_000. */ + heartbeatIntervalMs: number; + /** How many intervals may be missed before "away". Default 2. */ + missTolerance: number; + /** Age past which an agent is declared offline/dark. Default 600_000. */ + darkThresholdMs: number; +} + +/** A single agent's last observed heartbeat, as read from the fleet room. */ +export interface HeartbeatObservation { + slug: string; + mxid: string; + /** Wall-clock ms of the last heartbeat seen for this agent. */ + lastSeenTs: number; + /** Last seq observed (monotonic per agent). */ + lastSeq: number; + /** The status the agent last asserted about itself. */ + assertedStatus: PresenceStatus; +} + +/** Computed liveness for one agent (what a human/watchdog reads). */ +export interface AgentLiveness { + slug: string; + mxid: string; + /** Authoritative, heartbeat-derived status. */ + status: PresenceStatus; + lastSeenTs: number; + /** now - lastSeenTs, in ms. */ + ageMs: number; + lastSeq: number; +} + +export const DEFAULT_LIVENESS_POLICY: LivenessPolicy = { + heartbeatIntervalMs: 30_000, + missTolerance: 2, + darkThresholdMs: 600_000, +}; + +export const MACP_VERSION = '1.0'; diff --git a/packages/comms/tsconfig.json b/packages/comms/tsconfig.json new file mode 100644 index 00000000..c9733863 --- /dev/null +++ b/packages/comms/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/comms/vitest.config.ts b/packages/comms/vitest.config.ts new file mode 100644 index 00000000..b27ea14b --- /dev/null +++ b/packages/comms/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/index.ts'], + }, + }, +}); diff --git a/packages/coord/src/__tests__/interaction-coordination.test.ts b/packages/coord/src/__tests__/interaction-coordination.test.ts new file mode 100644 index 00000000..cb64ee39 --- /dev/null +++ b/packages/coord/src/__tests__/interaction-coordination.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + InMemoryInteractionCoordinationPort, + InteractionCoordinationClient, + type CoordinationScope, + type InteractionCoordinationAuthorityError, + type InteractionCoordinationPort, +} from '../index.js'; + +const scope: CoordinationScope = { + actorId: 'operator-1', + tenantId: 'tenant-a', + correlationId: 'corr-1', + requesterAgentId: 'Nova', +}; + +function client( + port: InteractionCoordinationPort, + handoffIdFactory: () => string = (): string => 'handoff-1', +): InteractionCoordinationClient { + return new InteractionCoordinationClient( + { interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' }, + port, + handoffIdFactory, + ); +} + +describe('InteractionCoordinationClient', (): void => { + it('round-trips handoff, observation, and result through the native port with identities as data', async (): Promise => { + const adapter = new InMemoryInteractionCoordinationPort(); + const coordination = client(adapter); + + await expect( + coordination.handoff( + { idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' }, + scope, + ), + ).resolves.toEqual({ + handoffId: 'handoff-1', + targetAgentId: 'Conductor', + status: 'queued', + correlationId: 'corr-1', + }); + + adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request'); + adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator'); + + await expect(coordination.observe('handoff-1', scope)).resolves.toMatchObject({ + status: 'completed', + targetAgentId: 'Conductor', + activity: expect.arrayContaining([ + expect.objectContaining({ status: 'queued' }), + expect.objectContaining({ status: 'running' }), + expect.objectContaining({ status: 'completed' }), + ]), + }); + await expect(coordination.result('handoff-1', scope)).resolves.toEqual({ + handoffId: 'handoff-1', + targetAgentId: 'Conductor', + status: 'completed', + correlationId: 'corr-1', + summary: 'Merged by orchestrator', + }); + + expect(coordination).not.toHaveProperty('dispatch'); + expect(coordination).not.toHaveProperty('assign'); + expect(coordination).not.toHaveProperty('review'); + expect(coordination).not.toHaveProperty('merge'); + expect(coordination).not.toHaveProperty('cancel'); + }); + + it('fails closed before delivery when an unconfigured agent requests orchestrator work', async (): Promise => { + const adapter = new InMemoryInteractionCoordinationPort(); + + await expect( + client(adapter).handoff( + { idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' }, + { ...scope, requesterAgentId: 'Untrusted' }, + ), + ).rejects.toMatchObject({ + code: 'requester_forbidden', + } satisfies Partial); + }); + + it('rejects self-delegation configuration before constructing a client', (): void => { + expect( + (): InteractionCoordinationClient => + new InteractionCoordinationClient( + { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' }, + new InMemoryInteractionCoordinationPort(), + ), + ).toThrow('Interaction and orchestration identities must differ'); + }); + + it('rejects whitespace-equivalent self-delegation identities', (): void => { + expect( + (): InteractionCoordinationClient => + new InteractionCoordinationClient( + { interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' }, + new InMemoryInteractionCoordinationPort(), + ), + ).toThrow('Interaction and orchestration identities must differ'); + }); + + it('does not expose another tenant handoff to observe or result', async (): Promise => { + const adapter = new InMemoryInteractionCoordinationPort(); + const coordination = client(adapter); + await coordination.handoff( + { idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' }, + scope, + ); + + const otherTenantScope = { ...scope, tenantId: 'tenant-b' }; + await expect(coordination.observe('handoff-1', otherTenantScope)).rejects.toMatchObject({ + code: 'forbidden', + }); + await expect(coordination.result('handoff-1', otherTenantScope)).rejects.toMatchObject({ + code: 'forbidden', + }); + }); + + it('bounds native handoff retention by evicting the oldest handoff', async (): Promise => { + const adapter = new InMemoryInteractionCoordinationPort({ maxHandoffs: 1 }); + const first = client(adapter, (): string => 'handoff-1'); + const second = client(adapter, (): string => 'handoff-2'); + await first.handoff({ idempotencyKey: 'handoff-request-1', summary: 'First request' }, scope); + await second.handoff({ idempotencyKey: 'handoff-request-2', summary: 'Second request' }, scope); + + await expect(first.observe('handoff-1', scope)).rejects.toMatchObject({ code: 'not_found' }); + await expect(second.observe('handoff-2', scope)).resolves.toMatchObject({ status: 'queued' }); + }); + + it('fails closed when a transport reports target drift', async (): Promise => { + const adapter: InteractionCoordinationPort = { + handoff: vi.fn(async () => ({ + handoffId: 'handoff-1', + targetAgentId: 'Unexpected', + status: 'accepted' as const, + correlationId: 'corr-1', + })), + observe: vi.fn(), + result: vi.fn(), + }; + + await expect( + client(adapter).handoff( + { idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' }, + scope, + ), + ).rejects.toMatchObject({ + code: 'target_drift', + } satisfies Partial); + }); +}); diff --git a/packages/coord/src/__tests__/runtime-launch-gate.test.ts b/packages/coord/src/__tests__/runtime-launch-gate.test.ts new file mode 100644 index 00000000..3e27f0de --- /dev/null +++ b/packages/coord/src/__tests__/runtime-launch-gate.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveLaunchCommand } from '../runner.js'; + +describe('coord consequential-runtime launch gate', () => { + it('routes default and direct configured Claude commands through mosaic', () => { + expect(resolveLaunchCommand('claude', 'continue', undefined)).toEqual([ + 'mosaic', + 'claude', + '-p', + 'continue', + ]); + expect(resolveLaunchCommand('claude', 'continue', ['claude', '-p', '{prompt}'])).toEqual([ + 'mosaic', + 'claude', + '-p', + 'continue', + ]); + }); + + it('preserves an already-gated Claude command and rejects unknown launchers', () => { + expect( + resolveLaunchCommand('claude', 'continue', ['mosaic', 'yolo', 'claude', '{prompt}']), + ).toEqual(['mosaic', 'yolo', 'claude', 'continue']); + expect(() => resolveLaunchCommand('claude', 'continue', ['custom-launcher'])).toThrow( + /must use `mosaic claude`/, + ); + }); + + it('does not change the out-of-scope Codex command contract', () => { + expect(resolveLaunchCommand('codex', 'continue', undefined)).toEqual([ + 'codex', + '-p', + 'continue', + ]); + expect(resolveLaunchCommand('codex', 'continue', ['codex', '{prompt}'])).toEqual([ + 'codex', + 'continue', + ]); + }); +}); diff --git a/packages/coord/src/in-memory-interaction-coordination-port.ts b/packages/coord/src/in-memory-interaction-coordination-port.ts new file mode 100644 index 00000000..517e2e12 --- /dev/null +++ b/packages/coord/src/in-memory-interaction-coordination-port.ts @@ -0,0 +1,208 @@ +import { + type CoordinationObservation, + type CoordinationResult, + type CoordinationScope, + type InteractionCoordinationActivity, + type InteractionCoordinationPort, + type Handoff, + type HandoffReceipt, + type HandoffStatus, +} from './interaction-coordination.js'; + +const DEFAULT_HANDOFF_TTL_MS = 60 * 60 * 1_000; +const DEFAULT_MAX_HANDOFFS = 1_000; + +interface StoredHandoff { + readonly handoff: Handoff; + status: HandoffStatus; + readonly activity: InteractionCoordinationActivity[]; + readonly expiresAt: number; + result?: CoordinationResult; +} + +export interface InMemoryInteractionCoordinationPortOptions { + now?: () => Date; + handoffTtlMs?: number; + maxHandoffs?: number; +} + +/** + * Native deterministic queue/port adapter for the coordination boundary. + * It intentionally has no fleet/tmux dependency. A future deployment adapter + * implements InteractionCoordinationPort without changing interaction-plane callers. + */ +export class InMemoryInteractionCoordinationPort implements InteractionCoordinationPort { + private readonly handoffs = new Map(); + private readonly now: () => Date; + private readonly handoffTtlMs: number; + private readonly maxHandoffs: number; + + constructor(options: InMemoryInteractionCoordinationPortOptions = {}) { + this.now = options.now ?? (() => new Date()); + this.handoffTtlMs = options.handoffTtlMs ?? DEFAULT_HANDOFF_TTL_MS; + this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS; + } + + async handoff(handoff: Handoff): Promise { + this.pruneExpiredHandoffs(); + const existing = this.handoffs.get(handoff.handoffId); + if (existing !== undefined) { + this.assertSameHandoff(existing.handoff, handoff); + return this.receipt(existing.handoff, existing.status); + } + + const stored: StoredHandoff = { + handoff: snapshotHandoff(handoff), + status: 'queued', + activity: [activity('queued', 'Handoff accepted by the native coordination queue', this.now)], + expiresAt: this.now().getTime() + this.handoffTtlMs, + }; + this.handoffs.set(handoff.handoffId, stored); + this.enforceHandoffLimit(); + return this.receipt(stored.handoff, stored.status); + } + + async observe(handoffId: string, scope: CoordinationScope): Promise { + this.pruneExpiredHandoffs(); + const stored = this.requireScopedHandoff(handoffId, scope); + return { + handoffId: stored.handoff.handoffId, + targetAgentId: stored.handoff.targetAgentId, + status: stored.status, + correlationId: stored.handoff.scope.correlationId, + activity: stored.activity.map(copyActivity), + }; + } + + async result(handoffId: string, scope: CoordinationScope): Promise { + this.pruneExpiredHandoffs(); + const stored = this.requireScopedHandoff(handoffId, scope); + return ( + stored.result ?? { + handoffId: stored.handoff.handoffId, + targetAgentId: stored.handoff.targetAgentId, + status: 'pending', + correlationId: stored.handoff.scope.correlationId, + } + ); + } + + /** Host-side progression seam; interaction clients never receive this capability. */ + recordActivity(handoffId: string, status: HandoffStatus, summary: string): void { + this.pruneExpiredHandoffs(); + const stored = this.requireHandoff(handoffId); + stored.status = status; + stored.activity.push(activity(status, summary, this.now)); + } + + /** Host-side result seam for deterministic qualification; not an orchestrator consumer. */ + recordResult(handoffId: string, status: 'completed' | 'failed', summary: string): void { + this.pruneExpiredHandoffs(); + const stored = this.requireHandoff(handoffId); + stored.status = status; + stored.activity.push(activity(status, summary, this.now)); + stored.result = { + handoffId: stored.handoff.handoffId, + targetAgentId: stored.handoff.targetAgentId, + status, + correlationId: stored.handoff.scope.correlationId, + summary, + }; + } + + private receipt(handoff: Handoff, status: HandoffStatus): HandoffReceipt { + return { + handoffId: handoff.handoffId, + targetAgentId: handoff.targetAgentId, + status: status === 'accepted' ? 'accepted' : 'queued', + correlationId: handoff.scope.correlationId, + }; + } + + private pruneExpiredHandoffs(): void { + const nowMs = this.now().getTime(); + for (const [handoffId, handoff] of this.handoffs) { + if (handoff.expiresAt <= nowMs) this.handoffs.delete(handoffId); + } + } + + private enforceHandoffLimit(): void { + while (this.handoffs.size > this.maxHandoffs) { + const oldest = this.handoffs.keys().next().value; + if (typeof oldest !== 'string') return; + this.handoffs.delete(oldest); + } + } + + private requireScopedHandoff(handoffId: string, scope: CoordinationScope): StoredHandoff { + const stored = this.requireHandoff(handoffId); + if ( + stored.handoff.scope.tenantId !== scope.tenantId || + stored.handoff.scope.actorId !== scope.actorId || + stored.handoff.scope.requesterAgentId !== scope.requesterAgentId + ) { + throw new InMemoryInteractionCoordinationError('forbidden', 'Handoff scope does not match'); + } + return stored; + } + + private requireHandoff(handoffId: string): StoredHandoff { + const stored = this.handoffs.get(handoffId); + if (stored === undefined) { + throw new InMemoryInteractionCoordinationError('not_found', 'Handoff was not found'); + } + return stored; + } + + private assertSameHandoff(existing: Handoff, incoming: Handoff): void { + if ( + existing.targetAgentId !== incoming.targetAgentId || + existing.request.idempotencyKey !== incoming.request.idempotencyKey || + existing.request.summary !== incoming.request.summary || + existing.request.context !== incoming.request.context || + existing.request.missionId !== incoming.request.missionId || + existing.scope.actorId !== incoming.scope.actorId || + existing.scope.tenantId !== incoming.scope.tenantId || + existing.scope.correlationId !== incoming.scope.correlationId || + existing.scope.requesterAgentId !== incoming.scope.requesterAgentId + ) { + throw new InMemoryInteractionCoordinationError( + 'conflict', + 'Handoff ID is already bound to different immutable input', + ); + } + } +} + +export type InMemoryInteractionCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found'; + +export class InMemoryInteractionCoordinationError extends Error { + constructor( + readonly code: InMemoryInteractionCoordinationErrorCode, + message: string, + ) { + super(message); + this.name = InMemoryInteractionCoordinationError.name; + } +} + +function activity( + status: HandoffStatus, + summary: string, + now: () => Date, +): InteractionCoordinationActivity { + return { occurredAt: now().toISOString(), status, summary }; +} + +function copyActivity(entry: InteractionCoordinationActivity): InteractionCoordinationActivity { + return { ...entry }; +} + +function snapshotHandoff(handoff: Handoff): Handoff { + return Object.freeze({ + handoffId: handoff.handoffId, + targetAgentId: handoff.targetAgentId, + request: Object.freeze({ ...handoff.request }), + scope: Object.freeze({ ...handoff.scope }), + }); +} diff --git a/packages/coord/src/index.ts b/packages/coord/src/index.ts index 7ac5309c..db708740 100644 --- a/packages/coord/src/index.ts +++ b/packages/coord/src/index.ts @@ -2,6 +2,26 @@ export { createMission, loadMission, missionFilePath, saveMission } from './miss export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.js'; export { runTask, resumeTask } from './runner.js'; export { getMissionStatus, getTaskStatus } from './status.js'; +export { + InMemoryInteractionCoordinationError, + InMemoryInteractionCoordinationPort, +} from './in-memory-interaction-coordination-port.js'; +export { + InteractionCoordinationAuthorityError, + InteractionCoordinationClient, +} from './interaction-coordination.js'; +export type { + CoordinationObservation, + CoordinationResult, + CoordinationScope, + InteractionCoordinationActivity, + InteractionCoordinationIdentity, + InteractionCoordinationPort, + Handoff, + HandoffReceipt, + HandoffRequest, + HandoffStatus, +} from './interaction-coordination.js'; export type { CreateMissionOptions, Mission, diff --git a/packages/coord/src/interaction-coordination.ts b/packages/coord/src/interaction-coordination.ts new file mode 100644 index 00000000..a07a3ff0 --- /dev/null +++ b/packages/coord/src/interaction-coordination.ts @@ -0,0 +1,209 @@ +export type HandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed'; + +export interface CoordinationScope { + readonly actorId: string; + readonly tenantId: string; + readonly correlationId: string; + /** Trusted gateway/configuration identity; never supplied by a channel client. */ + readonly requesterAgentId: string; +} + +export interface InteractionCoordinationIdentity { + readonly interactionAgentId: string; + readonly orchestrationAgentId: string; +} + +export interface HandoffRequest { + readonly idempotencyKey: string; + readonly summary: string; + readonly context?: string; + readonly missionId?: string; +} + +export interface Handoff { + readonly handoffId: string; + readonly targetAgentId: string; + readonly request: HandoffRequest; + readonly scope: CoordinationScope; +} + +export interface HandoffReceipt { + readonly handoffId: string; + readonly targetAgentId: string; + readonly status: 'queued' | 'accepted'; + readonly correlationId: string; +} + +export interface InteractionCoordinationActivity { + readonly occurredAt: string; + readonly status: HandoffStatus; + readonly summary: string; +} + +export interface CoordinationObservation { + readonly handoffId: string; + readonly targetAgentId: string; + readonly status: HandoffStatus; + readonly correlationId: string; + readonly activity: readonly InteractionCoordinationActivity[]; +} + +export interface CoordinationResult { + readonly handoffId: string; + readonly targetAgentId: string; + readonly status: 'completed' | 'failed' | 'pending'; + readonly correlationId: string; + readonly summary?: string; +} + +/** + * Transport-neutral boundary. The interaction plane can request work and read + * its progress/result, but it cannot issue worker, review, merge, or other + * general orchestration commands. + */ +export interface InteractionCoordinationPort { + handoff(handoff: Handoff): Promise; + observe(handoffId: string, scope: CoordinationScope): Promise; + result(handoffId: string, scope: CoordinationScope): Promise; +} + +export type InteractionCoordinationAuthorityErrorCode = + | 'invalid_identity' + | 'requester_forbidden' + | 'target_drift' + | 'correlation_drift'; + +export class InteractionCoordinationAuthorityError extends Error { + constructor( + readonly code: InteractionCoordinationAuthorityErrorCode, + message: string, + ) { + super(message); + this.name = InteractionCoordinationAuthorityError.name; + } +} + +/** + * Enforces the interaction-to-orchestration authority boundary before a + * transport is reached. Identity names remain configuration data. + */ +export class InteractionCoordinationClient { + private readonly identity: InteractionCoordinationIdentity; + + constructor( + identity: InteractionCoordinationIdentity, + private readonly port: InteractionCoordinationPort, + private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(), + ) { + this.identity = normalizeIdentity(identity); + } + + async handoff(request: HandoffRequest, scope: CoordinationScope): Promise { + this.assertRequester(scope); + const handoff: Handoff = { + handoffId: this.handoffIdFactory(), + targetAgentId: this.identity.orchestrationAgentId, + request: snapshotRequest(request), + scope: snapshotScope(scope), + }; + const receipt = await this.port.handoff(handoff); + if ( + receipt.handoffId !== handoff.handoffId || + receipt.targetAgentId !== handoff.targetAgentId + ) { + throw new InteractionCoordinationAuthorityError( + 'target_drift', + 'Interaction coordination transport returned a mismatched handoff target', + ); + } + if (receipt.correlationId !== handoff.scope.correlationId) { + throw new InteractionCoordinationAuthorityError( + 'correlation_drift', + 'Interaction coordination transport returned a mismatched correlation ID', + ); + } + return receipt; + } + + async observe(handoffId: string, scope: CoordinationScope): Promise { + this.assertRequester(scope); + return this.assertObservation(await this.port.observe(handoffId, snapshotScope(scope)), scope); + } + + async result(handoffId: string, scope: CoordinationScope): Promise { + this.assertRequester(scope); + return this.assertResult(await this.port.result(handoffId, snapshotScope(scope)), scope); + } + + private assertRequester(scope: CoordinationScope): void { + if (scope.requesterAgentId !== this.identity.interactionAgentId) { + throw new InteractionCoordinationAuthorityError( + 'requester_forbidden', + 'Requester is not the configured interaction agent', + ); + } + } + + private assertObservation( + observation: CoordinationObservation, + scope: CoordinationScope, + ): CoordinationObservation { + if (observation.targetAgentId !== this.identity.orchestrationAgentId) { + throw new InteractionCoordinationAuthorityError( + 'target_drift', + 'Interaction coordination transport returned an unexpected observation target', + ); + } + if (observation.correlationId !== scope.correlationId) { + throw new InteractionCoordinationAuthorityError( + 'correlation_drift', + 'Interaction coordination transport returned a mismatched observation correlation ID', + ); + } + return observation; + } + + private assertResult(result: CoordinationResult, scope: CoordinationScope): CoordinationResult { + if (result.targetAgentId !== this.identity.orchestrationAgentId) { + throw new InteractionCoordinationAuthorityError( + 'target_drift', + 'Interaction coordination transport returned an unexpected result target', + ); + } + if (result.correlationId !== scope.correlationId) { + throw new InteractionCoordinationAuthorityError( + 'correlation_drift', + 'Interaction coordination transport returned a mismatched result correlation ID', + ); + } + return result; + } +} + +function normalizeIdentity( + identity: InteractionCoordinationIdentity, +): InteractionCoordinationIdentity { + const interactionAgentId = identity.interactionAgentId.trim(); + const orchestrationAgentId = identity.orchestrationAgentId.trim(); + if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) { + throw new InteractionCoordinationAuthorityError( + 'invalid_identity', + 'Interaction and orchestration identities are required', + ); + } + if (interactionAgentId === orchestrationAgentId) { + throw new InteractionCoordinationAuthorityError( + 'invalid_identity', + 'Interaction and orchestration identities must differ', + ); + } + return Object.freeze({ interactionAgentId, orchestrationAgentId }); +} + +function snapshotRequest(request: HandoffRequest): HandoffRequest { + return Object.freeze({ ...request }); +} + +function snapshotScope(scope: CoordinationScope): CoordinationScope { + return Object.freeze({ ...scope }); +} diff --git a/packages/coord/src/runner.ts b/packages/coord/src/runner.ts index fd3d05de..ed663c8a 100644 --- a/packages/coord/src/runner.ts +++ b/packages/coord/src/runner.ts @@ -179,32 +179,41 @@ function buildContinuationPrompt(params: { `3. Read \`${mission.scratchpadFile}\` for session history and decisions`, `4. Read \`${mission.tasksFile}\` for current task state`, '5. `git pull --rebase` to sync latest changes', - `6. Launch runtime with \`${runtime} -p\``, + `6. Launch runtime with \`mosaic ${runtime} -p\``, `7. Continue execution from task **${taskId}**`, '8. Follow Two-Phase Completion Protocol', `9. You are the SOLE writer of \`${mission.tasksFile}\``, ].join('\n'); } -function resolveLaunchCommand( +export function resolveLaunchCommand( runtime: 'claude' | 'codex', prompt: string, configuredCommand: string[] | undefined, ): string[] { if (configuredCommand === undefined || configuredCommand.length === 0) { - return [runtime, '-p', prompt]; + return runtime === 'claude' ? ['mosaic', 'claude', '-p', prompt] : [runtime, '-p', prompt]; } const hasPromptPlaceholder = configuredCommand.some((value) => value === '{prompt}'); const withInterpolation = configuredCommand.map((value) => value === '{prompt}' ? prompt : value, ); + const command = hasPromptPlaceholder ? withInterpolation : [...withInterpolation, prompt]; - if (hasPromptPlaceholder) { - return withInterpolation; + if (runtime !== 'claude') return command; + if ( + command[0] === 'mosaic' && + (command[1] === 'claude' || (command[1] === 'yolo' && command[2] === 'claude')) + ) { + return command; } - - return [...withInterpolation, prompt]; + if (command[0] === 'claude') { + return ['mosaic', 'claude', ...command.slice(1)]; + } + throw new Error( + 'Custom Claude task commands must use `mosaic claude` so lease registration cannot be bypassed.', + ); } async function writeAtomicJson(filePath: string, payload: unknown): Promise { diff --git a/packages/db/drizzle/0012_interaction_durable_state.sql b/packages/db/drizzle/0012_interaction_durable_state.sql new file mode 100644 index 00000000..36d8680a --- /dev/null +++ b/packages/db/drizzle/0012_interaction_durable_state.sql @@ -0,0 +1,71 @@ +CREATE TYPE "public"."interaction_handoff_status" AS ENUM('pending', 'accepted');--> statement-breakpoint +CREATE TYPE "public"."interaction_inbox_status" AS ENUM('pending', 'processing', 'processed');--> statement-breakpoint +CREATE TYPE "public"."interaction_outbox_status" AS ENUM('pending', 'processing', 'delivered');--> statement-breakpoint +CREATE TABLE "interaction_checkpoints" ( + "session_id" text PRIMARY KEY NOT NULL, + "checkpoint_id" text NOT NULL, + "cursor" text NOT NULL, + "summary" text NOT NULL, + "compaction_epoch" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "interaction_checkpoints_checkpoint_id_unique" UNIQUE("checkpoint_id") +); +--> statement-breakpoint +CREATE TABLE "interaction_handoffs" ( + "handoff_id" text PRIMARY KEY NOT NULL, + "session_id" text NOT NULL, + "destination" text NOT NULL, + "correlation_id" text NOT NULL, + "checkpoint_id" text NOT NULL, + "status" "interaction_handoff_status" DEFAULT 'pending' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "interaction_inbox" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" text NOT NULL, + "idempotency_key" text NOT NULL, + "correlation_id" text NOT NULL, + "content" text NOT NULL, + "content_digest" text NOT NULL, + "status" "interaction_inbox_status" DEFAULT 'pending' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "interaction_outbox" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" text NOT NULL, + "idempotency_key" text NOT NULL, + "correlation_id" text NOT NULL, + "kind" text NOT NULL, + "content" text NOT NULL, + "content_digest" text NOT NULL, + "status" "interaction_outbox_status" DEFAULT 'pending' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "interaction_sessions" ( + "id" text PRIMARY KEY NOT NULL, + "agent_name" text NOT NULL, + "tenant_id" text NOT NULL, + "owner_id" text NOT NULL, + "provider_id" text NOT NULL, + "runtime_session_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "interaction_checkpoints" ADD CONSTRAINT "interaction_checkpoints_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "interaction_handoffs" ADD CONSTRAINT "interaction_handoffs_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "interaction_inbox" ADD CONSTRAINT "interaction_inbox_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "interaction_outbox" ADD CONSTRAINT "interaction_outbox_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "interaction_sessions" ADD CONSTRAINT "interaction_sessions_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "interaction_handoffs_session_status_idx" ON "interaction_handoffs" USING btree ("session_id","status");--> statement-breakpoint +CREATE UNIQUE INDEX "interaction_inbox_session_idempotency_idx" ON "interaction_inbox" USING btree ("session_id","idempotency_key");--> statement-breakpoint +CREATE INDEX "interaction_inbox_session_status_created_idx" ON "interaction_inbox" USING btree ("session_id","status","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "interaction_outbox_session_idempotency_idx" ON "interaction_outbox" USING btree ("session_id","idempotency_key");--> statement-breakpoint +CREATE INDEX "interaction_outbox_session_status_created_idx" ON "interaction_outbox" USING btree ("session_id","status","created_at"); \ No newline at end of file diff --git a/packages/db/drizzle/0013_interaction_checkpoint_history.sql b/packages/db/drizzle/0013_interaction_checkpoint_history.sql new file mode 100644 index 00000000..7fc8573c --- /dev/null +++ b/packages/db/drizzle/0013_interaction_checkpoint_history.sql @@ -0,0 +1,5 @@ +ALTER TABLE "interaction_checkpoints" DROP CONSTRAINT "interaction_checkpoints_checkpoint_id_unique";--> statement-breakpoint +ALTER TABLE "interaction_checkpoints" DROP CONSTRAINT "interaction_checkpoints_pkey";--> statement-breakpoint +ALTER TABLE "interaction_checkpoints" ADD COLUMN "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "interaction_checkpoints_session_idempotency_idx" ON "interaction_checkpoints" USING btree ("session_id","checkpoint_id");--> statement-breakpoint +CREATE INDEX "interaction_checkpoints_session_epoch_idx" ON "interaction_checkpoints" USING btree ("session_id","compaction_epoch"); \ No newline at end of file diff --git a/packages/db/drizzle/0014_interaction_outbox_channel_scope.sql b/packages/db/drizzle/0014_interaction_outbox_channel_scope.sql new file mode 100644 index 00000000..af44cf57 --- /dev/null +++ b/packages/db/drizzle/0014_interaction_outbox_channel_scope.sql @@ -0,0 +1,5 @@ +ALTER TABLE "interaction_outbox" ADD COLUMN "channel_id" text; +--> statement-breakpoint +UPDATE "interaction_outbox" SET "channel_id" = 'legacy:unknown' WHERE "channel_id" IS NULL; +--> statement-breakpoint +ALTER TABLE "interaction_outbox" ALTER COLUMN "channel_id" SET NOT NULL; diff --git a/packages/db/drizzle/0015_interaction_checkpoint_payload_digest.sql b/packages/db/drizzle/0015_interaction_checkpoint_payload_digest.sql new file mode 100644 index 00000000..f291de16 --- /dev/null +++ b/packages/db/drizzle/0015_interaction_checkpoint_payload_digest.sql @@ -0,0 +1,3 @@ +ALTER TABLE "interaction_checkpoints" ADD COLUMN "content_digest" text;--> statement-breakpoint +UPDATE "interaction_checkpoints" SET "content_digest" = 'legacy' WHERE "content_digest" IS NULL;--> statement-breakpoint +ALTER TABLE "interaction_checkpoints" ALTER COLUMN "content_digest" SET NOT NULL; diff --git a/packages/db/drizzle/0016_salty_morlocks.sql b/packages/db/drizzle/0016_salty_morlocks.sql new file mode 100644 index 00000000..fe8cecd6 --- /dev/null +++ b/packages/db/drizzle/0016_salty_morlocks.sql @@ -0,0 +1,36 @@ +CREATE TABLE "connector_lease_audit_log" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" text NOT NULL, + "logical_agent_id" text NOT NULL, + "binding_id" text NOT NULL, + "connector_id" text NOT NULL, + "lease_id" uuid, + "lease_epoch" bigint, + "event" text NOT NULL, + "outcome" text NOT NULL, + "reason" text, + "correlation_id" text NOT NULL, + "occurred_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "logical_agent_connector_leases" ( + "lease_id" uuid PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "logical_agent_id" text NOT NULL, + "binding_id" text NOT NULL, + "connector_id" text NOT NULL, + "scopes" jsonb NOT NULL, + "lease_epoch" bigint NOT NULL, + "acquired_at" timestamp with time zone NOT NULL, + "heartbeat_at" timestamp with time zone NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "released_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "connector_lease_audit_binding_occurred_idx" ON "connector_lease_audit_log" USING btree ("tenant_id","logical_agent_id","binding_id","occurred_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "connector_lease_audit_correlation_idx" ON "connector_lease_audit_log" USING btree ("correlation_id");--> statement-breakpoint +CREATE UNIQUE INDEX "logical_agent_connector_lease_binding_idx" ON "logical_agent_connector_leases" USING btree ("tenant_id","logical_agent_id","binding_id");--> statement-breakpoint +CREATE INDEX "logical_agent_connector_lease_expiry_idx" ON "logical_agent_connector_leases" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "logical_agent_connector_lease_connector_idx" ON "logical_agent_connector_leases" USING btree ("connector_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0012_snapshot.json b/packages/db/drizzle/meta/0012_snapshot.json new file mode 100644 index 00000000..721391c4 --- /dev/null +++ b/packages/db/drizzle/meta/0012_snapshot.json @@ -0,0 +1,4172 @@ +{ + "id": "5d2dbfe9-f5d2-4342-a613-8c73943a6122", + "prevId": "0aa37ae4-5a0b-464b-ba70-121c5d9bbd23", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_tokens": { + "name": "admin_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_tokens_user_id_idx": { + "name": "admin_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_tokens_hash_idx": { + "name": "admin_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_tokens_user_id_users_id_fk": { + "name": "admin_tokens_user_id_users_id_fk", + "tableFrom": "admin_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_logs": { + "name": "agent_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hot'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "summarized_at": { + "name": "summarized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_logs_session_tier_idx": { + "name": "agent_logs_session_tier_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_user_id_idx": { + "name": "agent_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_tier_created_at_idx": { + "name": "agent_logs_tier_created_at_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_logs_user_id_users_id_fk": { + "name": "agent_logs_user_id_users_id_fk", + "tableFrom": "agent_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_project_id_idx": { + "name": "agents_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_is_system_idx": { + "name": "agents_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_project_id_projects_id_fk": { + "name": "agents_project_id_projects_id_fk", + "tableFrom": "agents", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appreciations": { + "name": "appreciations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_user": { + "name": "to_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backlog": { + "name": "backlog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "backlog_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "depends_on": { + "name": "depends_on", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_ttl_seconds": { + "name": "claim_ttl_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acceptance": { + "name": "acceptance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "backlog_status_priority_idx": { + "name": "backlog_status_priority_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_status_claimed_at_idx": { + "name": "backlog_status_claimed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_idempotency_key_idx": { + "name": "backlog_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_user_archived_idx": { + "name": "conversations_user_archived_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_agent_id_idx": { + "name": "conversations_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_agent_id_agents_id_fk": { + "name": "conversations_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_type_idx": { + "name": "events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_date_idx": { + "name": "events_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_audit_log": { + "name": "federation_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verb": { + "name": "verb", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "denied_reason": { + "name": "denied_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "query_hash": { + "name": "query_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_audit_log_peer_created_at_idx": { + "name": "federation_audit_log_peer_created_at_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_subject_created_at_idx": { + "name": "federation_audit_log_subject_created_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_created_at_idx": { + "name": "federation_audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_audit_log_peer_id_federation_peers_id_fk": { + "name": "federation_audit_log_peer_id_federation_peers_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_subject_user_id_users_id_fk": { + "name": "federation_audit_log_subject_user_id_users_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_grant_id_federation_grants_id_fk": { + "name": "federation_audit_log_grant_id_federation_grants_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_enrollment_tokens": { + "name": "federation_enrollment_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_enrollment_tokens_grant_id_federation_grants_id_fk": { + "name": "federation_enrollment_tokens_grant_id_federation_grants_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_enrollment_tokens_peer_id_federation_peers_id_fk": { + "name": "federation_enrollment_tokens_peer_id_federation_peers_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_grants": { + "name": "federation_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_grants_subject_status_idx": { + "name": "federation_grants_subject_status_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_grants_peer_status_idx": { + "name": "federation_grants_peer_status_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_grants_subject_user_id_users_id_fk": { + "name": "federation_grants_subject_user_id_users_id_fk", + "tableFrom": "federation_grants", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_grants_peer_id_federation_peers_id_fk": { + "name": "federation_grants_peer_id_federation_peers_id_fk", + "tableFrom": "federation_grants", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_peers": { + "name": "federation_peers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "common_name": { + "name": "common_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_pem": { + "name": "cert_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_serial": { + "name": "cert_serial", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_not_after": { + "name": "cert_not_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "client_key_pem": { + "name": "client_key_pem", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "peer_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "endpoint_url": { + "name": "endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_peers_cert_serial_idx": { + "name": "federation_peers_cert_serial_idx", + "columns": [ + { + "expression": "cert_serial", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_peers_state_idx": { + "name": "federation_peers_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_peers_common_name_unique": { + "name": "federation_peers_common_name_unique", + "nullsNotDistinct": false, + "columns": [ + "common_name" + ] + }, + "federation_peers_cert_serial_unique": { + "name": "federation_peers_cert_serial_unique", + "nullsNotDistinct": false, + "columns": [ + "cert_serial" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insights": { + "name": "insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "relevance_score": { + "name": "relevance_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decayed_at": { + "name": "decayed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "insights_user_id_idx": { + "name": "insights_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_category_idx": { + "name": "insights_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_relevance_idx": { + "name": "insights_relevance_idx", + "columns": [ + { + "expression": "relevance_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insights_user_id_users_id_fk": { + "name": "insights_user_id_users_id_fk", + "tableFrom": "insights", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mission_tasks": { + "name": "mission_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr": { + "name": "pr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mission_tasks_mission_id_idx": { + "name": "mission_tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_task_id_idx": { + "name": "mission_tasks_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_user_id_idx": { + "name": "mission_tasks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_status_idx": { + "name": "mission_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mission_tasks_mission_id_missions_id_fk": { + "name": "mission_tasks_mission_id_missions_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mission_tasks_task_id_tasks_id_fk": { + "name": "mission_tasks_task_id_tasks_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mission_tasks_user_id_users_id_fk": { + "name": "mission_tasks_user_id_users_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.missions": { + "name": "missions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "missions_project_id_idx": { + "name": "missions_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "missions_user_id_idx": { + "name": "missions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "missions_project_id_projects_id_fk": { + "name": "missions_project_id_projects_id_fk", + "tableFrom": "missions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "missions_user_id_users_id_fk": { + "name": "missions_user_id_users_id_fk", + "tableFrom": "missions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mutable": { + "name": "mutable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "preferences_user_id_idx": { + "name": "preferences_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "preferences_user_key_idx": { + "name": "preferences_user_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_credentials": { + "name": "provider_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_credentials_user_provider_idx": { + "name": "provider_credentials_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_credentials_user_id_idx": { + "name": "provider_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_credentials_user_id_users_id_fk": { + "name": "provider_credentials_user_id_users_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routing_rules": { + "name": "routing_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routing_rules_scope_priority_idx": { + "name": "routing_rules_scope_priority_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_user_id_idx": { + "name": "routing_rules_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_enabled_idx": { + "name": "routing_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routing_rules_user_id_users_id_fk": { + "name": "routing_rules_user_id_users_id_fk", + "tableFrom": "routing_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_enabled_idx": { + "name": "skills_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_installed_by_users_id_fk": { + "name": "skills_installed_by_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "installed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.summarization_jobs": { + "name": "summarization_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "logs_processed": { + "name": "logs_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "insights_created": { + "name": "insights_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "summarization_jobs_status_idx": { + "name": "summarization_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_mission_id_idx": { + "name": "tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_mission_id_missions_id_fk": { + "name": "tasks_mission_id_missions_id_fk", + "tableFrom": "tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_user_idx": { + "name": "team_members_team_user_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_invited_by_users_id_fk": { + "name": "team_members_invited_by_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "teams_manager_id_users_id_fk": { + "name": "teams_manager_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "manager_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_checkpoints": { + "name": "interaction_checkpoints", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compaction_epoch": { + "name": "compaction_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "interaction_checkpoints_session_id_interaction_sessions_id_fk": { + "name": "interaction_checkpoints_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_checkpoints", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "interaction_checkpoints_checkpoint_id_unique": { + "name": "interaction_checkpoints_checkpoint_id_unique", + "nullsNotDistinct": false, + "columns": [ + "checkpoint_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_handoffs": { + "name": "interaction_handoffs", + "schema": "", + "columns": { + "handoff_id": { + "name": "handoff_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_handoff_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_handoffs_session_status_idx": { + "name": "interaction_handoffs_session_status_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_handoffs_session_id_interaction_sessions_id_fk": { + "name": "interaction_handoffs_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_handoffs", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_inbox": { + "name": "interaction_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_inbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_inbox_session_idempotency_idx": { + "name": "interaction_inbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_inbox_session_status_created_idx": { + "name": "interaction_inbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_inbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_inbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_inbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_outbox": { + "name": "interaction_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_outbox_session_idempotency_idx": { + "name": "interaction_outbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_outbox_session_status_created_idx": { + "name": "interaction_outbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_outbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_outbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_outbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_sessions": { + "name": "interaction_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_session_id": { + "name": "runtime_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "interaction_sessions_owner_id_users_id_fk": { + "name": "interaction_sessions_owner_id_users_id_fk", + "tableFrom": "interaction_sessions", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.backlog_status": { + "name": "backlog_status", + "schema": "public", + "values": [ + "ready", + "claimed", + "blocked", + "done" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "pending", + "active", + "revoked", + "expired" + ] + }, + "public.peer_state": { + "name": "peer_state", + "schema": "public", + "values": [ + "pending", + "active", + "suspended", + "revoked" + ] + }, + "public.interaction_handoff_status": { + "name": "interaction_handoff_status", + "schema": "public", + "values": [ + "pending", + "accepted" + ] + }, + "public.interaction_inbox_status": { + "name": "interaction_inbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed" + ] + }, + "public.interaction_outbox_status": { + "name": "interaction_outbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "delivered" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0013_snapshot.json b/packages/db/drizzle/meta/0013_snapshot.json new file mode 100644 index 00000000..6c85c862 --- /dev/null +++ b/packages/db/drizzle/meta/0013_snapshot.json @@ -0,0 +1,4214 @@ +{ + "id": "0cd8e1a3-a7f6-4c39-ac19-7cc0f9c02164", + "prevId": "5d2dbfe9-f5d2-4342-a613-8c73943a6122", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_tokens": { + "name": "admin_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_tokens_user_id_idx": { + "name": "admin_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_tokens_hash_idx": { + "name": "admin_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_tokens_user_id_users_id_fk": { + "name": "admin_tokens_user_id_users_id_fk", + "tableFrom": "admin_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_logs": { + "name": "agent_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hot'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "summarized_at": { + "name": "summarized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_logs_session_tier_idx": { + "name": "agent_logs_session_tier_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_user_id_idx": { + "name": "agent_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_tier_created_at_idx": { + "name": "agent_logs_tier_created_at_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_logs_user_id_users_id_fk": { + "name": "agent_logs_user_id_users_id_fk", + "tableFrom": "agent_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_project_id_idx": { + "name": "agents_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_is_system_idx": { + "name": "agents_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_project_id_projects_id_fk": { + "name": "agents_project_id_projects_id_fk", + "tableFrom": "agents", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appreciations": { + "name": "appreciations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_user": { + "name": "to_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backlog": { + "name": "backlog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "backlog_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "depends_on": { + "name": "depends_on", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_ttl_seconds": { + "name": "claim_ttl_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acceptance": { + "name": "acceptance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "backlog_status_priority_idx": { + "name": "backlog_status_priority_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_status_claimed_at_idx": { + "name": "backlog_status_claimed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_idempotency_key_idx": { + "name": "backlog_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_user_archived_idx": { + "name": "conversations_user_archived_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_agent_id_idx": { + "name": "conversations_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_agent_id_agents_id_fk": { + "name": "conversations_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_type_idx": { + "name": "events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_date_idx": { + "name": "events_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_audit_log": { + "name": "federation_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verb": { + "name": "verb", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "denied_reason": { + "name": "denied_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "query_hash": { + "name": "query_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_audit_log_peer_created_at_idx": { + "name": "federation_audit_log_peer_created_at_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_subject_created_at_idx": { + "name": "federation_audit_log_subject_created_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_created_at_idx": { + "name": "federation_audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_audit_log_peer_id_federation_peers_id_fk": { + "name": "federation_audit_log_peer_id_federation_peers_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_subject_user_id_users_id_fk": { + "name": "federation_audit_log_subject_user_id_users_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_grant_id_federation_grants_id_fk": { + "name": "federation_audit_log_grant_id_federation_grants_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_enrollment_tokens": { + "name": "federation_enrollment_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_enrollment_tokens_grant_id_federation_grants_id_fk": { + "name": "federation_enrollment_tokens_grant_id_federation_grants_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_enrollment_tokens_peer_id_federation_peers_id_fk": { + "name": "federation_enrollment_tokens_peer_id_federation_peers_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_grants": { + "name": "federation_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_grants_subject_status_idx": { + "name": "federation_grants_subject_status_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_grants_peer_status_idx": { + "name": "federation_grants_peer_status_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_grants_subject_user_id_users_id_fk": { + "name": "federation_grants_subject_user_id_users_id_fk", + "tableFrom": "federation_grants", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_grants_peer_id_federation_peers_id_fk": { + "name": "federation_grants_peer_id_federation_peers_id_fk", + "tableFrom": "federation_grants", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_peers": { + "name": "federation_peers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "common_name": { + "name": "common_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_pem": { + "name": "cert_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_serial": { + "name": "cert_serial", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_not_after": { + "name": "cert_not_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "client_key_pem": { + "name": "client_key_pem", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "peer_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "endpoint_url": { + "name": "endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_peers_cert_serial_idx": { + "name": "federation_peers_cert_serial_idx", + "columns": [ + { + "expression": "cert_serial", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_peers_state_idx": { + "name": "federation_peers_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_peers_common_name_unique": { + "name": "federation_peers_common_name_unique", + "nullsNotDistinct": false, + "columns": [ + "common_name" + ] + }, + "federation_peers_cert_serial_unique": { + "name": "federation_peers_cert_serial_unique", + "nullsNotDistinct": false, + "columns": [ + "cert_serial" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insights": { + "name": "insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "relevance_score": { + "name": "relevance_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decayed_at": { + "name": "decayed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "insights_user_id_idx": { + "name": "insights_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_category_idx": { + "name": "insights_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_relevance_idx": { + "name": "insights_relevance_idx", + "columns": [ + { + "expression": "relevance_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insights_user_id_users_id_fk": { + "name": "insights_user_id_users_id_fk", + "tableFrom": "insights", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mission_tasks": { + "name": "mission_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr": { + "name": "pr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mission_tasks_mission_id_idx": { + "name": "mission_tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_task_id_idx": { + "name": "mission_tasks_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_user_id_idx": { + "name": "mission_tasks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_status_idx": { + "name": "mission_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mission_tasks_mission_id_missions_id_fk": { + "name": "mission_tasks_mission_id_missions_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mission_tasks_task_id_tasks_id_fk": { + "name": "mission_tasks_task_id_tasks_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mission_tasks_user_id_users_id_fk": { + "name": "mission_tasks_user_id_users_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.missions": { + "name": "missions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "missions_project_id_idx": { + "name": "missions_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "missions_user_id_idx": { + "name": "missions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "missions_project_id_projects_id_fk": { + "name": "missions_project_id_projects_id_fk", + "tableFrom": "missions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "missions_user_id_users_id_fk": { + "name": "missions_user_id_users_id_fk", + "tableFrom": "missions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mutable": { + "name": "mutable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "preferences_user_id_idx": { + "name": "preferences_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "preferences_user_key_idx": { + "name": "preferences_user_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_credentials": { + "name": "provider_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_credentials_user_provider_idx": { + "name": "provider_credentials_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_credentials_user_id_idx": { + "name": "provider_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_credentials_user_id_users_id_fk": { + "name": "provider_credentials_user_id_users_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routing_rules": { + "name": "routing_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routing_rules_scope_priority_idx": { + "name": "routing_rules_scope_priority_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_user_id_idx": { + "name": "routing_rules_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_enabled_idx": { + "name": "routing_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routing_rules_user_id_users_id_fk": { + "name": "routing_rules_user_id_users_id_fk", + "tableFrom": "routing_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_enabled_idx": { + "name": "skills_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_installed_by_users_id_fk": { + "name": "skills_installed_by_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "installed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.summarization_jobs": { + "name": "summarization_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "logs_processed": { + "name": "logs_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "insights_created": { + "name": "insights_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "summarization_jobs_status_idx": { + "name": "summarization_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_mission_id_idx": { + "name": "tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_mission_id_missions_id_fk": { + "name": "tasks_mission_id_missions_id_fk", + "tableFrom": "tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_user_idx": { + "name": "team_members_team_user_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_invited_by_users_id_fk": { + "name": "team_members_invited_by_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "teams_manager_id_users_id_fk": { + "name": "teams_manager_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "manager_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_checkpoints": { + "name": "interaction_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compaction_epoch": { + "name": "compaction_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_checkpoints_session_idempotency_idx": { + "name": "interaction_checkpoints_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_checkpoints_session_epoch_idx": { + "name": "interaction_checkpoints_session_epoch_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compaction_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_checkpoints_session_id_interaction_sessions_id_fk": { + "name": "interaction_checkpoints_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_checkpoints", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_handoffs": { + "name": "interaction_handoffs", + "schema": "", + "columns": { + "handoff_id": { + "name": "handoff_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_handoff_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_handoffs_session_status_idx": { + "name": "interaction_handoffs_session_status_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_handoffs_session_id_interaction_sessions_id_fk": { + "name": "interaction_handoffs_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_handoffs", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_inbox": { + "name": "interaction_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_inbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_inbox_session_idempotency_idx": { + "name": "interaction_inbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_inbox_session_status_created_idx": { + "name": "interaction_inbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_inbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_inbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_inbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_outbox": { + "name": "interaction_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_outbox_session_idempotency_idx": { + "name": "interaction_outbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_outbox_session_status_created_idx": { + "name": "interaction_outbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_outbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_outbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_outbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_sessions": { + "name": "interaction_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_session_id": { + "name": "runtime_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "interaction_sessions_owner_id_users_id_fk": { + "name": "interaction_sessions_owner_id_users_id_fk", + "tableFrom": "interaction_sessions", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.backlog_status": { + "name": "backlog_status", + "schema": "public", + "values": [ + "ready", + "claimed", + "blocked", + "done" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "pending", + "active", + "revoked", + "expired" + ] + }, + "public.peer_state": { + "name": "peer_state", + "schema": "public", + "values": [ + "pending", + "active", + "suspended", + "revoked" + ] + }, + "public.interaction_handoff_status": { + "name": "interaction_handoff_status", + "schema": "public", + "values": [ + "pending", + "accepted" + ] + }, + "public.interaction_inbox_status": { + "name": "interaction_inbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed" + ] + }, + "public.interaction_outbox_status": { + "name": "interaction_outbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "delivered" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0014_snapshot.json b/packages/db/drizzle/meta/0014_snapshot.json new file mode 100644 index 00000000..b60ca23c --- /dev/null +++ b/packages/db/drizzle/meta/0014_snapshot.json @@ -0,0 +1,4220 @@ +{ + "id": "48124a40-a224-4d1c-ab8f-b3dab554b1ac", + "prevId": "0cd8e1a3-a7f6-4c39-ac19-7cc0f9c02164", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_tokens": { + "name": "admin_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_tokens_user_id_idx": { + "name": "admin_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_tokens_hash_idx": { + "name": "admin_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_tokens_user_id_users_id_fk": { + "name": "admin_tokens_user_id_users_id_fk", + "tableFrom": "admin_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_logs": { + "name": "agent_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hot'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "summarized_at": { + "name": "summarized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_logs_session_tier_idx": { + "name": "agent_logs_session_tier_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_user_id_idx": { + "name": "agent_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_tier_created_at_idx": { + "name": "agent_logs_tier_created_at_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_logs_user_id_users_id_fk": { + "name": "agent_logs_user_id_users_id_fk", + "tableFrom": "agent_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_project_id_idx": { + "name": "agents_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_is_system_idx": { + "name": "agents_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_project_id_projects_id_fk": { + "name": "agents_project_id_projects_id_fk", + "tableFrom": "agents", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appreciations": { + "name": "appreciations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_user": { + "name": "to_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backlog": { + "name": "backlog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "backlog_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "depends_on": { + "name": "depends_on", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_ttl_seconds": { + "name": "claim_ttl_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acceptance": { + "name": "acceptance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "backlog_status_priority_idx": { + "name": "backlog_status_priority_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_status_claimed_at_idx": { + "name": "backlog_status_claimed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_idempotency_key_idx": { + "name": "backlog_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_user_archived_idx": { + "name": "conversations_user_archived_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_agent_id_idx": { + "name": "conversations_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_agent_id_agents_id_fk": { + "name": "conversations_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_type_idx": { + "name": "events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_date_idx": { + "name": "events_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_audit_log": { + "name": "federation_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verb": { + "name": "verb", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "denied_reason": { + "name": "denied_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "query_hash": { + "name": "query_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_audit_log_peer_created_at_idx": { + "name": "federation_audit_log_peer_created_at_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_subject_created_at_idx": { + "name": "federation_audit_log_subject_created_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_created_at_idx": { + "name": "federation_audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_audit_log_peer_id_federation_peers_id_fk": { + "name": "federation_audit_log_peer_id_federation_peers_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_subject_user_id_users_id_fk": { + "name": "federation_audit_log_subject_user_id_users_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_grant_id_federation_grants_id_fk": { + "name": "federation_audit_log_grant_id_federation_grants_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_enrollment_tokens": { + "name": "federation_enrollment_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_enrollment_tokens_grant_id_federation_grants_id_fk": { + "name": "federation_enrollment_tokens_grant_id_federation_grants_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_enrollment_tokens_peer_id_federation_peers_id_fk": { + "name": "federation_enrollment_tokens_peer_id_federation_peers_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_grants": { + "name": "federation_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_grants_subject_status_idx": { + "name": "federation_grants_subject_status_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_grants_peer_status_idx": { + "name": "federation_grants_peer_status_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_grants_subject_user_id_users_id_fk": { + "name": "federation_grants_subject_user_id_users_id_fk", + "tableFrom": "federation_grants", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_grants_peer_id_federation_peers_id_fk": { + "name": "federation_grants_peer_id_federation_peers_id_fk", + "tableFrom": "federation_grants", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_peers": { + "name": "federation_peers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "common_name": { + "name": "common_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_pem": { + "name": "cert_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_serial": { + "name": "cert_serial", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_not_after": { + "name": "cert_not_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "client_key_pem": { + "name": "client_key_pem", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "peer_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "endpoint_url": { + "name": "endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_peers_cert_serial_idx": { + "name": "federation_peers_cert_serial_idx", + "columns": [ + { + "expression": "cert_serial", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_peers_state_idx": { + "name": "federation_peers_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_peers_common_name_unique": { + "name": "federation_peers_common_name_unique", + "nullsNotDistinct": false, + "columns": [ + "common_name" + ] + }, + "federation_peers_cert_serial_unique": { + "name": "federation_peers_cert_serial_unique", + "nullsNotDistinct": false, + "columns": [ + "cert_serial" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insights": { + "name": "insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "relevance_score": { + "name": "relevance_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decayed_at": { + "name": "decayed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "insights_user_id_idx": { + "name": "insights_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_category_idx": { + "name": "insights_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_relevance_idx": { + "name": "insights_relevance_idx", + "columns": [ + { + "expression": "relevance_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insights_user_id_users_id_fk": { + "name": "insights_user_id_users_id_fk", + "tableFrom": "insights", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mission_tasks": { + "name": "mission_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr": { + "name": "pr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mission_tasks_mission_id_idx": { + "name": "mission_tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_task_id_idx": { + "name": "mission_tasks_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_user_id_idx": { + "name": "mission_tasks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_status_idx": { + "name": "mission_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mission_tasks_mission_id_missions_id_fk": { + "name": "mission_tasks_mission_id_missions_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mission_tasks_task_id_tasks_id_fk": { + "name": "mission_tasks_task_id_tasks_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mission_tasks_user_id_users_id_fk": { + "name": "mission_tasks_user_id_users_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.missions": { + "name": "missions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "missions_project_id_idx": { + "name": "missions_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "missions_user_id_idx": { + "name": "missions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "missions_project_id_projects_id_fk": { + "name": "missions_project_id_projects_id_fk", + "tableFrom": "missions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "missions_user_id_users_id_fk": { + "name": "missions_user_id_users_id_fk", + "tableFrom": "missions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mutable": { + "name": "mutable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "preferences_user_id_idx": { + "name": "preferences_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "preferences_user_key_idx": { + "name": "preferences_user_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_credentials": { + "name": "provider_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_credentials_user_provider_idx": { + "name": "provider_credentials_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_credentials_user_id_idx": { + "name": "provider_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_credentials_user_id_users_id_fk": { + "name": "provider_credentials_user_id_users_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routing_rules": { + "name": "routing_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routing_rules_scope_priority_idx": { + "name": "routing_rules_scope_priority_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_user_id_idx": { + "name": "routing_rules_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_enabled_idx": { + "name": "routing_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routing_rules_user_id_users_id_fk": { + "name": "routing_rules_user_id_users_id_fk", + "tableFrom": "routing_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_enabled_idx": { + "name": "skills_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_installed_by_users_id_fk": { + "name": "skills_installed_by_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "installed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.summarization_jobs": { + "name": "summarization_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "logs_processed": { + "name": "logs_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "insights_created": { + "name": "insights_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "summarization_jobs_status_idx": { + "name": "summarization_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_mission_id_idx": { + "name": "tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_mission_id_missions_id_fk": { + "name": "tasks_mission_id_missions_id_fk", + "tableFrom": "tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_user_idx": { + "name": "team_members_team_user_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_invited_by_users_id_fk": { + "name": "team_members_invited_by_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "teams_manager_id_users_id_fk": { + "name": "teams_manager_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "manager_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_checkpoints": { + "name": "interaction_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compaction_epoch": { + "name": "compaction_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_checkpoints_session_idempotency_idx": { + "name": "interaction_checkpoints_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_checkpoints_session_epoch_idx": { + "name": "interaction_checkpoints_session_epoch_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compaction_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_checkpoints_session_id_interaction_sessions_id_fk": { + "name": "interaction_checkpoints_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_checkpoints", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_handoffs": { + "name": "interaction_handoffs", + "schema": "", + "columns": { + "handoff_id": { + "name": "handoff_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_handoff_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_handoffs_session_status_idx": { + "name": "interaction_handoffs_session_status_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_handoffs_session_id_interaction_sessions_id_fk": { + "name": "interaction_handoffs_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_handoffs", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_inbox": { + "name": "interaction_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_inbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_inbox_session_idempotency_idx": { + "name": "interaction_inbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_inbox_session_status_created_idx": { + "name": "interaction_inbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_inbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_inbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_inbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_outbox": { + "name": "interaction_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_outbox_session_idempotency_idx": { + "name": "interaction_outbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_outbox_session_status_created_idx": { + "name": "interaction_outbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_outbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_outbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_outbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_sessions": { + "name": "interaction_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_session_id": { + "name": "runtime_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "interaction_sessions_owner_id_users_id_fk": { + "name": "interaction_sessions_owner_id_users_id_fk", + "tableFrom": "interaction_sessions", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.backlog_status": { + "name": "backlog_status", + "schema": "public", + "values": [ + "ready", + "claimed", + "blocked", + "done" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "pending", + "active", + "revoked", + "expired" + ] + }, + "public.peer_state": { + "name": "peer_state", + "schema": "public", + "values": [ + "pending", + "active", + "suspended", + "revoked" + ] + }, + "public.interaction_handoff_status": { + "name": "interaction_handoff_status", + "schema": "public", + "values": [ + "pending", + "accepted" + ] + }, + "public.interaction_inbox_status": { + "name": "interaction_inbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed" + ] + }, + "public.interaction_outbox_status": { + "name": "interaction_outbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "delivered" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0015_snapshot.json b/packages/db/drizzle/meta/0015_snapshot.json new file mode 100644 index 00000000..ccb49fb7 --- /dev/null +++ b/packages/db/drizzle/meta/0015_snapshot.json @@ -0,0 +1,4244 @@ +{ + "id": "1a0a53b1-2dd8-4ea9-8d59-3e92ccca6c6a", + "prevId": "48124a40-a224-4d1c-ab8f-b3dab554b1ac", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_tokens": { + "name": "admin_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_tokens_user_id_idx": { + "name": "admin_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_tokens_hash_idx": { + "name": "admin_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_tokens_user_id_users_id_fk": { + "name": "admin_tokens_user_id_users_id_fk", + "tableFrom": "admin_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_logs": { + "name": "agent_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hot'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "summarized_at": { + "name": "summarized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_logs_session_tier_idx": { + "name": "agent_logs_session_tier_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_user_id_idx": { + "name": "agent_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_tier_created_at_idx": { + "name": "agent_logs_tier_created_at_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_logs_user_id_users_id_fk": { + "name": "agent_logs_user_id_users_id_fk", + "tableFrom": "agent_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_project_id_idx": { + "name": "agents_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_is_system_idx": { + "name": "agents_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_project_id_projects_id_fk": { + "name": "agents_project_id_projects_id_fk", + "tableFrom": "agents", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appreciations": { + "name": "appreciations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_user": { + "name": "to_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backlog": { + "name": "backlog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "backlog_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "depends_on": { + "name": "depends_on", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_ttl_seconds": { + "name": "claim_ttl_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acceptance": { + "name": "acceptance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "backlog_status_priority_idx": { + "name": "backlog_status_priority_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_status_claimed_at_idx": { + "name": "backlog_status_claimed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_idempotency_key_idx": { + "name": "backlog_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_user_archived_idx": { + "name": "conversations_user_archived_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_agent_id_idx": { + "name": "conversations_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_agent_id_agents_id_fk": { + "name": "conversations_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_type_idx": { + "name": "events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_date_idx": { + "name": "events_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_audit_log": { + "name": "federation_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verb": { + "name": "verb", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "denied_reason": { + "name": "denied_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "query_hash": { + "name": "query_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_audit_log_peer_created_at_idx": { + "name": "federation_audit_log_peer_created_at_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_subject_created_at_idx": { + "name": "federation_audit_log_subject_created_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_created_at_idx": { + "name": "federation_audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_audit_log_peer_id_federation_peers_id_fk": { + "name": "federation_audit_log_peer_id_federation_peers_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_subject_user_id_users_id_fk": { + "name": "federation_audit_log_subject_user_id_users_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_grant_id_federation_grants_id_fk": { + "name": "federation_audit_log_grant_id_federation_grants_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_enrollment_tokens": { + "name": "federation_enrollment_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_enrollment_tokens_grant_id_federation_grants_id_fk": { + "name": "federation_enrollment_tokens_grant_id_federation_grants_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_enrollment_tokens_peer_id_federation_peers_id_fk": { + "name": "federation_enrollment_tokens_peer_id_federation_peers_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_grants": { + "name": "federation_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_grants_subject_status_idx": { + "name": "federation_grants_subject_status_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_grants_peer_status_idx": { + "name": "federation_grants_peer_status_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_grants_subject_user_id_users_id_fk": { + "name": "federation_grants_subject_user_id_users_id_fk", + "tableFrom": "federation_grants", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_grants_peer_id_federation_peers_id_fk": { + "name": "federation_grants_peer_id_federation_peers_id_fk", + "tableFrom": "federation_grants", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_peers": { + "name": "federation_peers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "common_name": { + "name": "common_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_pem": { + "name": "cert_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_serial": { + "name": "cert_serial", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_not_after": { + "name": "cert_not_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "client_key_pem": { + "name": "client_key_pem", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "peer_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "endpoint_url": { + "name": "endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_peers_cert_serial_idx": { + "name": "federation_peers_cert_serial_idx", + "columns": [ + { + "expression": "cert_serial", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_peers_state_idx": { + "name": "federation_peers_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_peers_common_name_unique": { + "name": "federation_peers_common_name_unique", + "nullsNotDistinct": false, + "columns": [ + "common_name" + ] + }, + "federation_peers_cert_serial_unique": { + "name": "federation_peers_cert_serial_unique", + "nullsNotDistinct": false, + "columns": [ + "cert_serial" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insights": { + "name": "insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "relevance_score": { + "name": "relevance_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decayed_at": { + "name": "decayed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "insights_user_id_idx": { + "name": "insights_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_category_idx": { + "name": "insights_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_relevance_idx": { + "name": "insights_relevance_idx", + "columns": [ + { + "expression": "relevance_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insights_user_id_users_id_fk": { + "name": "insights_user_id_users_id_fk", + "tableFrom": "insights", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_checkpoints": { + "name": "interaction_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compaction_epoch": { + "name": "compaction_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_checkpoints_session_idempotency_idx": { + "name": "interaction_checkpoints_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_checkpoints_session_epoch_idx": { + "name": "interaction_checkpoints_session_epoch_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compaction_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_checkpoints_session_id_interaction_sessions_id_fk": { + "name": "interaction_checkpoints_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_checkpoints", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_handoffs": { + "name": "interaction_handoffs", + "schema": "", + "columns": { + "handoff_id": { + "name": "handoff_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_handoff_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_handoffs_session_status_idx": { + "name": "interaction_handoffs_session_status_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_handoffs_session_id_interaction_sessions_id_fk": { + "name": "interaction_handoffs_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_handoffs", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_inbox": { + "name": "interaction_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_inbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_inbox_session_idempotency_idx": { + "name": "interaction_inbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_inbox_session_status_created_idx": { + "name": "interaction_inbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_inbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_inbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_inbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_outbox": { + "name": "interaction_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_outbox_session_idempotency_idx": { + "name": "interaction_outbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_outbox_session_status_created_idx": { + "name": "interaction_outbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_outbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_outbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_outbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_sessions": { + "name": "interaction_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_session_id": { + "name": "runtime_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "interaction_sessions_owner_id_users_id_fk": { + "name": "interaction_sessions_owner_id_users_id_fk", + "tableFrom": "interaction_sessions", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mission_tasks": { + "name": "mission_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr": { + "name": "pr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mission_tasks_mission_id_idx": { + "name": "mission_tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_task_id_idx": { + "name": "mission_tasks_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_user_id_idx": { + "name": "mission_tasks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_status_idx": { + "name": "mission_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mission_tasks_mission_id_missions_id_fk": { + "name": "mission_tasks_mission_id_missions_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mission_tasks_task_id_tasks_id_fk": { + "name": "mission_tasks_task_id_tasks_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mission_tasks_user_id_users_id_fk": { + "name": "mission_tasks_user_id_users_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.missions": { + "name": "missions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "missions_project_id_idx": { + "name": "missions_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "missions_user_id_idx": { + "name": "missions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "missions_project_id_projects_id_fk": { + "name": "missions_project_id_projects_id_fk", + "tableFrom": "missions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "missions_user_id_users_id_fk": { + "name": "missions_user_id_users_id_fk", + "tableFrom": "missions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mutable": { + "name": "mutable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "preferences_user_id_idx": { + "name": "preferences_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "preferences_user_key_idx": { + "name": "preferences_user_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_credentials": { + "name": "provider_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_credentials_user_provider_idx": { + "name": "provider_credentials_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_credentials_user_id_idx": { + "name": "provider_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_credentials_user_id_users_id_fk": { + "name": "provider_credentials_user_id_users_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routing_rules": { + "name": "routing_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routing_rules_scope_priority_idx": { + "name": "routing_rules_scope_priority_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_user_id_idx": { + "name": "routing_rules_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_enabled_idx": { + "name": "routing_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routing_rules_user_id_users_id_fk": { + "name": "routing_rules_user_id_users_id_fk", + "tableFrom": "routing_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_enabled_idx": { + "name": "skills_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_installed_by_users_id_fk": { + "name": "skills_installed_by_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "installed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.summarization_jobs": { + "name": "summarization_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "logs_processed": { + "name": "logs_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "insights_created": { + "name": "insights_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "summarization_jobs_status_idx": { + "name": "summarization_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_mission_id_idx": { + "name": "tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_mission_id_missions_id_fk": { + "name": "tasks_mission_id_missions_id_fk", + "tableFrom": "tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_user_idx": { + "name": "team_members_team_user_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_invited_by_users_id_fk": { + "name": "team_members_invited_by_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "teams_manager_id_users_id_fk": { + "name": "teams_manager_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "manager_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.backlog_status": { + "name": "backlog_status", + "schema": "public", + "values": [ + "ready", + "claimed", + "blocked", + "done" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "pending", + "active", + "revoked", + "expired" + ] + }, + "public.interaction_handoff_status": { + "name": "interaction_handoff_status", + "schema": "public", + "values": [ + "pending", + "accepted" + ] + }, + "public.interaction_inbox_status": { + "name": "interaction_inbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed" + ] + }, + "public.interaction_outbox_status": { + "name": "interaction_outbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "delivered" + ] + }, + "public.peer_state": { + "name": "peer_state", + "schema": "public", + "values": [ + "pending", + "active", + "suspended", + "revoked" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0016_snapshot.json b/packages/db/drizzle/meta/0016_snapshot.json new file mode 100644 index 00000000..064efdf0 --- /dev/null +++ b/packages/db/drizzle/meta/0016_snapshot.json @@ -0,0 +1,4530 @@ +{ + "id": "77193fb2-b6e8-4a59-b611-c37441b49e1b", + "prevId": "1a0a53b1-2dd8-4ea9-8d59-3e92ccca6c6a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_tokens": { + "name": "admin_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_tokens_user_id_idx": { + "name": "admin_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_tokens_hash_idx": { + "name": "admin_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_tokens_user_id_users_id_fk": { + "name": "admin_tokens_user_id_users_id_fk", + "tableFrom": "admin_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_logs": { + "name": "agent_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hot'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "summarized_at": { + "name": "summarized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_logs_session_tier_idx": { + "name": "agent_logs_session_tier_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_user_id_idx": { + "name": "agent_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_tier_created_at_idx": { + "name": "agent_logs_tier_created_at_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_logs_user_id_users_id_fk": { + "name": "agent_logs_user_id_users_id_fk", + "tableFrom": "agent_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_project_id_idx": { + "name": "agents_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_is_system_idx": { + "name": "agents_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_project_id_projects_id_fk": { + "name": "agents_project_id_projects_id_fk", + "tableFrom": "agents", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appreciations": { + "name": "appreciations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_user": { + "name": "to_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backlog": { + "name": "backlog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "backlog_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "depends_on": { + "name": "depends_on", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_ttl_seconds": { + "name": "claim_ttl_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acceptance": { + "name": "acceptance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "backlog_status_priority_idx": { + "name": "backlog_status_priority_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_status_claimed_at_idx": { + "name": "backlog_status_claimed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_idempotency_key_idx": { + "name": "backlog_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_lease_audit_log": { + "name": "connector_lease_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logical_agent_id": { + "name": "logical_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_id": { + "name": "lease_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_epoch": { + "name": "lease_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connector_lease_audit_binding_occurred_idx": { + "name": "connector_lease_audit_binding_occurred_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "logical_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connector_lease_audit_correlation_idx": { + "name": "connector_lease_audit_correlation_idx", + "columns": [ + { + "expression": "correlation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_user_archived_idx": { + "name": "conversations_user_archived_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_agent_id_idx": { + "name": "conversations_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_agent_id_agents_id_fk": { + "name": "conversations_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_type_idx": { + "name": "events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_date_idx": { + "name": "events_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_audit_log": { + "name": "federation_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verb": { + "name": "verb", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "denied_reason": { + "name": "denied_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "query_hash": { + "name": "query_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_audit_log_peer_created_at_idx": { + "name": "federation_audit_log_peer_created_at_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_subject_created_at_idx": { + "name": "federation_audit_log_subject_created_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_created_at_idx": { + "name": "federation_audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_audit_log_peer_id_federation_peers_id_fk": { + "name": "federation_audit_log_peer_id_federation_peers_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_subject_user_id_users_id_fk": { + "name": "federation_audit_log_subject_user_id_users_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_grant_id_federation_grants_id_fk": { + "name": "federation_audit_log_grant_id_federation_grants_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_enrollment_tokens": { + "name": "federation_enrollment_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_enrollment_tokens_grant_id_federation_grants_id_fk": { + "name": "federation_enrollment_tokens_grant_id_federation_grants_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_enrollment_tokens_peer_id_federation_peers_id_fk": { + "name": "federation_enrollment_tokens_peer_id_federation_peers_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_grants": { + "name": "federation_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_grants_subject_status_idx": { + "name": "federation_grants_subject_status_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_grants_peer_status_idx": { + "name": "federation_grants_peer_status_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_grants_subject_user_id_users_id_fk": { + "name": "federation_grants_subject_user_id_users_id_fk", + "tableFrom": "federation_grants", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_grants_peer_id_federation_peers_id_fk": { + "name": "federation_grants_peer_id_federation_peers_id_fk", + "tableFrom": "federation_grants", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_peers": { + "name": "federation_peers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "common_name": { + "name": "common_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_pem": { + "name": "cert_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_serial": { + "name": "cert_serial", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_not_after": { + "name": "cert_not_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "client_key_pem": { + "name": "client_key_pem", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "peer_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "endpoint_url": { + "name": "endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_peers_cert_serial_idx": { + "name": "federation_peers_cert_serial_idx", + "columns": [ + { + "expression": "cert_serial", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_peers_state_idx": { + "name": "federation_peers_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_peers_common_name_unique": { + "name": "federation_peers_common_name_unique", + "nullsNotDistinct": false, + "columns": [ + "common_name" + ] + }, + "federation_peers_cert_serial_unique": { + "name": "federation_peers_cert_serial_unique", + "nullsNotDistinct": false, + "columns": [ + "cert_serial" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insights": { + "name": "insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "relevance_score": { + "name": "relevance_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decayed_at": { + "name": "decayed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "insights_user_id_idx": { + "name": "insights_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_category_idx": { + "name": "insights_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_relevance_idx": { + "name": "insights_relevance_idx", + "columns": [ + { + "expression": "relevance_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insights_user_id_users_id_fk": { + "name": "insights_user_id_users_id_fk", + "tableFrom": "insights", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_checkpoints": { + "name": "interaction_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compaction_epoch": { + "name": "compaction_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_checkpoints_session_idempotency_idx": { + "name": "interaction_checkpoints_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_checkpoints_session_epoch_idx": { + "name": "interaction_checkpoints_session_epoch_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compaction_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_checkpoints_session_id_interaction_sessions_id_fk": { + "name": "interaction_checkpoints_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_checkpoints", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_handoffs": { + "name": "interaction_handoffs", + "schema": "", + "columns": { + "handoff_id": { + "name": "handoff_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_handoff_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_handoffs_session_status_idx": { + "name": "interaction_handoffs_session_status_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_handoffs_session_id_interaction_sessions_id_fk": { + "name": "interaction_handoffs_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_handoffs", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_inbox": { + "name": "interaction_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_inbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_inbox_session_idempotency_idx": { + "name": "interaction_inbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_inbox_session_status_created_idx": { + "name": "interaction_inbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_inbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_inbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_inbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_outbox": { + "name": "interaction_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_outbox_session_idempotency_idx": { + "name": "interaction_outbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_outbox_session_status_created_idx": { + "name": "interaction_outbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_outbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_outbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_outbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_sessions": { + "name": "interaction_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_session_id": { + "name": "runtime_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "interaction_sessions_owner_id_users_id_fk": { + "name": "interaction_sessions_owner_id_users_id_fk", + "tableFrom": "interaction_sessions", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.logical_agent_connector_leases": { + "name": "logical_agent_connector_leases", + "schema": "", + "columns": { + "lease_id": { + "name": "lease_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logical_agent_id": { + "name": "logical_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "lease_epoch": { + "name": "lease_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "logical_agent_connector_lease_binding_idx": { + "name": "logical_agent_connector_lease_binding_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "logical_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "logical_agent_connector_lease_expiry_idx": { + "name": "logical_agent_connector_lease_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "logical_agent_connector_lease_connector_idx": { + "name": "logical_agent_connector_lease_connector_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mission_tasks": { + "name": "mission_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr": { + "name": "pr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mission_tasks_mission_id_idx": { + "name": "mission_tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_task_id_idx": { + "name": "mission_tasks_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_user_id_idx": { + "name": "mission_tasks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_status_idx": { + "name": "mission_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mission_tasks_mission_id_missions_id_fk": { + "name": "mission_tasks_mission_id_missions_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mission_tasks_task_id_tasks_id_fk": { + "name": "mission_tasks_task_id_tasks_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mission_tasks_user_id_users_id_fk": { + "name": "mission_tasks_user_id_users_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.missions": { + "name": "missions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "missions_project_id_idx": { + "name": "missions_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "missions_user_id_idx": { + "name": "missions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "missions_project_id_projects_id_fk": { + "name": "missions_project_id_projects_id_fk", + "tableFrom": "missions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "missions_user_id_users_id_fk": { + "name": "missions_user_id_users_id_fk", + "tableFrom": "missions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mutable": { + "name": "mutable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "preferences_user_id_idx": { + "name": "preferences_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "preferences_user_key_idx": { + "name": "preferences_user_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_credentials": { + "name": "provider_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_credentials_user_provider_idx": { + "name": "provider_credentials_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_credentials_user_id_idx": { + "name": "provider_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_credentials_user_id_users_id_fk": { + "name": "provider_credentials_user_id_users_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routing_rules": { + "name": "routing_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routing_rules_scope_priority_idx": { + "name": "routing_rules_scope_priority_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_user_id_idx": { + "name": "routing_rules_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_enabled_idx": { + "name": "routing_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routing_rules_user_id_users_id_fk": { + "name": "routing_rules_user_id_users_id_fk", + "tableFrom": "routing_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_enabled_idx": { + "name": "skills_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_installed_by_users_id_fk": { + "name": "skills_installed_by_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "installed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.summarization_jobs": { + "name": "summarization_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "logs_processed": { + "name": "logs_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "insights_created": { + "name": "insights_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "summarization_jobs_status_idx": { + "name": "summarization_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_mission_id_idx": { + "name": "tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_mission_id_missions_id_fk": { + "name": "tasks_mission_id_missions_id_fk", + "tableFrom": "tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_user_idx": { + "name": "team_members_team_user_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_invited_by_users_id_fk": { + "name": "team_members_invited_by_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "teams_manager_id_users_id_fk": { + "name": "teams_manager_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "manager_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.backlog_status": { + "name": "backlog_status", + "schema": "public", + "values": [ + "ready", + "claimed", + "blocked", + "done" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "pending", + "active", + "revoked", + "expired" + ] + }, + "public.interaction_handoff_status": { + "name": "interaction_handoff_status", + "schema": "public", + "values": [ + "pending", + "accepted" + ] + }, + "public.interaction_inbox_status": { + "name": "interaction_inbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed" + ] + }, + "public.interaction_outbox_status": { + "name": "interaction_outbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "delivered" + ] + }, + "public.peer_state": { + "name": "peer_state", + "schema": "public", + "values": [ + "pending", + "active", + "suspended", + "revoked" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index fb09f9d8..1f143f9e 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -85,6 +85,41 @@ "when": 1782310438919, "tag": "0011_bitter_gateway", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1783911983447, + "tag": "0012_interaction_durable_state", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1783913232578, + "tag": "0013_interaction_checkpoint_history", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1783913398006, + "tag": "0014_interaction_outbox_channel_scope", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1783942610000, + "tag": "0015_interaction_checkpoint_payload_digest", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1784050648841, + "tag": "0016_salty_morlocks", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 7f343ce2..9735cb2c 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -15,6 +15,7 @@ import { uniqueIndex, real, integer, + bigint, customType, } from 'drizzle-orm/pg-core'; @@ -487,6 +488,182 @@ export const agentLogs = pgTable( ], ); +// ─── Logical agent connector authority ────────────────────────────────────── +// One durable row is the current authority for a tenant/logical-agent/binding. +// Runtime-native session identifiers never enter these core tables. + +export const logicalAgentConnectorLeases = pgTable( + 'logical_agent_connector_leases', + { + leaseId: uuid('lease_id').primaryKey(), + tenantId: text('tenant_id').notNull(), + logicalAgentId: text('logical_agent_id').notNull(), + bindingId: text('binding_id').notNull(), + connectorId: text('connector_id').notNull(), + scopes: jsonb('scopes').notNull().$type(), + leaseEpoch: bigint('lease_epoch', { mode: 'bigint' }).notNull(), + acquiredAt: timestamp('acquired_at', { withTimezone: true }).notNull(), + heartbeatAt: timestamp('heartbeat_at', { withTimezone: true }).notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + releasedAt: timestamp('released_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('logical_agent_connector_lease_binding_idx').on( + t.tenantId, + t.logicalAgentId, + t.bindingId, + ), + index('logical_agent_connector_lease_expiry_idx').on(t.expiresAt), + index('logical_agent_connector_lease_connector_idx').on(t.connectorId), + ], +); + +/** Append-only, credential-safe lease lifecycle and fencing denial metadata. */ +export const connectorLeaseAuditLog = pgTable( + 'connector_lease_audit_log', + { + id: uuid('id').primaryKey().defaultRandom(), + tenantId: text('tenant_id').notNull(), + logicalAgentId: text('logical_agent_id').notNull(), + bindingId: text('binding_id').notNull(), + connectorId: text('connector_id').notNull(), + leaseId: uuid('lease_id'), + leaseEpoch: bigint('lease_epoch', { mode: 'bigint' }), + event: text('event', { + enum: ['acquire', 'renew', 'takeover', 'reject', 'release', 'expiry'], + }).notNull(), + outcome: text('outcome', { enum: ['succeeded', 'denied'] }).notNull(), + reason: text('reason'), + correlationId: text('correlation_id').notNull(), + occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(), + }, + (t) => [ + index('connector_lease_audit_binding_occurred_idx').on( + t.tenantId, + t.logicalAgentId, + t.bindingId, + t.occurredAt.desc(), + ), + index('connector_lease_audit_correlation_idx').on(t.correlationId), + ], +); + +// ─── Tess durable session state ───────────────────────────────────────────── +// PostgreSQL is canonical for restart-safe Tess session recovery. The state +// machine lives in @mosaicstack/agent; these records are its durable adapter. + +export const interactionInboxStatusEnum = pgEnum('interaction_inbox_status', [ + 'pending', + 'processing', + 'processed', +]); +export const interactionOutboxStatusEnum = pgEnum('interaction_outbox_status', [ + 'pending', + 'processing', + 'delivered', +]); +export const interactionHandoffStatusEnum = pgEnum('interaction_handoff_status', [ + 'pending', + 'accepted', +]); + +export const interactionSessions = pgTable('interaction_sessions', { + id: text('id').primaryKey(), + agentName: text('agent_name').notNull(), + tenantId: text('tenant_id').notNull(), + ownerId: text('owner_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + providerId: text('provider_id').notNull(), + runtimeSessionId: text('runtime_session_id').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const interactionInbox = pgTable( + 'interaction_inbox', + { + id: uuid('id').primaryKey().defaultRandom(), + sessionId: text('session_id') + .notNull() + .references(() => interactionSessions.id, { onDelete: 'cascade' }), + idempotencyKey: text('idempotency_key').notNull(), + correlationId: text('correlation_id').notNull(), + content: text('content').notNull(), + contentDigest: text('content_digest').notNull(), + status: interactionInboxStatusEnum('status').notNull().default('pending'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('interaction_inbox_session_idempotency_idx').on(t.sessionId, t.idempotencyKey), + index('interaction_inbox_session_status_created_idx').on(t.sessionId, t.status, t.createdAt), + ], +); + +export const interactionOutbox = pgTable( + 'interaction_outbox', + { + id: uuid('id').primaryKey().defaultRandom(), + sessionId: text('session_id') + .notNull() + .references(() => interactionSessions.id, { onDelete: 'cascade' }), + idempotencyKey: text('idempotency_key').notNull(), + correlationId: text('correlation_id').notNull(), + channelId: text('channel_id').notNull(), + kind: text('kind').notNull(), + content: text('content').notNull(), + contentDigest: text('content_digest').notNull(), + status: interactionOutboxStatusEnum('status').notNull().default('pending'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('interaction_outbox_session_idempotency_idx').on(t.sessionId, t.idempotencyKey), + index('interaction_outbox_session_status_created_idx').on(t.sessionId, t.status, t.createdAt), + ], +); + +export const interactionCheckpoints = pgTable( + 'interaction_checkpoints', + { + id: uuid('id').primaryKey().defaultRandom(), + sessionId: text('session_id') + .notNull() + .references(() => interactionSessions.id, { onDelete: 'cascade' }), + checkpointId: text('checkpoint_id').notNull(), + contentDigest: text('content_digest').notNull(), + cursor: text('cursor').notNull(), + summary: text('summary').notNull(), + compactionEpoch: integer('compaction_epoch').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('interaction_checkpoints_session_idempotency_idx').on(t.sessionId, t.checkpointId), + index('interaction_checkpoints_session_epoch_idx').on(t.sessionId, t.compactionEpoch), + ], +); + +export const interactionHandoffs = pgTable( + 'interaction_handoffs', + { + handoffId: text('handoff_id').primaryKey(), + sessionId: text('session_id') + .notNull() + .references(() => interactionSessions.id, { onDelete: 'cascade' }), + destination: text('destination').notNull(), + correlationId: text('correlation_id').notNull(), + checkpointId: text('checkpoint_id').notNull(), + status: interactionHandoffStatusEnum('status').notNull().default('pending'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index('interaction_handoffs_session_status_idx').on(t.sessionId, t.status)], +); + // ─── Skills ───────────────────────────────────────────────────────────────── export const skills = pgTable( diff --git a/packages/log/src/agent-logs.ts b/packages/log/src/agent-logs.ts index e303ee48..337f409a 100644 --- a/packages/log/src/agent-logs.ts +++ b/packages/log/src/agent-logs.ts @@ -58,9 +58,28 @@ export function createAgentLogsRepo(db: Db) { return rows[0]; }, + /** + * Transition hot logs for one session to warm tier. Session retention is + * default-deny: no other session's logs can be changed by this operation. + */ + async promoteSessionToWarm(sessionId: string, olderThan: Date): Promise { + const result = await db + .update(agentLogs) + .set({ tier: 'warm', summarizedAt: new Date() }) + .where( + and( + eq(agentLogs.sessionId, sessionId), + eq(agentLogs.tier, 'hot'), + lt(agentLogs.createdAt, olderThan), + ), + ) + .returning(); + return result.length; + }, + /** * Transition hot logs older than the cutoff to warm tier. - * Returns the number of logs transitioned. + * Reserved for a separately authorized global retention job. */ async promoteToWarm(olderThan: Date): Promise { const result = await db diff --git a/packages/log/src/index.ts b/packages/log/src/index.ts index 86bdffe2..699509c3 100644 --- a/packages/log/src/index.ts +++ b/packages/log/src/index.ts @@ -10,3 +10,15 @@ export { type LogQuery, } from './agent-logs.js'; export { registerLogCommand } from './cli.js'; +export { + redactSensitiveContent, + type RedactionResult, + type SensitiveClassification, +} from './redaction.js'; +export { + createRuntimeAuditLogEntry, + type RuntimeAuditEvent, + type RuntimeAuditErrorCode, + type RuntimeAuditOperation, + type RuntimeAuditOutcome, +} from './runtime-audit.js'; diff --git a/packages/log/src/redaction.spec.ts b/packages/log/src/redaction.spec.ts new file mode 100644 index 00000000..4a0fd183 --- /dev/null +++ b/packages/log/src/redaction.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { redactSensitiveContent } from './redaction.js'; + +describe('redactSensitiveContent', (): void => { + it('redacts seeded secret and PII canaries before persistence or egress', (): void => { + const result = redactSensitiveContent( + 'email canary@example.test token=sk_CANARY12345678 phone +1 555 555 1212', + ); + expect(result.content).not.toContain('canary@example.test'); + expect(result.content).not.toContain('sk_CANARY12345678'); + expect(result.content).not.toContain('+1 555 555 1212'); + expect(result.classifications).toEqual(['secret', 'pii']); + }); + + it('redacts common provider credential formats', (): void => { + const result = redactSensitiveContent( + 'Authorization: Bearer canary.bearer.token jwt eyJcanary.eyJpayload.eyJsignature aws AKIACANARY1234567890', + ); + + expect(result.content).not.toContain('canary.bearer.token'); + expect(result.content).not.toContain('eyJcanary.eyJpayload.eyJsignature'); + expect(result.content).not.toContain('AKIACANARY1234567890'); + expect(result.classifications).toEqual(['secret']); + }); +}); diff --git a/packages/log/src/redaction.ts b/packages/log/src/redaction.ts new file mode 100644 index 00000000..3a9320b0 --- /dev/null +++ b/packages/log/src/redaction.ts @@ -0,0 +1,40 @@ +export type SensitiveClassification = 'secret' | 'pii'; + +export interface RedactionResult { + content: string; + classifications: SensitiveClassification[]; +} + +const SECRET_PATTERNS: RegExp[] = [ + /\b(?:sk|ghp|gitea)_[A-Za-z0-9_-]{8,}\b/g, + /\b(?:api[_-]?key|token|password|secret)\s*[:=]\s*[^\s,;]+/gi, + /\b(?:authorization\s*:\s*)?bearer\s+[A-Za-z0-9._~+/-]+=*/gi, + /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, + /-----BEGIN(?: [A-Z]+)* KEY-----[\s\S]*?-----END(?: [A-Z]+)* KEY-----/g, + /https?:\/\/[^\s?#]+[^\s]*[?&](?:token|key|secret|signature|sig)=[^\s&#]+/gi, +]; +const PII_PATTERNS: RegExp[] = [ + /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, + /\b\+?\d[\d(). -]{7,}\d\b/g, +]; + +export function redactSensitiveContent(content: string): RedactionResult { + let redacted = content; + const classifications: SensitiveClassification[] = []; + for (const pattern of SECRET_PATTERNS) { + if (pattern.test(redacted)) { + classifications.push('secret'); + redacted = redacted.replace(pattern, '[REDACTED_SECRET]'); + } + pattern.lastIndex = 0; + } + for (const pattern of PII_PATTERNS) { + if (pattern.test(redacted)) { + classifications.push('pii'); + redacted = redacted.replace(pattern, '[REDACTED_PII]'); + } + pattern.lastIndex = 0; + } + return { content: redacted, classifications: [...new Set(classifications)] }; +} diff --git a/packages/log/src/runtime-audit.test.ts b/packages/log/src/runtime-audit.test.ts new file mode 100644 index 00000000..0651b5f1 --- /dev/null +++ b/packages/log/src/runtime-audit.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { createRuntimeAuditLogEntry } from './runtime-audit.js'; + +describe('createRuntimeAuditLogEntry', (): void => { + it('serializes only allowlisted runtime audit metadata', (): void => { + const entry = createRuntimeAuditLogEntry({ + providerId: 'fleet', + operation: 'session.send', + outcome: 'succeeded', + actorId: 'actor-1', + tenantId: 'tenant-1', + channelId: 'discord', + correlationId: 'correlation-1', + resourceId: 'session-1', + durationMs: 12, + }); + + expect(entry).toMatchObject({ + sessionId: 'runtime:fleet', + userId: 'actor-1', + level: 'info', + category: 'tool_use', + content: 'runtime.provider.audit', + metadata: { + providerId: 'fleet', + operation: 'session.send', + outcome: 'succeeded', + correlationId: 'correlation-1', + resourceId: expect.stringMatching(/^sha256:/), + durationMs: 12, + }, + }); + expect(JSON.stringify(entry)).not.toContain('approvalRef'); + }); + + it('hashes every resource ID without blocking a runtime audit or persisting its raw value', (): void => { + const entry = createRuntimeAuditLogEntry({ + providerId: 'fleet', + operation: 'session.send', + outcome: 'succeeded', + actorId: 'actor-1', + tenantId: 'tenant-1', + channelId: 'discord', + correlationId: 'correlation-1', + resourceId: 'credential-canary:secret-value', + durationMs: 12, + }); + + expect(entry.metadata).toMatchObject({ resourceId: expect.stringMatching(/^sha256:/) }); + expect(JSON.stringify(entry)).not.toContain('secret-value'); + }); +}); diff --git a/packages/log/src/runtime-audit.ts b/packages/log/src/runtime-audit.ts new file mode 100644 index 00000000..66bc00f3 --- /dev/null +++ b/packages/log/src/runtime-audit.ts @@ -0,0 +1,87 @@ +import { createHash } from 'node:crypto'; +import type { NewAgentLog } from './agent-logs.js'; + +export type RuntimeAuditOperation = + | 'session.list' + | 'session.tree' + | 'session.stream' + | 'session.send' + | 'session.attach' + | 'session.terminate' + | 'runtime.capabilities' + | 'runtime.health' + | 'runtime.transitional-capabilities'; + +export type RuntimeAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed'; +export type RuntimeAuditErrorCode = 'policy_denied' | 'provider_error'; + +/** + * Deliberately metadata-only runtime audit record. It has no fields for message + * content, credentials, approval references, tool arguments, or tool output. + */ +export interface RuntimeAuditEvent { + providerId: string; + operation: RuntimeAuditOperation; + outcome: RuntimeAuditOutcome; + actorId: string; + tenantId: string; + channelId: string; + correlationId: string; + resourceId?: string; + durationMs?: number; + errorCode?: RuntimeAuditErrorCode; +} + +const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + +function safeIdentifier(value: string): string { + if (SAFE_IDENTIFIER.test(value)) return value; + return hashIdentifier(value); +} + +function hashIdentifier(value: string): string { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +/** + * Converts a typed audit event into a durable log entry using an explicit + * allowlist. Values that could carry credentials or untrusted content are + * rejected before persistence or structured log emission. + */ +export function createRuntimeAuditLogEntry(event: RuntimeAuditEvent): NewAgentLog { + const providerId = safeIdentifier(event.providerId); + const actorId = safeIdentifier(event.actorId); + const tenantId = safeIdentifier(event.tenantId); + const channelId = safeIdentifier(event.channelId); + const correlationId = safeIdentifier(event.correlationId); + // Provider resource identifiers may be opaque or user-derived, so never persist them raw. + const resourceId = event.resourceId ? hashIdentifier(event.resourceId) : undefined; + const persistedUserId = SAFE_IDENTIFIER.test(event.actorId) ? event.actorId : null; + + if ( + event.durationMs !== undefined && + (!Number.isInteger(event.durationMs) || event.durationMs < 0) + ) { + throw new Error('Runtime audit duration must be a non-negative integer'); + } + + return { + sessionId: `runtime:${providerId}`, + userId: persistedUserId, + level: event.outcome === 'failed' ? 'error' : event.outcome === 'denied' ? 'warn' : 'info', + category: event.operation.startsWith('session.') ? 'tool_use' : 'general', + content: 'runtime.provider.audit', + metadata: { + providerId, + operation: event.operation, + outcome: event.outcome, + actorId, + tenantId, + channelId, + correlationId, + ...(resourceId ? { resourceId } : {}), + ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}), + ...(event.errorCode ? { errorCode: event.errorCode } : {}), + }, + }; +} diff --git a/packages/memory/src/adapters/keyword.test.ts b/packages/memory/src/adapters/keyword.test.ts index 2a0ac855..a56b8007 100644 --- a/packages/memory/src/adapters/keyword.test.ts +++ b/packages/memory/src/adapters/keyword.test.ts @@ -274,6 +274,20 @@ describe('KeywordAdapter', () => { expect(results).toHaveLength(1); }); + it('should return all scoped insights for the explicit wildcard query', async () => { + await adapter.storeInsight({ + userId: 'u1', + content: 'A literal * marker is still ordinary content', + source: 'chat', + category: 'technical', + relevanceScore: 0.7, + }); + + const results = await adapter.searchInsights('u1', '*'); + expect(results).toHaveLength(4); + expect(results.every((result) => result.score === 1)).toBe(true); + }); + it('should return empty for empty query', async () => { const results = await adapter.searchInsights('u1', ' '); expect(results).toHaveLength(0); diff --git a/packages/memory/src/adapters/keyword.ts b/packages/memory/src/adapters/keyword.ts index 75750766..ea188e7a 100644 --- a/packages/memory/src/adapters/keyword.ts +++ b/packages/memory/src/adapters/keyword.ts @@ -132,19 +132,23 @@ export class KeywordAdapter implements MemoryAdapter { opts?: { limit?: number; embedding?: number[] }, ): Promise { const limit = opts?.limit ?? 10; - const words = query - .toLowerCase() - .split(/\s+/) - .filter((w) => w.length > 0); + const normalizedQuery = query.trim(); + const matchAll = normalizedQuery === '*'; + const words = matchAll + ? [] + : normalizedQuery + .toLowerCase() + .split(/\s+/) + .filter((word) => word.length > 0); - if (words.length === 0) return []; + if (words.length === 0 && !matchAll) return []; const rows = await this.storage.find(INSIGHTS, { userId }); const scored: InsightSearchResult[] = []; for (const row of rows) { const content = row.content.toLowerCase(); - let score = 0; + let score = matchAll ? 1 : 0; for (const word of words) { if (content.includes(word)) score++; } diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index 97f734da..243a0276 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -22,6 +22,13 @@ export type { InsightSearchResult, } from './types.js'; export { createMemoryAdapter, registerMemoryAdapter } from './factory.js'; +export { + createOperatorMemoryPlugin, + type OperatorMemoryPlugin, + type OperatorMemoryScope, + type OperatorMemoryConfig, + type OperatorMemoryResult, +} from './operator-memory-plugin.js'; export { PgVectorAdapter } from './adapters/pgvector.js'; export { KeywordAdapter } from './adapters/keyword.js'; diff --git a/packages/memory/src/operator-memory-plugin.test.ts b/packages/memory/src/operator-memory-plugin.test.ts new file mode 100644 index 00000000..26649db9 --- /dev/null +++ b/packages/memory/src/operator-memory-plugin.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createOperatorMemoryPlugin } from './operator-memory-plugin.js'; +import type { Insight, InsightSearchResult, NewInsight } from './types.js'; + +function adapter() { + return { + name: 'test', + embedder: null, + storeInsight: vi.fn( + async (value: NewInsight): Promise => ({ + ...value, + id: '1', + createdAt: new Date(), + }), + ), + searchInsights: vi.fn(async (): Promise => []), + getInsight: vi.fn(), + deleteInsight: vi.fn(), + getPreference: vi.fn(), + setPreference: vi.fn(), + deletePreference: vi.fn(), + listPreferences: vi.fn(), + close: vi.fn(), + }; +} + +describe('OperatorMemoryPlugin', () => { + it('isolates configured namespace storage across server-derived tenant, owner, and session scopes', async () => { + const memory = adapter(); + const plugin = createOperatorMemoryPlugin({ + adapter: memory, + instanceId: 'Nova', + namespace: 'operator-memory', + redact: (value) => value, + }); + await plugin.capture( + { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' }, + { content: 'one', source: 'test', category: 'note' }, + ); + await plugin.capture( + { tenantId: 'tenant-b', ownerId: 'owner-a', sessionId: 'session-a' }, + { content: 'two', source: 'test', category: 'note' }, + ); + await plugin.capture( + { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-b' }, + { content: 'three', source: 'test', category: 'note' }, + ); + + expect( + memory.storeInsight.mock.calls.map((call: unknown[]) => (call[0] as NewInsight).userId), + ).toEqual([ + '["operator-memory","tenant-a","owner-a","session-a"]', + '["operator-memory","tenant-b","owner-a","session-a"]', + '["operator-memory","tenant-a","owner-a","session-b"]', + ]); + }); + + it('rejects an incomplete runtime scope before it can produce a shared storage key', async () => { + const memory = adapter(); + const plugin = createOperatorMemoryPlugin({ + adapter: memory, + instanceId: 'Nova', + namespace: 'operator-memory', + redact: (value) => value, + }); + + await expect( + plugin.capture( + { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: ' ' }, + { content: 'note', source: 'test', category: 'note' }, + ), + ).rejects.toThrow('Operator memory session ID is required'); + expect(memory.storeInsight).not.toHaveBeenCalled(); + }); + + it('uses a differently named configured instance in retrieval provenance', async () => { + const memory = adapter(); + memory.searchInsights.mockResolvedValue([ + { id: '1', content: 'x', score: 1, metadata: { source: 'project' } }, + ]); + const plugin = createOperatorMemoryPlugin({ + adapter: memory, + instanceId: 'Nova', + namespace: 'operator-memory', + redact: (value) => value, + }); + + const results = await plugin.search({ tenantId: 't', ownerId: 'o', sessionId: 's' }, 'x'); + + expect(results[0]?.provenance).toEqual({ + instanceId: 'Nova', + namespace: 'operator-memory', + source: 'project', + }); + }); + + it('orders startup context with project and flat-file truth before retrieved material', async () => { + const memory = adapter(); + memory.searchInsights.mockResolvedValue([ + { id: 'retrieval', content: 'retrieval', score: 1 }, + { id: 'flat-file', content: 'flat-file', score: 1, metadata: { source: 'flat-file' } }, + { id: 'project', content: 'project', score: 1, metadata: { source: 'project' } }, + ]); + const plugin = createOperatorMemoryPlugin({ + adapter: memory, + instanceId: 'Nova', + namespace: 'operator-memory', + maxStartupContext: 2, + redact: (value) => value, + }); + + const context = await plugin.startupContext({ + tenantId: 'tenant-a', + ownerId: 'owner-a', + sessionId: 'session-a', + }); + + expect(context.map((result) => result.id)).toEqual(['project', 'flat-file']); + expect(memory.searchInsights).toHaveBeenCalledWith(expect.any(String), '*', { limit: 64 }); + }); + + it('redacts content before adapter persistence and records configured provenance metadata', async () => { + const memory = adapter(); + const plugin = createOperatorMemoryPlugin({ + adapter: memory, + instanceId: 'Nova', + namespace: 'operator-memory', + redact: (value) => value.replace('secret', '[REDACTED]'), + }); + + await plugin.capture( + { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' }, + { content: 'secret note', source: 'project', category: 'note' }, + ); + + expect(memory.storeInsight).toHaveBeenCalledWith( + expect.objectContaining({ + content: '[REDACTED] note', + metadata: { + instanceId: 'Nova', + namespace: 'operator-memory', + source: 'project', + }, + }), + ); + }); +}); diff --git a/packages/memory/src/operator-memory-plugin.ts b/packages/memory/src/operator-memory-plugin.ts new file mode 100644 index 00000000..698d904f --- /dev/null +++ b/packages/memory/src/operator-memory-plugin.ts @@ -0,0 +1,154 @@ +import type { Insight, InsightSearchResult, MemoryAdapter } from './types.js'; + +const STARTUP_CONTEXT_CANDIDATE_LIMIT = 64; + +/** Immutable server-derived boundary; callers never choose an adapter namespace. */ +export interface OperatorMemoryScope { + readonly tenantId: string; + readonly ownerId: string; + readonly sessionId: string; +} + +export interface OperatorMemoryConfig { + /** Adapter injection is deployment/lifecycle configuration, never caller input. */ + readonly adapter: MemoryAdapter; + /** Configured agent identity; it is metadata rather than a storage key default. */ + readonly instanceId: string; + /** Configured storage partition; callers cannot select a namespace. */ + readonly namespace: string; + readonly maxStartupContext?: number; + redact(content: string): string; +} + +export interface OperatorMemoryResult extends InsightSearchResult { + provenance: { instanceId: string; namespace: string; source: string }; +} + +export interface OperatorMemoryPlugin { + capture( + scope: OperatorMemoryScope, + input: { content: string; source: string; category: string }, + ): Promise; + search( + scope: OperatorMemoryScope, + query: string, + limit?: number, + ): Promise; + recent(scope: OperatorMemoryScope, limit?: number): Promise; + stats(scope: OperatorMemoryScope): Promise<{ namespace: string; resultCount: number }>; + startupContext(scope: OperatorMemoryScope): Promise; +} + +function scopedUserId(scope: OperatorMemoryScope, namespace: string): string { + const normalizedScope = normalizeScope(scope); + // JSON tuple encoding avoids delimiter collisions between independently scoped IDs. + return JSON.stringify([ + namespace, + normalizedScope.tenantId, + normalizedScope.ownerId, + normalizedScope.sessionId, + ]); +} + +function normalizeScope(scope: OperatorMemoryScope): OperatorMemoryScope { + if (typeof scope !== 'object' || scope === null) { + throw new Error('Operator memory scope is required'); + } + return Object.freeze({ + tenantId: requiredScopeId(scope.tenantId, 'tenant ID'), + ownerId: requiredScopeId(scope.ownerId, 'owner ID'), + sessionId: requiredScopeId(scope.sessionId, 'session ID'), + }); +} + +function requiredScopeId(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Operator memory ${field} is required`); + } + return value.trim(); +} + +function compareStartupContext(left: OperatorMemoryResult, right: OperatorMemoryResult): number { + return ( + startupSourcePriority(left.provenance.source) - startupSourcePriority(right.provenance.source) + ); +} + +function startupSourcePriority(source: string): number { + if (source === 'project') return 0; + if (source === 'flat-file') return 1; + return 2; +} + +function normalizeConfig(config: OperatorMemoryConfig): OperatorMemoryConfig { + const instanceId = config.instanceId.trim(); + const namespace = config.namespace.trim(); + const maxStartupContext = config.maxStartupContext ?? 8; + if (instanceId.length === 0 || namespace.length === 0) { + throw new Error('Operator memory instance ID and namespace must be configured'); + } + if (!Number.isSafeInteger(maxStartupContext) || maxStartupContext < 1) { + throw new Error('Operator memory startup context limit must be a positive integer'); + } + return Object.freeze({ ...config, instanceId, namespace, maxStartupContext }); +} + +/** Creates a leaf-package, replaceable memory adapter facade. */ +export function createOperatorMemoryPlugin(config: OperatorMemoryConfig): OperatorMemoryPlugin { + const pluginConfig = normalizeConfig(config); + const mapResult = (result: InsightSearchResult): OperatorMemoryResult => ({ + ...result, + provenance: { + instanceId: pluginConfig.instanceId, + namespace: pluginConfig.namespace, + source: String(result.metadata?.['source'] ?? 'retrieval'), + }, + }); + const search = async ( + scope: OperatorMemoryScope, + query: string, + limit = 10, + ): Promise => + ( + await pluginConfig.adapter.searchInsights( + scopedUserId(scope, pluginConfig.namespace), + query, + { + limit, + }, + ) + ).map(mapResult); + return { + async capture(scope, input) { + return pluginConfig.adapter.storeInsight({ + userId: scopedUserId(scope, pluginConfig.namespace), + content: pluginConfig.redact(input.content), + source: input.source, + category: input.category, + relevanceScore: 1, + metadata: { + namespace: pluginConfig.namespace, + instanceId: pluginConfig.instanceId, + source: input.source, + }, + }); + }, + search, + async recent(scope, limit = 10) { + return search(scope, '*', limit); + }, + async stats(scope) { + return { + namespace: pluginConfig.namespace, + resultCount: (await search(scope, '*', 100)).length, + }; + }, + async startupContext(scope) { + const maxStartupContext = pluginConfig.maxStartupContext ?? 8; + // Prioritize authoritative sources within a bounded candidate window. + const candidateLimit = Math.max(maxStartupContext, STARTUP_CONTEXT_CANDIDATE_LIMIT); + const context = await search(scope, '*', candidateLimit); + return [...context].sort(compareStartupContext).slice(0, maxStartupContext); + }, + }; +} diff --git a/packages/memory/src/types.ts b/packages/memory/src/types.ts index 47f9bf0b..22aeb416 100644 --- a/packages/memory/src/types.ts +++ b/packages/memory/src/types.ts @@ -49,6 +49,10 @@ export interface MemoryAdapter { // Insights storeInsight(insight: NewInsight): Promise; getInsight(id: string): Promise; + /** + * Searches within one scoped user ID. The reserved `*` query returns scoped + * recent/all results rather than performing backend-specific wildcard parsing. + */ searchInsights( userId: string, query: string, diff --git a/packages/mosaic/README.md b/packages/mosaic/README.md index a6164796..6a1b9c93 100644 --- a/packages/mosaic/README.md +++ b/packages/mosaic/README.md @@ -47,6 +47,61 @@ export MOSAIC_ADMIN_PASSWORD="securepass123" mosaic gateway install ``` +## Runtime launchers + +```bash +mosaic claude # Launch Claude Code with Mosaic injection +mosaic yolo claude # …with --dangerously-skip-permissions +mosaic codex | opencode | pi +``` + +### `mosaic claudex` (EXPERIMENTAL) + +Runs GPT models **inside the Claude Code harness** by pointing Claude Code at a +local [`claude-code-proxy`](https://github.com/raine/claude-code-proxy) that +translates the Anthropic Messages API to a ChatGPT-subscription (Codex OAuth) +backend. This is **not Anthropic Claude** — model behavior, tool use, and output +quality may differ. Intended for evaluation, not production delivery. + +```bash +mosaic claudex # launch (prompts through the proxy readiness gate) +mosaic yolo claudex # …with --dangerously-skip-permissions +mosaic claudex --print "hello" # trailing args are forwarded to Claude Code +``` + +**Prerequisite:** the `claude-code-proxy` binary must be installed and +authenticated (`claude-code-proxy codex auth …`). `mosaic claudex` runs a +preflight that verifies the binary, the OAuth state (triggering a device re-auth +if needed), and a trusted local listener before launching; it **fails closed** +if the proxy cannot be brought up with a verified identity. + +**Isolation (never touches your real Claude state).** claudex always launches +against an isolated `CLAUDE_CONFIG_DIR` (default `~/.config/mosaic/claudex/home`). +The ambient `CLAUDE_CONFIG_DIR` is deliberately ignored, and a guard proves the +resolved dir can never be — or live under — the real `~/.claude`. A claudex +session therefore cannot mutate your normal Claude Code config. + +**No token leakage.** claudex never reads the proxy's credential file. Claude +Code is handed only `ANTHROPIC_AUTH_TOKEN=unused` pointed at the loopback proxy; +the entire credential-bearing env family (`ANTHROPIC_*`, `AWS_*`, `GOOGLE_CLOUD_*`, +`GOOGLE_APPLICATION_CREDENTIALS`, `*_TOKEN`, `*_KEY`, `*_SECRET`, …) is stripped +from the composed environment. The Bedrock/Vertex routing switches +(`CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_USE_VERTEX`, and the `_SKIP_*_AUTH` +pair) are force-removed regardless of value — otherwise their mere presence +would route Claude Code to the real Anthropic API via AWS/GCP and bypass the +proxy. The proxy holds the real OAuth credential. + +**Model tiers (override via env).** + +| Tier | Env var | Default | +| --------------------- | ---------------------------- | -------------- | +| primary (opus/sonnet) | `ANTHROPIC_MODEL` | `gpt-5.6-sol` | +| small/fast (haiku) | `ANTHROPIC_SMALL_FAST_MODEL` | `gpt-5.6-luna` | + +Operator-provided values win over the defaults. Additional overrides: +`MOSAIC_CLAUDEX_CONFIG_DIR` (isolated config dir), `ANTHROPIC_BASE_URL` (proxy +endpoint). + ## Hooks management After running `mosaic wizard`, Claude hooks are installed in `~/.claude/hooks-config.json`. diff --git a/packages/mosaic/framework/defaults/README.md b/packages/mosaic/framework/defaults/README.md index 1a598dcb..8222ffb3 100644 --- a/packages/mosaic/framework/defaults/README.md +++ b/packages/mosaic/framework/defaults/README.md @@ -189,15 +189,24 @@ bash tools/install.sh --dev # Contributor lane: source build at --ref/ma bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next) ``` +The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage. + ## Universal Skills -The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories. +The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`. Install, wizard finalization, and `mosaic update` automatically reconcile every canonical skill into Claude Code's `~/.claude/skills/` directory. ```bash -mosaic sync # Full sync (clone + link) -~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only +mosaic sync # Full canonical catalog sync +~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only +mosaic skill list # Show registered, missing, dangling, and foreign entries +mosaic skill register # Register or repair one canonical Claude link +mosaic skill unregister # Remove one Mosaic-owned Claude link ``` +Skill names are direct children using `[A-Za-z0-9][A-Za-z0-9._-]*`, not paths. Registration rejects traversal/control characters and never replaces foreign files, directories, or symlinks; unregister removes only links that point inside the canonical Mosaic skill root. After registering during a running Claude Code session, use `/reload-skills` or start a new session. + +M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosaic root through its launcher configuration. Codex parity remains follow-up scope and continues to use the existing full skill-sync linker. + ## Health Audit ```bash diff --git a/packages/mosaic/framework/defaults/TOOLS.md b/packages/mosaic/framework/defaults/TOOLS.md index 0cca3dbf..6b0bf178 100644 --- a/packages/mosaic/framework/defaults/TOOLS.md +++ b/packages/mosaic/framework/defaults/TOOLS.md @@ -5,20 +5,20 @@ Tool suites live at `~/.config/mosaic/tools//`. This is the index only. read it (or the relevant service guide) when your task actually touches that service. Project-specific tooling belongs in the project's `AGENTS.md`, not here. -## ⚡ Most-used fleet tools (reach for these FIRST — don't hand-roll) +## Most-used fleet tools (reach for these first) -You are a Mosaic fleet agent. These cover the highest-frequency cross-agent and git-provider -tasks — use them before improvising with raw `tmux send-keys`, raw `tea`/`gh`/`glab`, or `curl`. + -**1. Message another agent** → `tools/tmux/agent-send.sh` (NOT raw `tmux send-keys`): +You are a Mosaic fleet agent. Use the runtime-composed **Fleet Comms — authoritative exact targets** +section for inter-agent messaging. It renders your authoritative local host, exact agent/session, resolved +tmux socket, installed helper path, generation, and one executable command per known peer. -```bash -tools/tmux/agent-send.sh -s -m "message" # or -f to send a file's contents -``` +Select only a peer row rendered for your exact roster identity. Never invent, substitute, or fuzzy-match +a host, session, socket, SSH destination, or helper path. If a peer is absent, stop and run the exact +self-scoped discovery command shown in that composed section; report the peer as unknown if it remains +absent. Do not use raw `tmux send-keys` for fleet messaging. -The coordinator session is `mos-claude` — send status, findings, and questions there. - -**2. Issues / PRs / milestones** → `tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`): +**Issues / PRs / milestones** → `tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`): ```bash tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ... diff --git a/packages/mosaic/framework/defaults/wake-watch-list.schema.json b/packages/mosaic/framework/defaults/wake-watch-list.schema.json new file mode 100644 index 00000000..3b495f30 --- /dev/null +++ b/packages/mosaic/framework/defaults/wake-watch-list.schema.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mosaicstack.dev/schemas/wake-watch-list.schema.json", + "title": "Mosaic Wake Watch-List", + "description": "Declarative watch-list for the wake/heartbeat detector (EPIC #892). The SCHEMA is framework-owned; the VALUES are operator-supplied (repos, board files, lane anchors, per-class SLOs). This is the W2 schema contract only — the detector (W4) and digest renderer (W3) consume it. Per CONVERGED-DESIGN §1.4: 'operator repo; schema is framework, values are operator.'", + "type": "object", + "required": ["schema_version", "watches"], + "additionalProperties": false, + "properties": { + "schema_version": { + "type": "integer", + "minimum": 1, + "description": "Watch-list schema version. The wake component's manifest.txt declares the supported range (schema_min/schema_max, Gate B); a watch-list outside that range is rejected by the component, not silently coerced." + }, + "host": { + "type": "string", + "description": "Optional operator label for the host this watch-list serves. Per-host single-instance detector (§1.1). Operator-supplied; no semantic meaning to the schema." + }, + "repos": { + "type": "array", + "description": "Git repositories to watch. Source SHAs are descriptors, not the cursor (§2.4).", + "items": { + "type": "object", + "required": ["id"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Operator-chosen stable identifier for this repo watch." + }, + "remote": { + "type": "string", + "description": "Remote/clone locator (operator-supplied). No credentials inline; secrets are by-name via load_credentials." + }, + "branches": { + "type": "array", + "items": { "type": "string" }, + "description": "Branch refs to track. Empty => default branch." + }, + "class": { "$ref": "#/$defs/class" }, + "slo": { "$ref": "#/$defs/slo_ref" }, + "aba_sensitive": { + "type": "boolean", + "default": false, + "description": "If true, this source needs an event-stream/webhook rather than poll-only (intra-poll ABA mitigation, §2.4 / gate G5). Poll-only remains a mitigation, not elimination." + } + } + } + }, + "board_files": { + "type": "array", + "description": "Board / decision files whose edits must be caught (repo-section/anchor-scoped hashing, §1.1). Human-decision file edits, not just API-visible state.", + "items": { + "type": "object", + "required": ["id", "path"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "repo": { + "type": "string", + "description": "Optional reference to a repos[].id this file lives in." + }, + "path": { + "type": "string", + "description": "File path (operator-supplied). Locators are hard: repo/issue#/SHA/file:anchor (§2.1)." + }, + "class": { "$ref": "#/$defs/class" }, + "slo": { "$ref": "#/$defs/slo_ref" } + } + } + }, + "lane_anchors": { + "type": "array", + "description": "In-file anchors (headings/markers) scoping a lane's obligations, so a file edit outside the lane's anchor does not wake it.", + "items": { + "type": "object", + "required": ["id", "anchor"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "board_file": { + "type": "string", + "description": "Optional reference to a board_files[].id this anchor lives in." + }, + "anchor": { + "type": "string", + "description": "Anchor text/marker delimiting the lane's section within the file." + }, + "class": { "$ref": "#/$defs/class" }, + "slo": { "$ref": "#/$defs/slo_ref" } + } + } + }, + "slos": { + "type": "object", + "description": "Named per-class urgency SLO tiers. SYMBOLIC — the operator sets concrete durations; the schema only fixes the shape and the class ordering intent (§4: security/lease/CI = tight; board = tens of minutes; routine = hours). No numeric parameters are baked into the framework.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "class": { "$ref": "#/$defs/class" }, + "fallback_bound": { + "type": "string", + "description": "Operator-supplied duration (e.g. '5m', '30m', '4h'). Symbolic tier is set by the operator, not the framework." + }, + "fallback_cadence": { + "type": "string", + "description": "OPTIONAL, additive (schema_version 1, backward-compatible — omitting it is valid). The per-class cadence bound for the framework-shipped canon FALLBACK WAKE (F7 replacement-before-retirement, EPIC #892): the low-frequency SAFETY-wake timer (mosaic-wake-fallback.timer) that fires the canon drain INDEPENDENT of the event-driven detector, so a stalled detector cannot silently starve delivery. The A10 installer reads this per-class value and writes it as the fallback timer's OnUnitActiveSec via the blank-reset drop-in (exactly one effective OnUnitActiveUSec). SYMBOLIC — an operator-supplied duration (e.g. '30m', '1h', '4h'); the framework bakes in no numeric. Config, not code. Should be no tighter than this tier's `fallback_bound` (the safety wake is a floor, never the primary mechanism)." + }, + "quiet_hours_may_suppress": { + "type": "boolean", + "default": false, + "description": "If true, quiet-hours may suppress the cold fallback for this tier. MUST remain false for actionable/critical classes (§3: quiet-hours never gate an actionable/critical class)." + }, + "measure_to": { + "type": "string", + "enum": ["consumed", "qualified-action-or-handoff"], + "description": "Terminal the SLO is measured to (§4/G8): CONSUMED measures reading; qualified-action-or-handoff measures doing. Actionable/critical classes measure to the action terminal." + } + } + } + }, + "watches": { + "type": "array", + "description": "The declared source-coverage inventory: a lane-by-lane list of every operational source the lane depends on, so an omitted source cannot make the retirement vector pass vacuously (§4/G3 parity inventory). Each entry references a source declared above by kind+id.", + "items": { + "type": "object", + "required": ["lane", "sources"], + "additionalProperties": false, + "properties": { + "lane": { + "type": "string", + "description": "Operator lane identifier this watch serves." + }, + "sources": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["kind", "id"], + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": ["repo", "board_file", "lane_anchor"], + "description": "Which top-level collection the id refers to." + }, + "id": { + "type": "string", + "description": "Reference to repos[].id / board_files[].id / lane_anchors[].id." + } + } + } + } + } + } + } + }, + "$defs": { + "class": { + "type": "string", + "enum": ["digest", "actionable", "human", "terminal-log", "reaction"], + "description": "Wake class (§2.3). Only `digest` coalesces (cumulative-state replace); actionable/human APPEND. ALL classes are durable. Absent class => the consumer treats it as `actionable` (fail-safe)." + }, + "slo_ref": { + "type": "string", + "description": "Name of an entry in the top-level `slos` map to apply to this source." + } + } +} diff --git a/packages/mosaic/framework/fleet/README.md b/packages/mosaic/framework/fleet/README.md index b1480fd1..df42327f 100644 --- a/packages/mosaic/framework/fleet/README.md +++ b/packages/mosaic/framework/fleet/README.md @@ -9,12 +9,39 @@ package, normally at: ``` The default tmux socket is `mosaic-fleet` so fleet commands do not touch the -default tmux server. +default tmux server. The roster is the desired-state authority; generated environment files are +rebuildable projections, never a second source of configuration. ## Examples - `examples/minimal.yaml` starts one local canary slot. - `examples/local-canary.yaml` starts a small generic dogfood fleet. +- `examples/operator-interaction.yaml` is an example Pi operator-interaction + service; replace its example agent name before provisioning. + +## Operator interaction service + +`services/operator-interaction.yaml` pins the Pi runtime, GPT-5.6 Sol model, +high reasoning, and the `operator-interaction` tool policy. The agent identity +is provisioning data: choose a roster name, generate its per-agent environment +file, then start the matching generic systemd instance. The service fails before +launch if the configured identity does not match the instance or any pinned +policy field drifts. + +The installed `tools/fleet/print-interaction-effective-policy.sh` prints only +the resolved name, runtime, model, reasoning, and tool policy. It never reads +or prints credential variables. + +## Generated agent environment boundary + +`mosaic fleet install` writes a private deterministic projection at +`~/.config/mosaic/fleet/agents/.env.generated`. It may relocate only approved local machine +data to `.env.local`; generated keys, arbitrary commands, secret-like keys, duplicate keys, +unknown keys, and unsafe permissions fail before a tmux session is created. Legacy `.env` input is +regenerated, relocated, or quarantined and is not a launch authority. + +See [`docs/fleet/reference/generated-env-boundary.md`](../../../../docs/fleet/reference/generated-env-boundary.md) +for allowed local keys and the USC downstream interface evidence. Initialize a roster: diff --git a/packages/mosaic/framework/fleet/examples/operator-interaction.yaml b/packages/mosaic/framework/fleet/examples/operator-interaction.yaml new file mode 100644 index 00000000..1f9a4f9f --- /dev/null +++ b/packages/mosaic/framework/fleet/examples/operator-interaction.yaml @@ -0,0 +1,19 @@ +# Example instance only. Replace `Tess` with the chosen provisioned identity. +version: 1 +transport: tmux +tmux: + socket_name: mosaic-fleet + holder_session: _holder +defaults: + working_directory: ~/src +runtimes: + pi: + reset_command: /new +agents: + - name: Tess + runtime: pi + class: operator-interaction + model_hint: openai/gpt-5.6-sol + reasoning_level: high + tool_policy: operator-interaction + persistent_persona: true diff --git a/packages/mosaic/framework/fleet/roles/LIBRARY.md b/packages/mosaic/framework/fleet/roles/LIBRARY.md index b605909e..59d2440b 100644 --- a/packages/mosaic/framework/fleet/roles/LIBRARY.md +++ b/packages/mosaic/framework/fleet/roles/LIBRARY.md @@ -12,19 +12,22 @@ on demand. Engineering personas have no explicit `domain:` marker (they are the implicit `engineering` domain); cross-domain personas carry a `domain:` key in their intro so tooling can group them. -> This file is an index only — no code imports it. To add a persona, drop a new -> `*.md` next to the others (mirroring the existing structure) and add a row here. +> This file is an index, not an authority source. The fleet persona resolver reads +> its rows for discovery compatibility, then requires a readable `*.md` contract; +> authority is derived from canonical class metadata in code, never from this prose. ## engineering | Persona | Purpose | | --------------- | ------------------------------------------------------------------------------ | | orchestrator | Always-on coordinator — runs the supervisor loop, dispatches ready work | +| team-leader | Coordinates only orchestrator-leased capacity for one bounded project | | board | Multi-lens deliberation panel; owns the mission's direction, not its execution | | planner | Turns ratified objectives into a phased FR plan wired into a `depends_on` DAG | | decomposition | Splits FRs into one-PR-each cards wired with `depends_on` edges | | code | Primary executor — one card, one branch, one PR to green CI | | review | Correctness reviewer — judges an open PR on correctness, scope, and coverage | +| validator | Independent final evidence certificate; never approves-to-land or merges | | security-review | Second line of review — secrets, auth, and forbidden-path safety | | site-tester | Runtime verifier — runs the change and checks behavior vs. acceptance criteria | | documentation | Prose maintainer — keeps human-facing docs and projections in sync | @@ -33,6 +36,7 @@ their intro so tooling can group them. | operator | Escalation and control surface — owns exceptions and the fleet pause switch | | session-review | Post-task retrospective — turns finished work into improvement signals | | enhancer | Continuous-improvement loop — upgrades the fleet's tools, skills, and harness | +| interaction | Operator request/status surface; routes orchestration and merge decisions | ## executive diff --git a/packages/mosaic/framework/fleet/roles/interaction.md b/packages/mosaic/framework/fleet/roles/interaction.md new file mode 100644 index 00000000..8346ee0f --- /dev/null +++ b/packages/mosaic/framework/fleet/roles/interaction.md @@ -0,0 +1,16 @@ +# Interaction — fleet role definition + +The **interaction** role (`class: interaction`) is the operator-facing request and status surface for Mosaic. + +## Mandate + +1. Receive operator requests and present observable fleet or runtime status. +2. Route orchestration requests to the orchestrator and merge decisions to the merge-gate. +3. Report supported actions and their outcomes without claiming another role's authority. + +## Boundaries + +- Request/status only; it does not orchestrate, issue leases, approve-to-land, or merge. +- It does not mutate roster configuration, role authority, or credentials. +- A configured instance name such as Tess is display data, never a class or authority source. +- `operator-interaction` remains a compatibility alias for this canonical class. diff --git a/packages/mosaic/framework/fleet/roles/merge-gate.md b/packages/mosaic/framework/fleet/roles/merge-gate.md index 7227084a..3f6f67b9 100644 --- a/packages/mosaic/framework/fleet/roles/merge-gate.md +++ b/packages/mosaic/framework/fleet/roles/merge-gate.md @@ -13,7 +13,14 @@ It is a **gate** role: the one and only merge path. 2. **Use the wrapped scripts as the ONLY merge path** — the merge-gate merges **exclusively** by calling **`pr-merge.sh`** (the merge action, which carries the authoritative forbidden-path guard) and **`pr-ci-wait.sh`** (to wait for green - CI before merging). These two scripts are the _only_ sanctioned merge path. + CI before merging). Before issuing a verdict, scan the full JSON/API child-step + record (including `clone`) with **`verify-terminal-green.py --expect-commit +`** and record the equal expected/observed full-40 + commits, exact step count, anomalies, and named exemptions. Missing or mismatched + commit binding is a hard refusal. The verifier's sole interim + exemption is `WP-K8S-1000-CI-POSTGRES-TEARDOWN`; it is signature-scoped, tracked + by #1000, and retires when #1000 is fixed. These scripts are the _only_ + sanctioned merge path. 3. **Never call the raw API** — the merge-gate **does NOT** call `tea`, the raw Gitea/forge HTTP API, or any other merge mechanism directly. Only `pr-merge.sh` and `pr-ci-wait.sh`. diff --git a/packages/mosaic/framework/fleet/roles/operator-interaction.md b/packages/mosaic/framework/fleet/roles/operator-interaction.md new file mode 100644 index 00000000..81f40bb9 --- /dev/null +++ b/packages/mosaic/framework/fleet/roles/operator-interaction.md @@ -0,0 +1,11 @@ +# Operator Interaction — fleet role definition + +The **operator-interaction** role is the authorized human interaction plane for +Mosaic. It presents runtime and fleet state, mediates approved actions, and +hands coding or general orchestration work to the orchestrator. + +## Boundaries + +- It does not claim orchestrator-owned coding or general orchestration work. +- It exposes only the configured, observable tool policy. +- It does not receive or surface credentials in its effective policy. diff --git a/packages/mosaic/framework/fleet/roles/team-leader.md b/packages/mosaic/framework/fleet/roles/team-leader.md new file mode 100644 index 00000000..c4ccfcc8 --- /dev/null +++ b/packages/mosaic/framework/fleet/roles/team-leader.md @@ -0,0 +1,16 @@ +# Team leader — fleet role definition + +The **team-leader** (`class: team-leader`) coordinates a bounded project team using only capacity granted by an orchestrator-issued lease. + +## Mandate + +1. Direct the leased coder, reviewer, and validator capacity for the assigned project scope. +2. Track delivery status and return results or blockers to the orchestrator. +3. Stop using capacity when the lease or assignment ends. + +## Boundaries + +- Leased capacity only; this role does not issue or expand its own lease. +- It cannot change fleet roster membership, role authority, fleet configuration, or credentials. +- It cannot approve-to-land or merge. +- It does not displace the orchestrator's topology and lease authority. diff --git a/packages/mosaic/framework/fleet/roles/validator.md b/packages/mosaic/framework/fleet/roles/validator.md new file mode 100644 index 00000000..08a092a5 --- /dev/null +++ b/packages/mosaic/framework/fleet/roles/validator.md @@ -0,0 +1,16 @@ +# Validator — fleet role definition + +The **validator** (`class: validator`) is the independent final evidence seat. It examines the accepted requirements, test evidence, review record, and candidate head and may issue a validation certificate for that exact evidence set. + +## Mandate + +1. Validate acceptance evidence independently from the implementation author. +2. Issue or withhold a final validation certificate for the reviewed candidate. +3. Report missing, stale, or contradictory evidence without altering it. + +## Boundaries + +- **Certificate only:** the validator does not approve-to-land or merge. +- It does not replace correctness or security review. +- It does not write product code, mutate the roster, issue leases, or access credentials. +- A configured instance name such as Ultron is display data, never a class or authority source. diff --git a/packages/mosaic/framework/fleet/roster.schema.json b/packages/mosaic/framework/fleet/roster.schema.json index 43270758..8b21f61e 100644 --- a/packages/mosaic/framework/fleet/roster.schema.json +++ b/packages/mosaic/framework/fleet/roster.schema.json @@ -75,6 +75,14 @@ "type": "string", "pattern": "^[A-Za-z0-9_.-]+$" }, + "alias": { + "description": "Optional operator-defined display name for the agent.", + "type": "string" + }, + "provider": { + "description": "Optional agent runtime provider identifier such as openai-codex.", + "type": "string" + }, "runtime": { "type": "string" }, @@ -86,11 +94,11 @@ "type": "string" }, "ssh": { - "description": "SSH target (user@host) for a cross-host peer, so onboarding renders the `agent-send.sh -H ` form. Optional; only needed for agents on a different host than the fleet.", + "description": "Explicit SSH target (normally user@host) for a cross-host inventory peer. Exact comms rendering requires this whenever the peer's resolved host differs from the current agent's host; the host value is never substituted as an SSH destination.", "type": "string" }, "socket": { - "description": "tmux socket the agent's session runs on. Onboarding renders `-L ` when set; absent = the default socket (no `-L`). Must match the LIVE socket, not blindly inherit the roster's tmux.socket_name.", + "description": "Optional compatibility declaration of the fleet-wide tmux socket. When present it must exactly equal tmux.socket_name; independent per-agent sockets are rejected because the local fleet runtime provisions every session on the fleet-wide socket.", "type": "string" }, "working_directory": { @@ -105,6 +113,18 @@ "modelHint": { "type": "string" }, + "reasoning_level": { + "type": "string" + }, + "reasoningLevel": { + "type": "string" + }, + "tool_policy": { + "type": "string" + }, + "toolPolicy": { + "type": "string" + }, "persistent_persona": { "oneOf": [{ "type": "boolean" }, { "type": "string" }] }, @@ -130,29 +150,67 @@ "description": "Orchestrator chat connector (F4). Optional — absent means tmux (back-compat). Secrets (access/bot tokens) come from the environment, never this file.", "type": "object", "additionalProperties": false, - "required": ["kind"], - "properties": { - "kind": { - "enum": ["tmux", "discord", "matrix"] - }, - "matrix": { - "type": "object", - "additionalProperties": false, - "required": ["homeserver_url", "user_id", "room_id"], - "properties": { - "homeserver_url": { "type": "string" }, - "user_id": { "type": "string" }, - "room_id": { "type": "string" } + "oneOf": [ + { + "properties": { "kind": { "const": "tmux" } }, + "required": ["kind"], + "not": { + "anyOf": [{ "required": ["discord"] }, { "required": ["matrix"] }] } }, - "discord": { - "type": "object", - "additionalProperties": false, - "required": ["channel_id"], + { "properties": { - "channel_id": { "type": "string" } - } + "kind": { "const": "discord" }, + "discord": { + "type": "object", + "additionalProperties": false, + "required": ["channel_id"], + "properties": { + "channel_id": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + } + } + } + }, + "required": ["kind", "discord"], + "not": { "required": ["matrix"] } + }, + { + "properties": { + "kind": { "const": "matrix" }, + "matrix": { + "type": "object", + "additionalProperties": false, + "required": ["homeserver_url", "user_id", "room_id"], + "properties": { + "homeserver_url": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "user_id": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "room_id": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + } + } + } + }, + "required": ["kind", "matrix"], + "not": { "required": ["discord"] } } + ], + "properties": { + "kind": { "enum": ["tmux", "discord", "matrix"] }, + "matrix": { "type": "object" }, + "discord": { "type": "object" } } } } diff --git a/packages/mosaic/framework/fleet/services/operator-interaction.yaml b/packages/mosaic/framework/fleet/services/operator-interaction.yaml new file mode 100644 index 00000000..1d016fdc --- /dev/null +++ b/packages/mosaic/framework/fleet/services/operator-interaction.yaml @@ -0,0 +1,5 @@ +# Generic service policy. Provisioning supplies the agent name as data. +runtime: pi +model: openai/gpt-5.6-sol +reasoning: high +tool_policy: operator-interaction diff --git a/packages/mosaic/framework/framework-manifest.txt b/packages/mosaic/framework/framework-manifest.txt new file mode 100644 index 00000000..99320075 --- /dev/null +++ b/packages/mosaic/framework/framework-manifest.txt @@ -0,0 +1,90 @@ +# Mosaic framework path-ownership manifest — SSOT for the updater. +# +# This single file is the source of truth consumed by BOTH the bash installer +# (packages/mosaic/framework/install.sh) and the TypeScript config adapter +# (packages/mosaic/src/config/file-adapter.ts). A parity test asserts both +# paths resolve the same ownership from this file, so the two can never drift +# (the failure mode that #631 patched by hand in two places). +# +# Format: one glob per line, relative to the mosaic home (~/.config/mosaic). +# - Lines starting with '#' and blank lines are ignored. +# - '[framework]' / '[operator]' switch the active section. +# - '**' matches any depth; '*' matches within a single path segment. +# +# Ownership resolution for a path P (deny-wins / fail-safe): +# 1. P matches an [operator] glob -> operator-owned. +# 2. else P matches a [framework] glob -> framework-owned. +# 3. else (matches neither) -> OPERATOR-OWNED BY DEFAULT. +# +# Rule 3 is the root-cause fix for #791: a path the manifest authors never +# anticipated is protected because UNKNOWN defaults to operator. The updater +# may only ever create/overwrite framework-owned paths, and may only prune a +# framework-owned path that lives inside a shipped framework subtree and is +# absent from the current framework source (a genuinely retired file). +# Operator-owned and unknown paths are structurally unreachable by pruning. + +[framework] +# Top-level framework contract files (also reconciled from defaults/ on upgrade). +CONSTITUTION.md +AGENTS.md +STANDARDS.md +# Shipped framework subtrees — pruning is scoped to these roots. +adapters/** +constitution/** +CONTRIBUTING.md +defaults/** +examples/** +guides/** +# Shipped framework subtree — canonical skills are upgrade-reconciled. +skills/** +install.sh +install.ps1 +LICENSE +profiles/** +runtime/** +systemd/** +templates/** +tools/** +# Fleet: only the framework-seeded fleet subtrees are framework-owned. +fleet/README.md +fleet/examples/** +fleet/profiles/** +fleet/roles/** +fleet/roster.schema.json +fleet/services/** +# The manifest itself is framework-owned. +framework-manifest.txt + +[operator] +# Identity / user-seeded contract files — generated by the wizard or seeded +# once from defaults/, then owned by the operator. Never overwritten on upgrade. +SOUL.md +USER.md +TOOLS.md +# Local overlays (tighten-only) authored by the operator. +*.local.md +# Operator-owned trees the updater must never write over or prune. +agents/** +policy/** +memory/** +sources/** +credentials/** +# Operator-authored/customized skills live separately from canonical skills/ and +# must remain structurally unprunable even as skills/** is framework-owned. +skills-local/** +# Secret-bearing operator file INSIDE the framework-owned tools/ subtree. +# Listed explicitly so the deny-wins rule carves it out of tools/**. +tools/_lib/credentials.json +# Operator-owned fleet state (roster SSOT, per-agent env, heartbeats, backlog, +# persona overrides). Losing these silently downgrades a running fleet (#791). +fleet/roster.yaml +fleet/roster.json +fleet/agents/** +# Runtime state, incl. the #797 Runtime Session Ledger at fleet/run/sessions/ +# (events.ndjson journal + ledger.json projection). This carve-out is the +# mechanism that makes the ledger upgrade-safe: an upgrade that wiped it would +# defeat its reason to exist. The HARD GATE (test-upgrade-manifest-guard.sh) +# proves a populated ledger survives byte-identical + mtime-unchanged. +fleet/run/** +fleet/backlog/** +fleet/roles.local/** diff --git a/packages/mosaic/framework/guides/BOOTSTRAP.md b/packages/mosaic/framework/guides/BOOTSTRAP.md index d6b5c45d..edf93cbb 100755 --- a/packages/mosaic/framework/guides/BOOTSTRAP.md +++ b/packages/mosaic/framework/guides/BOOTSTRAP.md @@ -15,6 +15,22 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.) 7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges 8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention +## Agent Host Prerequisites + +Agent hosts must provide the Python runtime shape that runtime agents and +Mosaic automation assume is present. + +For Debian/Ubuntu hosts: + +```bash +sudo apt-get update +# #561: bare python invocations from agents must resolve. +sudo apt-get install -y python3 python-is-python3 +``` + +For non-Debian hosts, install the equivalent Python 3 runtime and ensure +`/usr/bin/python` resolves to `python3` (for example, via a managed symlink). + ## Quick Start ```bash diff --git a/packages/mosaic/framework/guides/CI-CD-PIPELINES.md b/packages/mosaic/framework/guides/CI-CD-PIPELINES.md index 3766b14c..9616f18b 100644 --- a/packages/mosaic/framework/guides/CI-CD-PIPELINES.md +++ b/packages/mosaic/framework/guides/CI-CD-PIPELINES.md @@ -868,6 +868,38 @@ steps: 7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main` 8. **Verify images appear** in Gitea Packages tab after successful pipeline +## Terminal-Green Full-Step Contract + +A successful pipeline summary is not sufficient: verification MUST consume the full JSON/API child-step record, including `clone`. + +```bash +PR_HEAD= +~/.config/mosaic/tools/woodpecker/pipeline-status.sh \ + -r mosaicstack/stack -n -f json \ + | ~/.config/mosaic/tools/woodpecker/verify-terminal-green.py \ + --expect-commit "$PR_HEAD" - +``` + +`PR_HEAD` MUST come from the current provider PR metadata and MUST be the full 40-hex head, not a local branch guess. The verifier fails if the argument is missing, malformed, absent from the pipeline record, or differs from that record. + +The verifier reports the expected and observed commits, total step count, state counts, anomalies, and any applied exemption. Exit `0` means the record satisfies the contract; exit `1` means the commit binding or at least one pipeline, workflow, or child-step state blocks terminal-green; exit `2` means the invocation or JSON input could not be verified. + +### Named interim exemption: `WP-K8S-1000-CI-POSTGRES-TEARDOWN` + +Only this exact conjunction is exempted: + +- pipeline and workflow state are `success`; +- exactly one non-success child exists; +- its name is `ci-postgres` and type is `service`; +- its state is `failure`, exit code is the JSON integer `0` (not boolean, float, string, or null); and +- its error exactly matches `pods "wp-svc--ci-postgres" not found`. + +Every near miss remains blocking, including non-zero service exits, startup failures, post-readiness crashes, connection errors, image-pull errors, skipped steps, another failed child, malformed pod names, duplicate matches, or a non-success pipeline/workflow. + +**Boundary in both directions:** this exemption recognizes the observed Woodpecker Kubernetes reconciliation miss after an otherwise-successful run. It does not prove that every future PostgreSQL or Kubernetes failure is distinguishable. It does prove, through provider controls, that a deterministic startup failure (`exit_code=1`) and an armed post-readiness postmaster crash (`exit_code=137`, dependent probe `Connection refused`) do not match and remain red. + +**Tracking and retirement:** [mosaicstack/stack#1000](https://git.mosaicstack.dev/mosaicstack/stack/issues/1000) owns the provider-seam fix. This exemption MUST be removed when #1000 is fixed. It is not authority to retry or re-trigger a pipeline, and no per-PR re-roll is part of the contract. + ## Post-Merge CI Monitoring (Hard Rule) For source-code delivery, completion is not allowed at "PR opened" stage. @@ -893,14 +925,16 @@ Woodpecker note: Before pushing a branch or merging a PR, guard against overlapping project pipelines: ```bash -~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main -~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main +~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push +~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha ``` Behavior: -- If pipeline state is running/queued/pending, wait until queue clears. -- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop. +- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero. +- Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero. +- Credential, transport, or provider unavailability is `CANNOT_ASSERT`: the guard emits a loud diagnostic and durable JSONL audit record. For push it exits 0 so recovery work is not bricked. For merge it returns distinct retryable exit 75 and holds until provider recovery; rerunning then self-clears without manual reset. This result is never evidence that CI was clear. If the audit cannot be written, the guard exits nonzero. +- `pr-merge.sh` resolves and guards the exact PR head repository and full SHA automatically, including fork PRs. ## Gitea as Unified Platform diff --git a/packages/mosaic/framework/guides/CODE-REVIEW.md b/packages/mosaic/framework/guides/CODE-REVIEW.md index 10ac1e8b..938915d8 100755 --- a/packages/mosaic/framework/guides/CODE-REVIEW.md +++ b/packages/mosaic/framework/guides/CODE-REVIEW.md @@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE): - PR target for delivery is `main`. - Direct pushes to `main` are prohibited. - Merge to `main` MUST be squash-only. -- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent). +- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent). ## Review Checklist diff --git a/packages/mosaic/framework/guides/E2E-DELIVERY.md b/packages/mosaic/framework/guides/E2E-DELIVERY.md index dbf40706..b41a34be 100644 --- a/packages/mosaic/framework/guides/E2E-DELIVERY.md +++ b/packages/mosaic/framework/guides/E2E-DELIVERY.md @@ -79,7 +79,7 @@ For implementation work, you MUST run this cycle in order: 8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 9. `push` - push immediately after queue guard passes. 10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers. -11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`. +11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha `. 12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow). 13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable). 14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes). @@ -93,8 +93,8 @@ For implementation work, you MUST run this cycle in order: > the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds > without asking. -1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main` -2. `~/.config/mosaic/tools/git/pr-merge.sh -n -m squash` +1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha ` +2. `~/.config/mosaic/tools/git/pr-merge.sh -n -m squash --expect-head ` 3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n ` 4. `~/.config/mosaic/tools/git/issue-close.sh -i ` (or close internal `docs/TASKS.md` ref when no provider exists) 5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop. diff --git a/packages/mosaic/framework/guides/ORCHESTRATOR.md b/packages/mosaic/framework/guides/ORCHESTRATOR.md index 81c8e5bd..076f7a6a 100644 --- a/packages/mosaic/framework/guides/ORCHESTRATOR.md +++ b/packages/mosaic/framework/guides/ORCHESTRATOR.md @@ -3,7 +3,7 @@ When spawning workers, include skill loading in the kickstart: ```bash -claude -p "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."codex exec "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..." +mosaic claude -p "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."codex exec "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..." ``` #### **MANDATORY** @@ -425,11 +425,11 @@ git push and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable. 13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks): - Before merging, run queue guard: - `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main` + `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha ` - Ensure PR exists for the task branch (create/update via wrappers if needed): `~/.config/mosaic/tools/git/pr-create.sh ... -B main` - Merge via wrapper: - `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` + `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` - Wait for terminal CI status: `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}` - Close linked issue after merge + green CI: @@ -630,7 +630,7 @@ Construct this from the task row and pass to worker via Task tool: **MANDATORY:** This ALWAYS includes linting. If the project has a linter configured (ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched. -Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below) +Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below) ## Git Scripts @@ -638,8 +638,9 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh): - `~/.config/mosaic/tools/git/issue-view.sh -i {N}` - `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main` -- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main` -- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` +- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}` +- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}` +- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` - `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}` - `~/.config/mosaic/tools/git/issue-close.sh -i {N}` diff --git a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md index 74ce461b..0eca6c5a 100644 --- a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md +++ b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md @@ -23,10 +23,12 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a # Milestones ~/.config/mosaic/tools/git/milestone-create.sh -# CI queue guard (required before push/merge) +# CI queue guard (required before push/merge; defaults to the checked-out branch) ~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge ``` +The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head ` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`. + ### Code Review (Codex) ```bash diff --git a/packages/mosaic/framework/guides/WAKE-DOCTRINE.md b/packages/mosaic/framework/guides/WAKE-DOCTRINE.md new file mode 100644 index 00000000..d95c2d60 --- /dev/null +++ b/packages/mosaic/framework/guides/WAKE-DOCTRINE.md @@ -0,0 +1,31 @@ +# Wake Doctrine + +This is the canonical fleet wake/heartbeat doctrine, extracted verbatim from the ratified +converged wake/heartbeat design (`docs/scratchpads/heartbeat-planning/CONVERGED-DESIGN.md`). It +governs when agents wake and how a wake is delivered, consumed, and retired. + +**Wake only on a real, un-consumed, lane-relevant obligation.** Fixed-interval heartbeats are +forbidden as the primary wake mechanism; they survive only as a **per-class fallback cadence** +bounded by urgency SLO, never as the steady state. + +**A digest is cumulative state since the last CONSUMED ack**, not an event delta. It is +self-orienting (who / lane / board-head) and decides the no-op case with **zero tool calls**. +Actionable facts are **claims-to-verify** carrying a **hard locator** (repo/issue#/SHA/file); +self-sufficiency never exempts a consequential action from its live gate. + +**Consumption is a consumer act, not a delivery act.** Split RECEIVED (delivery; `wake_id`-deduped) +from CONSUMED (durable capture of a contiguous prefix). Never ack-then-crash-before-capture. Acks +are local-write-only and cumulative; a turn never blocks on the network to ack. + +**Durability is unconditional; coalescing is optional.** Every delivered class is durably stored +and acked; only machine `digest` wakes coalesce. A parked or absent pane must never lose a human +or peer message. + +**Park is two-phase:** flush-and-checkpoint (recording the CONSUMED cursor) _before_ `/clear`. + +**Liveness is independent of work-triggering:** an off-host dead-man beacon, alarming on absence — +never a same-host sibling, never a pane scrape. + +**Retire the old net LAST:** run new alongside old, compare ledgers, and cut over only when the +per-host safety vector (no-op-rate ↓ AND canary-FN=0 AND source-parity-inventory-complete AND +reconcile=0 AND p95 event→CONSUMED≤SLO AND p95 event→qualified-action≤SLO) passes. diff --git a/packages/mosaic/framework/install.sh b/packages/mosaic/framework/install.sh index 223b58b1..7d262a24 100755 --- a/packages/mosaic/framework/install.sh +++ b/packages/mosaic/framework/install.sh @@ -1,5 +1,10 @@ #!/usr/bin/env bash -set -euo pipefail +# -E (errtrace): the ERR trap must propagate INTO functions and command +# substitutions. Without it the `trap restore_snapshot ERR` set below is dead +# code for any failure inside sync_framework_keep() (its whole body runs in a +# function) — a mid-sync failure would abort with a half-written target and NO +# rollback (#791 B1). Keep -E first so every later function inherits the trap. +set -Eeuo pipefail # ─── Mosaic Framework Installer ────────────────────────────────────────────── # @@ -13,38 +18,55 @@ set -euo pipefail # MOSAIC_INSTALL_MODE — prompt|keep|overwrite (default: prompt) # MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING — 1 to bypass MCP check # MOSAIC_SKIP_SKILLS_SYNC — 1 to skip skill sync +# +# Flags (CLI args, NOT environment variables — see #869 Point-1 C2): +# --allow-inactive-enforcement Explicit, per-invocation opt-out that lets the +# lease-enforcement hooks (mutator-gate.py, +# receipt-observer-client.py) be wired into +# ~/.claude/settings.json even when this host +# cannot confirm it can ACTIVATE them. Loud on +# use (see mosaic-link-runtime-assets). Default +# (flag absent) is fail-loud: the enforcement +# hooks are NOT wired and the framework's +# runtime-asset-link step reports a failure. # ────────────────────────────────────────────────────────────────────────────── SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}" INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}" -# Files/dirs protected from rsync --delete during sync. NOTE: framework-owned -# entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by -# reconcile_framework_files (overwrite + backup-once); the rest stay user-owned. -# User-created content in these paths survives rsync --delete. -# -# fleet/* — the framework SEEDS fleet/examples, fleet/roles, fleet/profiles, and -# fleet/roster.schema.json (synced normally — every fleet/roles/*.md role contract -# and fleet/profiles/*.yaml system-type profile lands automatically via this sync, -# so no per-file entry is needed; the preserved "fleet/*.yaml" glob is anchored to -# the top level only and does NOT shadow fleet/profiles/*.yaml). The user's -# own fleet files MUST -# survive `mosaic update` (which runs this sync automatically): the active -# roster (`fleet/roster.yaml` + any other `fleet/*.yaml`), per-agent env -# (`fleet/agents/`), heartbeat run dir (`fleet/run/`), and the Mosaic-native -# backlog-of-record store (`fleet/backlog/` — embedded PGlite data dir; see -# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update -# wipes the operator's fleet AND their backlog. Glob entries are honored by -# both the rsync path (`--exclude`) and the glob-aware cp fallback below. -# -# fleet/roles.local — the persona OVERRIDE layer (H4). Baseline personas in -# fleet/roles/ are reseeded normally on every update (delivering new baseline -# personas), so any local edit there would be clobbered. User customizations -# and user-ADDED personas instead live in fleet/roles.local/ and MUST survive -# `mosaic update` — they win over the baseline on merge (AC-NS-7; see -# packages/mosaic/src/commands/fleet-personas.ts). -PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local") +# Deliberately parsed from "$@" (a real, explicit, per-invocation argument) — +# never an environment variable — so this opt-out can never sit silently +# inherited in a shell profile. See #869 Point-1 C2. +ALLOW_INACTIVE_ENFORCEMENT=0 +# Component-scoped install (#892 W7): `install.sh --component ` runs an +# additive, self-contained component installer and EXITS — it never enters the +# full-framework sync below and never modifies framework-manifest ownership +# behavior (#869: the diff is ADDITIVE). Parsed as a two-token flag here. +COMPONENT="" +_prev_arg="" +for _arg in "$@"; do + case "$_arg" in + --allow-inactive-enforcement) ALLOW_INACTIVE_ENFORCEMENT=1 ;; + --component=*) COMPONENT="${_arg#--component=}" ;; + esac + [[ "$_prev_arg" == "--component" ]] && COMPONENT="$_arg" + _prev_arg="$_arg" +done + +# Shared framework path-ownership manifest reader (#791). Parity with +# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt. +# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0). +# shellcheck source=tools/_lib/manifest.sh +source "$SOURCE_DIR/tools/_lib/manifest.sh" + +# Which paths a keep-mode upgrade may touch is no longer a hand-maintained +# denylist. It is derived from the shared framework-manifest.txt (#791): the +# updater only ever creates/overwrites framework-owned paths and only prunes a +# retired framework file inside a shipped framework subtree. Everything else — +# every operator file, and every path the manifest never anticipated — is +# operator-owned by default (fail-safe) and is never written or deleted. See +# sync_framework_keep() below and packages/mosaic/src/framework/manifest.ts. # Framework-owned contract files: re-copied from defaults/ on every upgrade (the # user must not edit them; a divergent copy is backed up once before overwrite). @@ -75,17 +97,267 @@ step() { echo -e "\n${BOLD}$1${RESET}"; } SNAPSHOT_DIR="" make_snapshot() { is_existing_install || return 0 + # mktemp -d creates the dir 0700 — the snapshot (which mirrors operator config, + # possibly including secrets) is never world-readable. SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-snapshot-XXXXXX")" - cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/" 2>/dev/null || true + # The snapshot MUST be complete: restore rebuilds the target from it, so a + # partial capture (unreadable file, disk-full, I/O error) would silently + # discard whatever it missed. If cp -a cannot copy the whole tree, abort NOW — + # before the restore trap is armed and before anything is mutated. Fail closed + # rather than proceed with a snapshot we cannot trust (#791 blocker-2). + if ! cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/"; then + fail "Could not capture a complete pre-upgrade snapshot of $TARGET_DIR — aborting before any changes were made (fail-closed)." + rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR="" + exit 1 + fi } restore_snapshot() { + # Disarm the trap first: restore runs under `set -e`, and a non-zero step + # inside it must not re-enter this handler (errtrace makes ERR fire in + # functions now). One restore attempt, then let the script exit non-zero. + trap - ERR INT TERM [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] || return 0 fail "Install interrupted/failed — restoring previous state from snapshot" - rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR" - cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/" 2>/dev/null || true + # Reset the target before rebuilding from the snapshot — but CHECK it. Under + # `set -e` (trap already disarmed) a bare `rm -rf; mkdir -p` that fails would + # exit the whole script immediately, after `rm` may have deleted part of the + # target, WITHOUT ever printing the recovery pointer below — the operator would + # be left with a half-removed target and no idea the snapshot survives in /tmp. + # Test the reset explicitly (like the cp -a below), and on failure keep the + # snapshot and tell the operator where it is (#791 blocker-D2). + if ! rm -rf "$TARGET_DIR" || ! mkdir -p "$TARGET_DIR"; then + fail "Snapshot restore could not reset $TARGET_DIR. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually." + return 1 + fi + # Surface an incomplete restore instead of swallowing it: the snapshot is the + # last good copy, so if cp cannot fully rebuild the target we must NOT delete + # the snapshot — point the operator at it for manual recovery (#791 blocker-2). + if ! cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/"; then + fail "Snapshot restore did not complete cleanly. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually." + return 1 + fi } cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; } +# ─── durable operator-config snapshot (#791 PR2) ───────────────────────────── +# A SECOND, independent safety layer, distinct from SNAPSHOT_DIR above: +# • SNAPSHOT_DIR is ephemeral (/tmp, deleted on success) and mirrors the WHOLE +# target for CRASH rollback if the sync aborts mid-write. +# • DURABLE_SNAPSHOT_DIR is RETAINED, holds only the operator-owned surface, and +# lives OUTSIDE the framework tree and any repo. It exists for the failure the +# crash-rollback cannot see: a sync that finishes "successfully" yet a +# manifest/logic bug let it modify an operator file. verify_operator_surface() +# (post-sync) heals from it; `mosaic restore` recovers from it days later. +# Path convention is mirrored in packages/mosaic/src/commands/restore.ts — keep +# the two in sync (there is no shared code across the bash/TS boundary). +DURABLE_SNAPSHOT_DIR="" +backup_root() { printf '%s/mosaic/backups' "${XDG_STATE_HOME:-$HOME/.local/state}"; } + +# Relative paths that a migration INTENTIONALLY removes from the target (e.g. the +# legacy bin/ tree). Such a path is operator-classified by the manifest (unknown⇒ +# operator), so the durable snapshot captures it — but its post-migration absence +# is correct, NOT a manifest bug. run_migrations() records each removal here so +# verify_operator_surface() does not "heal" it back and silently undo the +# migration (which would then be skipped forever once the version is stamped). +MIGRATION_REMOVED_PATHS=() + +# True (0) if $1 (a path relative to TARGET_DIR) equals or lives under a path a +# migration deliberately removed this run. +is_migration_removed() { + local rel="$1" removed + for removed in ${MIGRATION_REMOVED_PATHS[@]+"${MIGRATION_REMOVED_PATHS[@]}"}; do + [[ -n "$removed" ]] || continue + [[ "$rel" == "$removed" || "$rel" == "$removed"/* ]] && return 0 + done + return 1 +} + +# True (0) if any parent directory of $1 (relative to TARGET_DIR) is a symlink. +# Restoring THROUGH a symlinked ancestor would let cp write snapshot contents — +# possibly secrets — outside the target (CWE-59), so the verify net refuses it. +has_symlinked_parent() { + local rel="$1" dir p seg + dir="$(dirname "$rel")" + [[ "$dir" == "." ]] && return 1 + p="$TARGET_DIR" + local IFS='/' + for seg in $dir; do + [[ -n "$seg" ]] || continue + p="$p/$seg" + [[ -L "$p" ]] && return 0 + done + return 1 +} + +# Emit (NUL-delimited, into file $1) the operator-owned relative paths that exist +# under TARGET_DIR, classified via the shared manifest (deny-wins; unknown⇒ +# operator). Returns non-zero if the filesystem walk itself failed — we must +# NEVER snapshot from a truncated scan (a `< <(find …)` process substitution +# would hide that error; capture-then-check does not — cf. #791 blocker-D1). +enumerate_operator_files() { + local out="$1" scan abs rel + scan="$(mktemp)" + if ! find "$TARGET_DIR" -type f -print0 > "$scan"; then + rm -f "$scan" + return 1 # OP-SCAN-GUARD + fi + : > "$out" + while IFS= read -r -d '' abs; do + rel="${abs#"$TARGET_DIR"/}" + # Not operator config: version marker and any VCS metadata. + case "$rel" in .framework-version|.git|.git/*) continue ;; esac + manifest_is_framework "$rel" || printf '%s\0' "$rel" >> "$out" + done < "$scan" + rm -f "$scan" +} + +# Retain only the newest MOSAIC_BACKUP_RETENTION (default 5) snapshots. The +# pre-update- names sort lexicographically = chronologically, so a +# reverse sort is newest-first. Pruning failures are non-fatal (they only leave +# extra old backups); the enclosing find's status is still honored, not swallowed. +prune_durable_snapshots() { + local root keep list d i=0 + root="$(backup_root)" + keep="${MOSAIC_BACKUP_RETENTION:-5}" + [[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5 + list="$(mktemp)" + if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then + rm -f "$list"; return 0 + fi + # Newest-first ordering needs `sort` (`-o` writes back in place — no `mv` + # dependency); if it is somehow unavailable, leave the backups untouched rather + # than risk pruning in an undefined order. + if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then + rm -f "$list"; return 0 + fi + while IFS= read -r d; do + [[ -n "$d" ]] || continue + i=$((i + 1)) + (( i > keep )) && rm -rf "$d" + done < "$list" + rm -f "$list" +} + +# Take the durable pre-update snapshot BEFORE any mutation. Fail-OPEN: the durable +# snapshot is a recovery bonus on top of the manifest (which already keeps the +# sync out of operator paths) and the crash-rollback — so an un-writable backup +# location warns and continues rather than blocking the upgrade. Everything it +# creates is private (umask 077 + explicit 0700 dirs / 0600 files): the snapshot +# mirrors operator config, which may hold secrets, and must never be world-readable. +make_durable_snapshot() { + is_existing_install || return 0 + local root ts dir list rel src dst count=0 old_umask + root="$(backup_root)" + # Fail-open if we cannot even stamp a timestamp: the durable snapshot is a + # recovery bonus and must never be the thing that aborts an upgrade. + ts="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || true)" + if [[ -z "$ts" ]]; then + warn "Durable snapshot skipped: no UTC timestamp available (upgrade continues)." + return 0 + fi + # umask 077 makes every dir/file the snapshot creates private from birth (it + # mirrors operator config, which may hold secrets). It is PROCESS-global, so we + # save and restore it around exactly this block — otherwise every later sync + # copy and new framework dir would inherit 0600/0700 instead of 0644/0755. + old_umask="$(umask)" + umask 077 + if ! mkdir -p "$root"; then + umask "$old_umask" + warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)." + return 0 + fi + chmod 700 "$root" 2>/dev/null || true + dir="$root/pre-update-$ts" + if [[ -e "$dir" ]]; then # same-second re-run: disambiguate + local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n" + fi + if ! mkdir -p "$dir"; then + umask "$old_umask" + warn "Durable snapshot skipped: cannot create $dir (upgrade continues)." + return 0 + fi + chmod 700 "$dir" + list="$(mktemp)" + if ! enumerate_operator_files "$list"; then + umask "$old_umask" + warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)." + rm -f "$list"; rmdir "$dir" 2>/dev/null || true + return 0 + fi + while IFS= read -r -d '' rel; do + src="$TARGET_DIR/$rel"; dst="$dir/$rel" + [[ -f "$src" ]] || continue + mkdir -p "$(dirname "$dst")" + if ! cp "$src" "$dst"; then + warn "Durable snapshot: could not copy operator file '$rel' (skipped)." + continue + fi + chmod 600 "$dst" 2>/dev/null || true + count=$((count + 1)) + done < "$list" + rm -f "$list" + # Tighten every dir the copy created (mkdir -p honors umask, but be explicit). + find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true + umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above) + DURABLE_SNAPSHOT_DIR="$dir" + ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)" + prune_durable_snapshots +} + +# Post-sync safety net: a keep-mode upgrade must NEVER modify an operator file. +# Compare every file in the durable snapshot to its current target counterpart; +# any that changed (or vanished) was touched by a framework bug — restore it from +# the snapshot and warn loudly. This does NOT abort: the framework itself synced +# correctly; we only heal the operator collateral. Runs after the restore trap is +# disarmed so its corrective copies can't spuriously trip a full rollback, and +# every step is guarded so `set -e` cannot exit silently mid-heal (cf. blocker-D2). +verify_operator_surface() { + [[ -n "$DURABLE_SNAPSHOT_DIR" && -d "$DURABLE_SNAPSHOT_DIR" ]] || return 0 + local scan snap rel cur healed=0 + scan="$(mktemp)" + if ! find "$DURABLE_SNAPSHOT_DIR" -type f -print0 > "$scan"; then + rm -f "$scan" + warn "Post-upgrade verify skipped: could not enumerate the pre-update snapshot at $DURABLE_SNAPSHOT_DIR." + return 0 + fi + while IFS= read -r -d '' snap; do + rel="${snap#"$DURABLE_SNAPSHOT_DIR"/}" + cur="$TARGET_DIR/$rel" + # A migration may legitimately delete an operator-classified path (e.g. legacy + # bin/). Its absence is intended — do not heal it back, or the migration is + # silently undone and never re-runs once the version is stamped (#791 PR2). + is_migration_removed "$rel" && continue # MIGRATION-SKIP-GUARD + if [[ ! -e "$cur" ]] || ! cmp -s "$snap" "$cur"; then + # Never restore THROUGH a symlink: an operator path swapped for a link would + # otherwise let cp write snapshot contents (possibly secrets) outside the + # target (CWE-59). Refuse a symlinked parent; drop a symlinked leaf and write + # a real file in its place. + if has_symlinked_parent "$rel"; then + warn "Operator path '$rel' has a symlinked parent under $TARGET_DIR; refusing to restore through it (possible tampering) — recover it manually from $DURABLE_SNAPSHOT_DIR." + continue + fi + [[ -L "$cur" ]] && rm -f "$cur" # SYMLINK-LEAF-GUARD + # Guard mkdir too: under set -e (trap already disarmed) a bare failure would + # exit the whole installer before the recovery pointer below is emitted. + if ! mkdir -p "$(dirname "$cur")"; then + warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored (parent dir unavailable) — recover it manually from $DURABLE_SNAPSHOT_DIR." + continue + fi + if cp "$snap" "$cur"; then + chmod 600 "$cur" 2>/dev/null || true + warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel" + healed=$((healed + 1)) + else + warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored — recover it manually from $DURABLE_SNAPSHOT_DIR." + fi + fi + done < "$scan" + rm -f "$scan" + if (( healed > 0 )); then + warn "$healed operator file(s) were unexpectedly changed by this upgrade and were restored from the pre-update snapshot. A keep-mode upgrade must never modify operator files — this indicates a framework manifest bug; please report it (#791)." + fi +} + # Reconcile contract files after sync: framework-owned overwrite (backup-once), # user-seeded seed-if-absent. reconcile_framework_files() { @@ -184,63 +456,105 @@ sync_framework() { return fi - if command -v rsync >/dev/null 2>&1; then - local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak") - - if [[ "$INSTALL_MODE" == "keep" ]]; then - # Anchor to the transfer root (leading /) so we preserve the TOP-LEVEL - # ~/.config/mosaic/ without also excluding defaults/ from sync - # (reconcile_framework_files needs the freshly-synced defaults/ copies). - for path in "${PRESERVE_PATHS[@]}"; do - rsync_args+=(--exclude "/$path") - done - fi - - rsync "${rsync_args[@]}" "$SOURCE_DIR/" "$TARGET_DIR/" + if [[ "$INSTALL_MODE" == "keep" ]]; then + # The `mosaic update` path. Manifest-driven, never-deleting-outside-framework: + # operator config is structurally protected (#791). No rsync --delete here. + # The manifest is already loaded+validated in main() BEFORE the snapshot/trap + # (a fail-closed manifest must abort without ever restoring over operator + # files — see the pre-flight in main, #791 blocker-1). + sync_framework_keep return fi - # Fallback: cp-based sync. Glob-aware so entries like "fleet/*.yaml" preserve - # every matching user file (parity with the rsync --exclude path above). - local preserve_tmp="" - if [[ "$INSTALL_MODE" == "keep" ]]; then - preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")" - local match rel - for path in "${PRESERVE_PATHS[@]}"; do - # Unquoted $path lets the glob expand against TARGET_DIR; nullglob makes a - # non-matching pattern vanish instead of staying literal. - shopt -s nullglob - for match in "$TARGET_DIR/"$path; do - [[ -e "$match" ]] || continue - rel="${match#"$TARGET_DIR/"}" - mkdir -p "$preserve_tmp/$(dirname "$rel")" - cp -R "$match" "$preserve_tmp/$rel" - done - shopt -u nullglob - done - fi + # overwrite mode — a full replace, chosen only for a fresh install or when the + # operator explicitly asks to replace everything. No operator state to protect. + sync_framework_overwrite +} - find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" -exec rm -rf {} + +# Enumerate a NUL-delimited file list via `find` into the temp file $1, failing +# CLOSED if find errors. We capture to a checked file instead of consuming +# `< <(find …)` directly because a process substitution discards the producer's +# exit status: an EACCES/I/O failure partway through a scan would truncate the +# list yet leave the reading `while` loop exiting 0, so a partial upgrade would +# commit and report success and the ERR/restore trap would never fire. Running +# find to completion first, then checking its status, turns that silent +# truncation into a fail-closed abort that the restore trap can act on (#791 +# blocker-D1). $1 after the shift is the scan root — named in the error. +_scan_or_die() { + local out="$1"; shift + if ! find "$@" -print0 > "$out"; then + fail "Could not enumerate framework files under '$1' — aborting before committing an incomplete sync (fail-closed)." + return 1 # D1-GUARD + fi +} + +# Keep-mode sync: create/refresh framework-owned files and prune only retired +# framework files inside shipped framework subtrees. Operator-owned and unknown +# paths (fail-safe default) are never written and never deleted — the #791 HARD +# GATE. Single code path (no rsync) so it is byte-for-byte parity-testable. +sync_framework_keep() { + local src="$SOURCE_DIR" dst="$TARGET_DIR" abs rel root list + + # 1) Overlay copy — every framework-owned source file, refreshed only when its + # bytes changed (no mtime churn on unchanged files, never on operator files). + # The source scan is captured fail-closed (#791 blocker-D1): a find failure + # aborts the sync (→ ERR trap → restore) rather than silently truncating it. + list="$(mktemp)" + _scan_or_die "$list" "$src" -type f || { rm -f "$list"; return 1; } + while IFS= read -r -d '' abs; do + rel="${abs#"$src"/}" + case "$rel" in + .git|.git/*|.framework-version|*.pre-constitution.bak) continue ;; + esac + manifest_is_framework "$rel" || continue + if [[ -f "$dst/$rel" ]] && cmp -s "$abs" "$dst/$rel"; then continue; fi + [[ "$rel" == */* ]] && mkdir -p "$dst/${rel%/*}" + cp "$abs" "$dst/$rel" + done < "$list" + rm -f "$list" + + # 2) Scoped prune — within each shipped framework subtree root, remove + # framework-owned target files the current source no longer ships. Operator + # carve-outs (e.g. tools/_lib/credentials.json) resolve to operator and are + # skipped; unknown paths resolve to operator too — both are unreachable here. + # Each subtree scan is captured fail-closed for the same reason as the copy. + while IFS= read -r root; do + [[ -n "$root" && -d "$dst/$root" ]] || continue + list="$(mktemp)" + _scan_or_die "$list" "$dst/$root" -type f || { rm -f "$list"; return 1; } + while IFS= read -r -d '' abs; do + rel="${abs#"$dst"/}" + case "$rel" in *.pre-constitution.bak) continue ;; esac + [[ -f "$src/$rel" ]] && continue # still shipped + manifest_is_framework "$rel" || continue + rm -f "$abs" + done < "$list" + rm -f "$list" + # Drop framework dirs left empty by the prune (never touches a dir that still + # holds an operator file — those are never emptied). A genuine find failure + # (unreadable dir) is surfaced as a warning rather than silently swallowed; + # the "directory not empty" races we tolerate are ignored via -delete's own + # rc, not by hiding stderr — so a real error is still visible to the operator. + if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then + warn "prune: could not fully sweep empty framework dirs under $root (left as-is)" + fi + done < <(manifest_subtree_roots) +} + +# Overwrite-mode sync: full replace. Only reached for a fresh install or an +# explicit operator "replace everything" choice, so nothing is preserved. +sync_framework_overwrite() { + if command -v rsync >/dev/null 2>&1; then + rsync -a --delete \ + --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak" \ + "$SOURCE_DIR/" "$TARGET_DIR/" + return + fi + find "$TARGET_DIR" -mindepth 1 -maxdepth 1 \ + ! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" \ + -exec rm -rf {} + cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/ rm -rf "$TARGET_DIR/.git" - - if [[ -n "$preserve_tmp" ]]; then - # Restore by re-globbing the SAME patterns against preserve_tmp, so each - # preserved item is restored at its own relative path (e.g. only - # fleet/roster.yaml is replaced — the freshly-synced fleet/examples stays). - for path in "${PRESERVE_PATHS[@]}"; do - shopt -s nullglob - for match in "$preserve_tmp/"$path; do - [[ -e "$match" ]] || continue - rel="${match#"$preserve_tmp/"}" - rm -rf "$TARGET_DIR/$rel" - mkdir -p "$TARGET_DIR/$(dirname "$rel")" - cp -R "$match" "$TARGET_DIR/$rel" - done - shopt -u nullglob - done - rm -rf "$preserve_tmp" - fi } # ═══════════════════════════════════════════════════════════════════════════════ @@ -261,6 +575,10 @@ run_migrations() { # Remove bin/ directory — all executables now live in the npm CLI. # Scripts that were in bin/ are now in tools/_scripts/. if [[ "$from_version" -lt 2 ]]; then + # bin/ and the rails symlink are operator-classified by the manifest (unknown⇒ + # operator) and thus captured in the durable snapshot; record them as + # intentional removals so the post-sync verify net does not restore them. + MIGRATION_REMOVED_PATHS+=("bin" "rails") if [[ -d "$TARGET_DIR/bin" ]]; then ok "Removing legacy bin/ directory (executables now in npm CLI)" rm -rf "$TARGET_DIR/bin" @@ -296,6 +614,46 @@ run_migrations() { fi } +# ═══════════════════════════════════════════════════════════════════════════════ +# Component-scoped install (#892 W7) — additive early dispatch. +# ═══════════════════════════════════════════════════════════════════════════════ +# `install.sh --component ` delegates to the component's own idempotent, +# fail-closed installer and EXITS. This path is ADDITIVE (#869): it does NOT run +# the full-framework sync, does NOT alter framework-manifest ownership behavior, +# and touches NOTHING the #869 install-ordering-guard covers (no runtime-asset +# linking, no lease-enforcement hook wiring). Each component installer is +# INTERSECTED-AND-VALIDATED against the single SSOT framework-manifest.txt, so a +# component manifest can never authorize a write outside framework ownership. +run_component_install() { + local name="$1" + case "$name" in + wake) + local wi="$SOURCE_DIR/tools/wake/wake-install.sh" + if [[ ! -x "$wi" && ! -f "$wi" ]]; then + fail "Component 'wake' installer not found at $wi" + exit 1 + fi + step "Installing Mosaic component: wake" + WAKE_INSTALL_SOURCE="$SOURCE_DIR" WAKE_INSTALL_TARGET="$TARGET_DIR" \ + bash "$wi" install + ;; + "") + fail "--component requires a name (e.g. --component wake)." + exit 1 + ;; + *) + fail "Unknown component '$name'. Supported: wake." + exit 1 + ;; + esac +} + +if [[ -n "$COMPONENT" ]]; then + mkdir -p "$TARGET_DIR" + run_component_install "$COMPONENT" + exit 0 +fi + # ═══════════════════════════════════════════════════════════════════════════════ # Main # ═══════════════════════════════════════════════════════════════════════════════ @@ -311,9 +669,26 @@ else ok "Install mode: overwrite" fi +# Pre-flight (keep mode): load + validate the framework manifest BEFORE taking a +# snapshot or arming the restore trap. A fail-closed manifest (missing / empty / +# malformed) must abort here WITHOUT deleting or restoring over operator files — +# the snapshot/restore path exists only for a genuine mid-sync mutation failure, +# not for a validation failure that has touched nothing yet (#791 blocker-1). +if [[ "$INSTALL_MODE" == "keep" ]]; then + manifest_load + # Durable, operator-scoped backup taken BEFORE any mutation (#791 PR2). Kept + # outside the framework tree; recovered later via `mosaic restore`. Fail-open. + make_durable_snapshot +fi + # Snapshot before any destructive file operation; restore on interrupt/failure. +# The trap MUST exit after restoring: a bash INT/TERM handler that merely returns +# does NOT terminate the script — execution would resume past the interrupt, +# clear the snapshot, and report success, leaving a partial post-interrupt update +# (#791 blocker-A). `restore_snapshot; exit 1` guarantees a non-zero exit for +# both the errtrace (ERR) and signal (INT/TERM) paths. make_snapshot -trap 'restore_snapshot' ERR INT TERM +trap 'restore_snapshot; exit 1' ERR INT TERM sync_framework @@ -334,6 +709,10 @@ reconcile_framework_files # Ensure tool scripts are executable find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true +# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh +# suffix — git resolves credential helpers by exact name/path, not extension — +# so the *.sh glob above does not cover it; chmod it explicitly. +[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true ok "Framework synced to $TARGET_DIR" @@ -342,6 +721,10 @@ run_migrations # File-system phase complete and consistent — clear the restore trap. trap - ERR INT TERM +# Post-sync safety net: heal any operator file a manifest bug let the sync touch, +# using the durable pre-update snapshot (#791 PR2). Runs with the trap disarmed so +# a corrective copy can't spuriously trigger a full rollback. +verify_operator_surface # VERIFY-NET (#791 PR2) cleanup_snapshot # Testability / minimal-install hook: stop after the file-system phase, before any @@ -357,10 +740,15 @@ step "Post-install tasks" SCRIPTS="$TARGET_DIR/tools/_scripts" if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then - if "$SCRIPTS/mosaic-link-runtime-assets" >/dev/null 2>&1; then + link_args=() + [[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement) + # stdout is suppressed as before, but stderr is left connected: the + # install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach + # the operator, not be swallowed silently. + if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then ok "Runtime assets linked" else - warn "Runtime asset linking failed (non-fatal)" + warn "Runtime asset linking failed (non-fatal) — see message above for details." fi fi diff --git a/packages/mosaic/framework/runtime/claude/settings.json b/packages/mosaic/framework/runtime/claude/settings.json index 0318d9e7..eada96fe 100644 --- a/packages/mosaic/framework/runtime/claude/settings.json +++ b/packages/mosaic/framework/runtime/claude/settings.json @@ -1,7 +1,48 @@ { "model": "opus", "hooks": { + "PreCompact": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact" + } + ] + } + ], + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact" + } + ] + }, + { + "matcher": "resume|clear", + "hooks": [ + { + "type": "command", + "command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation" + } + ] + } + ], "PreToolUse": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py", + "timeout": 3 + } + ] + }, { "matcher": "Write|Edit|MultiEdit", "hooks": [ @@ -38,6 +79,11 @@ "Stop": [ { "hooks": [ + { + "type": "command", + "command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry", + "timeout": 3 + }, { "type": "command", "command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh", diff --git a/packages/mosaic/framework/runtime/pi/lease-lifecycle.ts b/packages/mosaic/framework/runtime/pi/lease-lifecycle.ts new file mode 100644 index 00000000..f637558a --- /dev/null +++ b/packages/mosaic/framework/runtime/pi/lease-lifecycle.ts @@ -0,0 +1,93 @@ +export type LeaseLifecycleRunner = (args: string[]) => boolean; + +type LifecycleEvent = { + reason?: unknown; + toolName?: unknown; +}; + +type LifecycleHandler = ( + event: LifecycleEvent, + context: Record, +) => unknown | Promise; + +export interface LeaseLifecyclePiApi { + on(event: string, handler: LifecycleHandler): void; +} + +const ROLLOVER_REASONS = new Set(['reload', 'new', 'resume', 'fork']); + +function eventReason(event: LifecycleEvent): string { + return typeof event.reason === 'string' && event.reason.length > 0 ? event.reason : 'unknown'; +} + +/** + * Register redundant Pi compaction observers and same-PID generation rollover. + * + * A failed pre-compaction observer cancels compaction. A failed post-compaction + * observer or generation rollover locally blocks later tools in addition to the + * broker-backed all-tools gate. + */ +export function registerLeaseLifecycleHooks( + pi: LeaseLifecyclePiApi, + runRevoker: LeaseLifecycleRunner, +): void { + let postCompactReason: string | null = null; + let postCompactFailure = false; + let rolloverFailure = false; + + pi.on('session_before_compact', async (event) => { + const reason = eventReason(event); + const revoked = runRevoker([ + '--runtime', + 'pi', + '--reason', + `pi-session-before-compact:${reason}`, + ]); + if (!revoked) return { cancel: true }; + return undefined; + }); + + pi.on('session_compact', async (event) => { + postCompactReason = eventReason(event); + }); + + pi.on('context', async () => { + if (postCompactReason === null) return undefined; + const reason = postCompactReason; + const revoked = runRevoker([ + '--runtime', + 'pi', + '--reason', + `pi-context-after-compact:${reason}`, + ]); + if (revoked) { + postCompactReason = null; + postCompactFailure = false; + } else { + postCompactFailure = true; + } + return undefined; + }); + + pi.on('session_start', async (event) => { + const reason = eventReason(event); + if (!ROLLOVER_REASONS.has(reason)) return undefined; + const revoked = runRevoker([ + '--runtime', + 'pi', + '--reason', + `pi-session-start:${reason}`, + '--bump-generation', + ]); + rolloverFailure = !revoked; + return undefined; + }); + + pi.on('tool_call', async () => { + if (!postCompactFailure && !rolloverFailure) return undefined; + return { + block: true, + reason: 'BLOCKED: Mosaic lease lifecycle revoke failed; runtime remains UNVERIFIED.', + }; + }); +} diff --git a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts index cbf783fc..10756ffe 100644 --- a/packages/mosaic/framework/runtime/pi/mosaic-extension.ts +++ b/packages/mosaic/framework/runtime/pi/mosaic-extension.ts @@ -22,12 +22,23 @@ import { import { join, basename } from 'node:path'; import { homedir } from 'node:os'; import { execSync, spawnSync } from 'node:child_process'; +import { registerLeaseLifecycleHooks, type LeaseLifecyclePiApi } from './lease-lifecycle.js'; // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); +const MUTATOR_GATE = join(MOSAIC_HOME, 'tools', 'lease-broker', 'mutator-gate.py'); +const LEASE_REVOKER = join(MOSAIC_HOME, 'tools', 'lease-broker', 'revoke-lease.py'); +const RECOVERY_COMMAND = join(MOSAIC_HOME, 'tools', 'lease-broker', 'recover-context.py'); +const RECEIPT_OBSERVER_CLIENT = join( + MOSAIC_HOME, + 'tools', + 'lease-broker', + 'receipt-observer-client.py', +); +const RECOVERY_TOOL = 'mosaic_context_recover'; // --------------------------------------------------------------------------- // Helpers @@ -106,6 +117,104 @@ function nowIso(): string { return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); } +function runPiLeaseRevoker(args: string[]): boolean { + const result = spawnSync('python3', [LEASE_REVOKER, ...args], { + encoding: 'utf8', + timeout: 2_000, + env: process.env, + }); + return result.status === 0; +} + +function checkPiMutatorGate(toolName: string): { block: true; reason: string } | undefined { + const result = spawnSync('python3', [MUTATOR_GATE, '--runtime', 'pi'], { + input: `${JSON.stringify({ tool_name: toolName })}\n`, + encoding: 'utf8', + timeout: 2_000, + env: process.env, + }); + if (result.status === 0) return undefined; + const detail = String(result.stderr ?? '') + .trim() + .split('\n')[0]; + return { + block: true, + reason: detail || 'BLOCKED: Mosaic mutator gate is unavailable or the lease is UNVERIFIED.', + }; +} + +function checkPiRecoveryGate(): { block: true; reason: string } | undefined { + return checkPiMutatorGate(RECOVERY_TOOL); +} + +function assistantMessageText(message: unknown): string | undefined { + if (typeof message !== 'object' || message === null) return undefined; + const value = message as { role?: unknown; content?: unknown }; + if (value.role !== 'assistant') return undefined; + if (typeof value.content === 'string') return value.content; + if (!Array.isArray(value.content)) return undefined; + const text: string[] = []; + for (const part of value.content) { + if (typeof part !== 'object' || part === null) return undefined; + const typed = part as { type?: unknown; text?: unknown }; + if (typed.type !== 'text' || typeof typed.text !== 'string') return undefined; + text.push(typed.text); + } + return text.join(''); +} + +function recordPiMessageEnd(message: unknown): void { + const latestAssistantMessage = assistantMessageText(message); + if (latestAssistantMessage === undefined) return; + // This sends finalized Pi message_end content only to the daemon-owned + // authenticated observer transport, never to the public broker request API. + spawnSync('python3', [RECEIPT_OBSERVER_CLIENT, '--runtime', 'pi'], { + input: `${JSON.stringify({ latest_assistant_message: latestAssistantMessage })}\n`, + encoding: 'utf8', + timeout: 2_000, + env: process.env, + }); +} + +function runPiRecoveryCommand(params: { + phase: 'begin' | 'complete'; + construction?: string; + compactionEpoch?: number; + requestEpoch?: number; +}): { content: Array<{ type: 'text'; text: string }> } { + const args = [RECOVERY_COMMAND, params.phase]; + if (params.phase === 'begin') { + if ( + typeof params.construction !== 'string' || + !Number.isInteger(params.compactionEpoch) || + !Number.isInteger(params.requestEpoch) || + params.compactionEpoch < 0 || + params.requestEpoch < 0 + ) { + return { + content: [ + { type: 'text', text: 'Recovery begin requires construction and non-negative epochs.' }, + ], + }; + } + args.push( + '--construction', + params.construction, + '--compaction-epoch', + String(params.compactionEpoch), + '--request-epoch', + String(params.requestEpoch), + ); + } + const result = spawnSync('python3', args, { + encoding: 'utf8', + timeout: 3_000, + env: process.env, + }); + const output = result.status === 0 ? String(result.stdout ?? '') : String(result.stderr ?? ''); + return { content: [{ type: 'text', text: output || 'Constrained recovery refused.' }] }; +} + // --------------------------------------------------------------------------- // Mission detection // --------------------------------------------------------------------------- @@ -250,6 +359,40 @@ export default function register(pi: ExtensionAPI) { let hbModel: string | null = null; let hbTimer: ReturnType | null = null; + // ── Compaction observers and same-PID generation rollover ───────────── + registerLeaseLifecycleHooks(pi as unknown as LeaseLifecyclePiApi, runPiLeaseRevoker); + + // ── Whole mutator-class authorization gate ──────────────────────────── + // Every Pi tool, including unknown/custom tools, reaches the broker-backed + // class gate before execution. Broker/script failure blocks fail-closed. + pi.on('tool_call', async (event) => checkPiMutatorGate(event.toolName)); + + // Pi records only a finalized assistant entry at message_end. It never uses + // after_provider_response, which occurs before stream consumption. + pi.on('message_end', async (event) => { + recordPiMessageEnd((event as unknown as { message?: unknown }).message); + }); + + // The recovery custom tool is the only Pi invocation that maps to the + // broker's exempt RECOVERY_TOOL identity. It is not a Bash exception. + pi.registerTool({ + name: RECOVERY_TOOL, + label: 'Mosaic Context Recovery', + description: + 'Run the constrained broker-backed context recovery flow. This is the sole ungated mutator.', + parameters: Type.Object({ + phase: Type.Union([Type.Literal('begin'), Type.Literal('complete')]), + construction: Type.Optional(Type.String()), + compactionEpoch: Type.Optional(Type.Integer({ minimum: 0 })), + requestEpoch: Type.Optional(Type.Integer({ minimum: 0 })), + }), + async execute(_toolCallId, params) { + const blocked = checkPiRecoveryGate(); + if (blocked !== undefined) return { content: [{ type: 'text', text: blocked.reason }] }; + return runPiRecoveryCommand(params); + }, + }); + // ── Session Start ───────────────────────────────────────────────────── pi.on('session_start', async (_event, ctx) => { sessionCwd = process.cwd(); diff --git a/packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md b/packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md new file mode 100644 index 00000000..4920233d --- /dev/null +++ b/packages/mosaic/framework/skills/mosaic-context-refresh/SKILL.md @@ -0,0 +1,65 @@ +--- +name: mosaic-context-refresh +description: Run the constrained Mosaic context-recovery flow after compaction or directive-loss. This is a thin wrapper over the broker-backed recovery command; it never treats a receipt as a safety or residency proof. +--- + +# mosaic-context-refresh + +Use this only after compaction, session resume, or confirmed directive drift. It invokes the +**single ungated mutator**, `tools/lease-broker/recover-context.py`; every other consequential +mutator remains behind the verified lease gate. + +## Wrapper procedure + +1. The runtime supplies the exact validated normative-fragment construction and the current + compaction/request epochs. + - **Claude:** invoke only this direct command shape (no shell composition): + + ```bash + python3 /absolute/path/to/mosaic/tools/lease-broker/recover-context.py begin --construction /absolute/path/to/mosaic-context-refresh-construction.json --compaction-epoch 0 --request-epoch 0 + ``` + + This is a literal argv template: replace the recover-context.py path and construction JSON path + with the literal absolute paths for your install, then replace each epoch with literal decimal + digits. Do not use variables, quoting, globs, redirects, + shell operators, substitutions, or line continuations. Claude's all-tools gate maps only this + fully literal recovery shape to `mosaic_context_recover`; ordinary `Bash` remains gated. + + - **Pi:** call the registered `mosaic_context_recover` tool with `phase: "begin"`, + `construction`, `compactionEpoch`, and `requestEpoch`. It is the exact broker-exempt tool name; + Pi `bash` and every other tool remain gated. + + Both forms delegate to the shipped WI-5 broker transition: revoke first, build the canonical + `B_payload`/`H_payload`, enter `PENDING_DELIVERY`, and mint a fresh one-time challenge. They print + the terminal receipt envelope to deliver exactly as returned. + +2. The current assistant message copies that one terminal receipt verbatim. It does not compute a + hash, add prose, quote a prior receipt, or present a caller-supplied receipt/challenge. +3. The production trusted-observer transport records that finalized assistant entry before completion: + - **Claude** selects the latest assistant entry at its `Stop` hook. + - **Pi** records only finalized assistant content at `message_end` (never + `after_provider_response`). + + Then invoke completion with the same adapter form: Claude runs + `python3 /absolute/path/to/mosaic/tools/lease-broker/recover-context.py complete`; Pi calls + `mosaic_context_recover` with `phase: "complete"`. Completion supplies no receipt or challenge + argument. The broker observes the exact latest assistant entry, commits evidence, consumes its own + fresh challenge, and promotes VERIFIED last. If observation is absent, malformed, stale, or + duplicated, recovery remains UNVERIFIED and a retry begins a new cycle. + +## Scope and honesty + +- A receipt from the normal verification path cannot be replayed through recovery: recovery mints a + distinct current challenge and does not accept caller-provided receipt text as evidence. +- Observable absent, malformed, prefix-truncated, and adapter-mutated terminal receipts do not + promote. “Tail-only” is non-promoting only when the delivered terminal bytes are concretely + malformed or incomplete. +- **Negative capability:** a tail-preserving middle drop is not represented as receipt-detectable. + It is a T-C injection-contract residual deferred to WI-7 server-side evidence; do not claim this + skill or receipt catches it. +- The receipt is a T-A delivery/liveness prerequisite only. It never proves obedience, comprehension, + durable residency, or safety; the whole mutator-class gate and server-side branch protection retain + those roles. + +This source-resident skill is projected by the Mosaic skill bridge after framework install/upgrade. +Do not create a live symlink manually. diff --git a/packages/mosaic/framework/systemd/user/README.md b/packages/mosaic/framework/systemd/user/README.md index f1e0b8aa..e0df756f 100644 --- a/packages/mosaic/framework/systemd/user/README.md +++ b/packages/mosaic/framework/systemd/user/README.md @@ -12,6 +12,8 @@ exact-match session. - `mosaic-tmux-holder.service` — user-mode holder that owns the named tmux server. - `mosaic-agent@.service` — user-mode template for one reusable agent session. +- `mosaic-interaction-agent@.service` — generic Pi operator-interaction template + that fails fast when its pinned runtime policy is incomplete or changed. - `test-fleet-units.sh` — validates unit syntax and required relationships. The agent template calls: @@ -22,36 +24,57 @@ The agent template calls: which starts or reuses a tmux session on `MOSAIC_TMUX_SOCKET`. -## Local customization +## Generated environment and local data -Per-agent overrides live outside the package in: +The roster-derived projection is written outside the package at: ```text -~/.config/mosaic/fleet/agents/.env +~/.config/mosaic/fleet/agents/.env.generated ``` -Example: +Systemd does not read either environment file. It starts the launcher with a fixed cleared bootstrap +environment; before it creates, queries, or stops an exact agent tmux session, `start-agent-session.sh` +strictly parses the generated projection and the optional local data file: -```dotenv -MOSAIC_TMUX_SOCKET=mosaic-fleet -MOSAIC_AGENT_RUNTIME=claude -MOSAIC_AGENT_WORKDIR=$HOME/src/your-project -# Optional escape hatch for PoC/canary agents: -# MOSAIC_AGENT_COMMAND=mosaic yolo claude +```text +~/.config/mosaic/fleet/agents/.env.local ``` +The local file may contain only safe machine-specific data (`MOSAIC_RUNTIME_BIN`, heartbeat paths or +interval, and Claude configuration paths). It cannot override roster-derived keys, carry a command, +or contain secret-like/unknown keys. Both files must be private regular files. Do not hand-edit the +generated projection; update the roster and regenerate it instead. A legacy `.env` is +consumed only for regeneration, strict relocation, or private quarantine and is never launch input. + +See `docs/fleet/reference/generated-env-boundary.md` for the full contract. + ## Manual canary sequence +Use the roster and the supported installer; do not pre-create the agent environment directory or +edit a generated projection. `mosaic fleet install` validates the roster, installs the units and +helpers, and writes private roster-derived projections before any service is started. + ```bash -mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents -cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/ -cp packages/mosaic/framework/tools/fleet/start-agent-session.sh ~/.config/mosaic/tools/fleet/ -chmod +x ~/.config/mosaic/tools/fleet/start-agent-session.sh +# Create a site-owned canary roster. Inspect an existing roster before using --force. +mosaic fleet init --profile minimal --write +mosaic fleet install systemctl --user daemon-reload -systemctl --user start mosaic-tmux-holder.service -systemctl --user start mosaic-agent@canary.service +mosaic fleet start canary-pi tmux -L mosaic-fleet ls ``` +For an operator-interaction service, first put `` in the roster with the pinned Pi +runtime, model, reasoning, and `operator-interaction` tool policy. Re-run `mosaic fleet install` after +that roster change so it writes `.env.generated`; ambient `MOSAIC_AGENT_*` values are not +launch authority. The generic unit instance uses that generated identity, and no service source is +renamed for an instance: + +```bash +mosaic fleet install +systemctl --user daemon-reload +systemctl --user start mosaic-interaction-agent@.service +~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh +``` + Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant to avoid disturbing the user's default tmux server. diff --git a/packages/mosaic/framework/systemd/user/mosaic-agent@.service b/packages/mosaic/framework/systemd/user/mosaic-agent@.service index 0ebdec9c..f4d4a985 100644 --- a/packages/mosaic/framework/systemd/user/mosaic-agent@.service +++ b/packages/mosaic/framework/systemd/user/mosaic-agent@.service @@ -7,16 +7,13 @@ PartOf=mosaic-tmux-holder.service [Service] Type=oneshot +# Remove loader and noninteractive-shell controls before ExecStart loads env. +UnsetEnvironment=LD_PRELOAD BASH_ENV ENV RemainAfterExit=yes -# No default MOSAIC_TMUX_SOCKET: an absent roster socket means the literal -# default tmux socket (no -L). The per-agent .env sets it when the roster names -# one; otherwise it stays unset and start-agent-session.sh uses the default socket. -Environment=MOSAIC_AGENT_NAME=%i -Environment=MOSAIC_AGENT_RUNTIME=pi -Environment=MOSAIC_AGENT_WORKDIR=%h -EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env -ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-agent-session.sh %i -ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi' +# Never preload the projection. The launcher starts from a fixed minimal +# environment and strictly validates generated/local data before tmux effects. +ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh %i +ExecStop=-/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh --stop %i [Install] WantedBy=default.target diff --git a/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service b/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service new file mode 100644 index 00000000..9bc5831d --- /dev/null +++ b/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service @@ -0,0 +1,19 @@ +[Unit] +Description=Mosaic operator interaction agent %i +Documentation=https://git.mosaicstack.dev/mosaicstack/stack +Requires=mosaic-tmux-holder.service +After=mosaic-tmux-holder.service +PartOf=mosaic-tmux-holder.service + +[Service] +Type=oneshot +# Remove loader and noninteractive-shell controls before ExecStart loads env. +UnsetEnvironment=LD_PRELOAD BASH_ENV ENV +RemainAfterExit=yes +# The interaction wrapper delegates to the shared strict parser before pinned +# profile checks; no projection data reaches Bash through systemd. +ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i +ExecStop=-/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh --stop %i + +[Install] +WantedBy=default.target diff --git a/packages/mosaic/framework/systemd/user/mosaic-lease-broker.service b/packages/mosaic/framework/systemd/user/mosaic-lease-broker.service new file mode 100644 index 00000000..b7046e62 --- /dev/null +++ b/packages/mosaic/framework/systemd/user/mosaic-lease-broker.service @@ -0,0 +1,22 @@ +[Unit] +Description=Mosaic lease broker daemon (framework tools/lease-broker/daemon.py) +Documentation=https://git.mosaicstack.dev/mosaicstack/stack +After=default.target + +[Service] +Type=simple +# The broker socket lives under the runtime directory so it disappears with +# the user session instead of surviving as stale state across logins. +# daemon.py's secure_parent() fails closed unless this directory is exactly +# 0700, so RuntimeDirectoryMode is not cosmetic. +RuntimeDirectory=mosaic-lease +RuntimeDirectoryMode=0700 +# Remove loader and noninteractive-shell controls before ExecStart loads env, +# matching the tmux fleet units in this same directory. +UnsetEnvironment=LD_PRELOAD BASH_ENV ENV +ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin XDG_RUNTIME_DIR=%t /bin/bash --noprofile --norc %h/.config/mosaic/tools/lease-broker/start-lease-broker.sh +Restart=on-failure +RestartSec=1 + +[Install] +WantedBy=default.target diff --git a/packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service b/packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service index a4ae3aed..7795efdf 100644 --- a/packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service +++ b/packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service @@ -6,10 +6,11 @@ After=default.target [Service] Type=oneshot RemainAfterExit=yes -Environment=MOSAIC_TMUX_SOCKET=mosaic-fleet -Environment=MOSAIC_TMUX_HOLDER=_holder -ExecStart=/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" "while true; do sleep 3600; done"' -ExecStop=-/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server' +# The holder owns the tmux server, so clear loader, shell-control, and stale +# manager/session variables before the server process starts. +UnsetEnvironment=LD_PRELOAD BASH_ENV ENV +ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet MOSAIC_TMUX_HOLDER=_holder /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-tmux-holder.sh +ExecStop=-/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet /bin/bash --noprofile --norc -c 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server' [Install] WantedBy=default.target diff --git a/packages/mosaic/framework/systemd/user/mosaic-wake-fallback.service b/packages/mosaic/framework/systemd/user/mosaic-wake-fallback.service new file mode 100644 index 00000000..144a8b1c --- /dev/null +++ b/packages/mosaic/framework/systemd/user/mosaic-wake-fallback.service @@ -0,0 +1,41 @@ +[Unit] +# Mosaic wake FALLBACK safety drain (F7 replacement-before-retirement, EPIC #892, +# W7). This is the framework-shipped canon-side FALLBACK WAKE: a LOW-FREQUENCY +# SAFETY drain that fires the canon drain (digest.sh render --from-store) on a +# per-class cadence bound, INDEPENDENT of the event-driven detector daemon +# (mosaic-wake.service). Its whole reason to exist is that a stalled/dead detector +# or daemon can never SILENTLY STARVE delivery: even with nothing pushing, this +# oneshot periodically drains the durable pending-inbox so the cumulative unacked +# set still reaches the consumer. It is the §5 retirement precondition (F7) — it +# must be live + proven-firing BEFORE the legacy mosaic-heartbeat@ timer is reaped, +# so there is never a coverage gap. Fixed-interval heartbeats are forbidden as the +# PRIMARY wake mechanism (WAKE-DOCTRINE); they survive ONLY as this per-class +# fallback cadence, bounded by urgency SLO, never as the steady state. +# +# This is a oneshot SERVICE activated by mosaic-wake-fallback.timer; the cadence +# lives on the TIMER (OnUnitActiveSec), set per-class by the A10 installer via the +# blank-reset drop-in — never here. The service therefore carries NO [Install] +# section (the TIMER is what is enabled/wanted); it is triggered, not wanted. +Description=Mosaic wake fallback safety drain (canon drain: digest.sh render --from-store) +After=default.target + +[Service] +Type=oneshot +# Strip loader / noninteractive-shell controls before ExecStart, matching the +# detector, lease-broker and tmux fleet units in this same directory (defense-in- +# depth against an injected BASH_ENV/ENV/LD_PRELOAD in the user manager environment). +UnsetEnvironment=LD_PRELOAD BASH_ENV ENV +# Operator-owned runtime configuration. This EnvironmentFile carries only WAKE_* +# NAMES (the per-agent namespace WAKE_AGENT, the lane WAKE_LANE, and — reused from +# the detector — the pluggable adapter COMMANDS resolved BY NAME at runtime). It +# carries NEVER any secret and NEVER any endpoint value. The '-' prefix keeps a +# missing file from masking the installer's dedicated fail-closed install-validate. +EnvironmentFile=-%h/.config/mosaic/wake/fallback.env +# THE CANON DRAIN. digest.sh render --from-store drains the durable pending-inbox +# (store.sh drain) and renders the cumulative-state digest. Running it here, on the +# timer cadence, is the safety net: it is the SAME drain the delivery path uses, so +# a stalled detector cannot starve it. Delivery/paste of the rendered digest is the +# same operator-wired send seam the detector path uses (out of framework scope); +# this unit guarantees the DRAIN fires on a bounded cadence regardless of detector +# health. digest render exits 0 on an empty inbox, so a quiet cycle is a clean no-op. +ExecStart=/bin/bash --noprofile --norc %h/.config/mosaic/tools/wake/digest.sh render --from-store diff --git a/packages/mosaic/framework/systemd/user/mosaic-wake-fallback.timer b/packages/mosaic/framework/systemd/user/mosaic-wake-fallback.timer new file mode 100644 index 00000000..fcab5a8a --- /dev/null +++ b/packages/mosaic/framework/systemd/user/mosaic-wake-fallback.timer @@ -0,0 +1,30 @@ +[Unit] +# Cadence timer for the Mosaic wake FALLBACK safety drain (F7, EPIC #892, W7). +# Drives mosaic-wake-fallback.service on a LOW-FREQUENCY per-class cadence bound, +# INDEPENDENT of the event-driven detector daemon, so a stalled detector can never +# silently starve delivery. This is the framework-shipped canon-side FALLBACK WAKE: +# a heartbeat-shaped timer that survives ONLY as the per-class fallback cadence +# (WAKE-DOCTRINE) bounded by urgency SLO — never the steady-state wake mechanism. +Description=Mosaic wake fallback cadence timer (per-class safety wake) +After=default.target + +[Timer] +# BASE cadence placeholder. The A10 installer OVERRIDES this per-class from the +# watch-list schema's per-class `fallback_cadence` bound, via a BLANK-RESET drop-in +# (an empty OnUnitActiveSec= reset line, then the new value) written under +# mosaic-wake-fallback.timer.d/. systemd merges base + drop-ins so exactly ONE +# effective OnUnitActiveUSec results (wake-install.sh verify-single). The base value +# here is a conservative safety floor for a host installed before any per-class +# drop-in is written — it is deliberately low-frequency (never the primary wake). +OnUnitActiveSec=1h +# Also fire shortly after boot so a freshly-booted host does not wait a full cadence +# for its first safety drain. OnBootSec is a distinct key from OnUnitActiveSec and +# does NOT count toward the exactly-one-OnUnitActiveUSec blank-reset invariant. +OnBootSec=15min +# Catch up a missed elapse (host asleep/off) rather than silently skipping it — a +# fallback that silently skips is exactly the starvation this unit exists to prevent. +Persistent=true +Unit=mosaic-wake-fallback.service + +[Install] +WantedBy=timers.target diff --git a/packages/mosaic/framework/systemd/user/mosaic-wake.service b/packages/mosaic/framework/systemd/user/mosaic-wake.service new file mode 100644 index 00000000..e1ff4232 --- /dev/null +++ b/packages/mosaic/framework/systemd/user/mosaic-wake.service @@ -0,0 +1,31 @@ +[Unit] +# Mosaic wake DETECTOR daemon (A1/W7 of the wake canon, EPIC #892). A LONG-LIVED +# single-instance detector: tools/wake/detector.sh run. This is a SERVICE, not a +# timer — the per-class SLO lives INSIDE the daemon's run-loop (WAKE_DETECTOR_INTERVAL +# poll cadence + the per-cycle off-host beacon emit), never as a systemd +# OnUnitActiveSec interval. The blank-reset cadence idiom therefore does NOT apply +# to this unit; it applies only to the legacy mosaic-heartbeat@ timer during retire. +Description=Mosaic wake detector daemon (framework tools/wake/detector.sh run) +After=default.target + +[Service] +Type=simple +# Strip loader / noninteractive-shell controls before ExecStart, matching the +# lease-broker and tmux fleet units in this same directory (defense-in-depth +# against an injected BASH_ENV/ENV/LD_PRELOAD in the user manager environment). +UnsetEnvironment=LD_PRELOAD BASH_ENV ENV +# Operator-owned runtime configuration. This EnvironmentFile carries only the +# WAKE_* NAMES and the pluggable adapter COMMANDS (the off-host beacon/alarm sink +# and the HMAC key NAME) — NEVER the HMAC key material and NEVER the alarm +# endpoint value. Both are resolved BY NAME at runtime via load_credentials, so +# no secret and no endpoint is ever written into this unit. The installer's +# fail-closed install-validate (wake-install.sh validate-targets) is what proves +# the required names are configured + reachable BEFORE this unit is enabled; the +# '-' prefix keeps a missing file from masking that dedicated validation. +EnvironmentFile=-%h/.config/mosaic/wake/detector.env +ExecStart=/bin/bash --noprofile --norc %h/.config/mosaic/tools/wake/detector.sh run +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/packages/mosaic/framework/systemd/user/test-fleet-units.sh b/packages/mosaic/framework/systemd/user/test-fleet-units.sh index de5d7c2a..6973a9ce 100755 --- a/packages/mosaic/framework/systemd/user/test-fleet-units.sh +++ b/packages/mosaic/framework/systemd/user/test-fleet-units.sh @@ -4,6 +4,9 @@ set -euo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service" AGENT="$SCRIPT_DIR/mosaic-agent@.service" +INTERACTION="$SCRIPT_DIR/mosaic-interaction-agent@.service" +HOLDER_START="$SCRIPT_DIR/../../tools/fleet/start-tmux-holder.sh" +START_AGENT="$SCRIPT_DIR/../../tools/fleet/start-agent-session.sh" fail() { echo "FAIL: $*" >&2 @@ -12,19 +15,145 @@ fail() { [ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service" [ -f "$AGENT" ] || fail "missing mosaic-agent@.service" +[ -f "$INTERACTION" ] || fail "missing mosaic-interaction-agent@.service" +[ -x "$HOLDER_START" ] || fail "missing executable start-tmux-holder.sh" +[ -x "$START_AGENT" ] || fail "missing executable start-agent-session.sh" grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart" grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket" grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit" +grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$HOLDER" || \ + fail "holder does not remove loader and shell-control variables" +grep -qF 'ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet MOSAIC_TMUX_HOLDER=_holder /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-tmux-holder.sh' "$HOLDER" || \ + fail "holder does not clear manager environment before starting tmux" +grep -qF 'ExecStop=-/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet /bin/bash --noprofile --norc -c' "$HOLDER" || \ + fail "holder stop does not clear manager environment" +if grep -qF -- '/bin/bash -lc' "$HOLDER"; then + fail "holder must not start tmux through a login shell" +fi grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder" grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh" -grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session" +if grep -qE '^Environment(File)?=' "$AGENT" "$INTERACTION"; then + fail "agent units must not accept ambient or projection environment before strict parsing" +fi +grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$AGENT" || \ + fail "agent unit does not remove loader and shell-control variables" +grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$INTERACTION" || \ + fail "interaction unit does not remove loader and shell-control variables" +grep -qF 'ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc' "$AGENT" || \ + fail "agent unit does not clear bootstrap environment before strict parsing" +grep -qF 'start-agent-session.sh --stop %i' "$AGENT" || \ + fail "agent stop does not use the validated exact-stop path" +grep -qF 'Requires=mosaic-tmux-holder.service' "$INTERACTION" || fail "interaction service does not require holder" +grep -qF 'ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc' "$INTERACTION" || \ + fail "interaction unit does not clear bootstrap environment before strict parsing" +grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not use shared strict parsing" +grep -qF 'start-agent-session.sh --stop %i' "$INTERACTION" || \ + fail "interaction stop does not use the validated exact-stop path" if command -v systemd-analyze >/dev/null 2>&1; then - systemd-analyze verify --user "$HOLDER" "$AGENT" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || { + systemd-analyze verify --user "$HOLDER" "$AGENT" "$INTERACTION" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || { cat /tmp/mosaic-fleet-systemd-verify.log >&2 fail "systemd-analyze verify failed" } fi +# Real isolated socket regression: a preexisting server with an LD_PRELOAD +# constructor marker must fail closed, while a fresh named server is created. +if command -v tmux >/dev/null 2>&1 && command -v cc >/dev/null 2>&1; then + TEST_ROOT=$(mktemp -d) + TEST_SOCKET="mosaic-holder-test-$$" + trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT + MARKER="$TEST_ROOT/loader-marker" + LIBRARY="$TEST_ROOT/marker.so" + HOLDER_HOME="$TEST_ROOT/holder-home" + mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run" + chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \ + "$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run" + printf '123e4567-e89b-12d3-a456-426614174000\n' > \ + "$HOLDER_HOME/.config/mosaic/fleet/run/holder-owner" + chmod 600 "$HOLDER_HOME/.config/mosaic/fleet/run/holder-owner" + cat > "$TEST_ROOT/marker.c" <<'EOF' +#include +#include +#include +__attribute__((constructor)) static void mark_loader(void) { + const char *path = getenv("MOSAIC_LOADER_MARKER"); + if (path != NULL) { + int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600); + if (fd >= 0) { write(fd, "loaded\\n", 7); close(fd); } + } +} +EOF + cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c" + MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \ + tmux -L "$TEST_SOCKET" new-session -d -s _holder 'sleep 60' + [ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor" + server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}') + : > "$MARKER" + if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \ + MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START" \ + >"$TEST_ROOT/holder.out" 2>&1; then + fail "holder adopted contaminated named server" + fi + grep -qF 'global environment does not match the owned-server contract' "$TEST_ROOT/holder.out" || \ + fail "holder did not report contaminated server environment" + [ "$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')" = "$server_pid" ] || \ + fail "holder replaced a contaminated server instead of failing closed" + [ ! -s "$MARKER" ] || fail "holder execution triggered a contaminated loader" + + # Agent validation must reject the same unmanaged server without cleaning its + # global environment or adding a managed session. + AGENT_HOME="$HOLDER_HOME/.config/mosaic" + AGENT_NAME=loader-safe + AGENT_WORKDIR="$AGENT_HOME/work" + AGENT_BIN="$TEST_ROOT/agent-bin" + mkdir -p "$AGENT_HOME/fleet/agents" "$AGENT_WORKDIR" "$AGENT_BIN" + chmod 700 "$AGENT_HOME/fleet/agents" + cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" < "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.local" + chmod 600 "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" \ + "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.local" + cat > "$AGENT_BIN/mosaic" <<'EOF' +#!/bin/sh +sleep 30 +EOF + chmod 700 "$AGENT_BIN/mosaic" + server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort) + server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort) + if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \ + "$START_AGENT" "$AGENT_NAME" >"$TEST_ROOT/agent.out" 2>&1; then + fail "agent launcher adopted contaminated named server" + fi + [ "$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')" = "$server_pid" ] || \ + fail "agent launcher changed unmanaged server PID" + [ "$(tmux -L "$TEST_SOCKET" show-environment -g | sort)" = "$server_environment_before" ] || \ + fail "agent launcher changed unmanaged global environment" + [ "$(tmux -L "$TEST_SOCKET" list-sessions | sort)" = "$server_sessions_before" ] || \ + fail "agent launcher changed unmanaged sessions" + tmux -L "$TEST_SOCKET" kill-server + /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \ + MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START" + tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created" + if tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then + fail "fresh holder retained LD_PRELOAD" + fi + /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \ + "$START_AGENT" "$AGENT_NAME" + tmux -L "$TEST_SOCKET" has-session -t "=$AGENT_NAME:0.0" || \ + fail "agent did not launch on a valid owned server" + tmux -L "$TEST_SOCKET" kill-server + trap - EXIT + rm -rf "$TEST_ROOT" +fi + echo "ok - fleet systemd unit templates" diff --git a/packages/mosaic/framework/templates/agent/AGENTS.md.template b/packages/mosaic/framework/templates/agent/AGENTS.md.template index 14dfc1cf..86d18d01 100755 --- a/packages/mosaic/framework/templates/agent/AGENTS.md.template +++ b/packages/mosaic/framework/templates/agent/AGENTS.md.template @@ -9,7 +9,7 @@ 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 3. Completion is forbidden at PR-open stage. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. -5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. @@ -88,7 +88,7 @@ Reference: 5. Do not mark implementation complete until PR is merged. 6. Do not mark implementation complete until CI/pipeline status is terminal green. 7. Close linked issues/tasks only after merge + green CI. -8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. ## Container Release Strategy (When Applicable) diff --git a/packages/mosaic/framework/templates/agent/CLAUDE.md.template b/packages/mosaic/framework/templates/agent/CLAUDE.md.template index 937de770..b2715a6d 100755 --- a/packages/mosaic/framework/templates/agent/CLAUDE.md.template +++ b/packages/mosaic/framework/templates/agent/CLAUDE.md.template @@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 7. Update `docs/TASKS.md` status + issue/internal ref before coding. -8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. +8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 9. Open PR to `main` for delivery changes (no direct push to `main`). -10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. +10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha `. 11. Merge PRs that pass required checks and review gates with squash strategy only. 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. diff --git a/packages/mosaic/framework/templates/agent/projects/django/AGENTS.md.template b/packages/mosaic/framework/templates/agent/projects/django/AGENTS.md.template index b566accb..4c6b2893 100755 --- a/packages/mosaic/framework/templates/agent/projects/django/AGENTS.md.template +++ b/packages/mosaic/framework/templates/agent/projects/django/AGENTS.md.template @@ -9,7 +9,7 @@ 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 3. Completion is forbidden at PR-open stage. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. -5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. @@ -97,7 +97,7 @@ Reference: 5. Do not mark implementation complete until PR is merged. 6. Do not mark implementation complete until CI/pipeline status is terminal green. 7. Close linked issues/tasks only after merge + green CI. -8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. ## Container Release Strategy (When Applicable) diff --git a/packages/mosaic/framework/templates/agent/projects/django/CLAUDE.md.template b/packages/mosaic/framework/templates/agent/projects/django/CLAUDE.md.template index 306b3e1f..4cd3e8d8 100755 --- a/packages/mosaic/framework/templates/agent/projects/django/CLAUDE.md.template +++ b/packages/mosaic/framework/templates/agent/projects/django/CLAUDE.md.template @@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 7. Update `docs/TASKS.md` status + issue/internal ref before coding. -8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. +8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 9. Open PR to `main` for delivery changes (no direct push to `main`). -10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. +10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha `. 11. Merge PRs that pass required checks and review gates with squash strategy only. 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. diff --git a/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/AGENTS.md.template b/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/AGENTS.md.template index 85aad51e..b423d545 100755 --- a/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/AGENTS.md.template +++ b/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/AGENTS.md.template @@ -9,7 +9,7 @@ 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 3. Completion is forbidden at PR-open stage. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. -5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. @@ -101,7 +101,7 @@ Reference: 5. Do not mark implementation complete until PR is merged. 6. Do not mark implementation complete until CI/pipeline status is terminal green. 7. Close linked issues/tasks only after merge + green CI. -8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. ## Container Release Strategy (When Applicable) diff --git a/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/CLAUDE.md.template b/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/CLAUDE.md.template index b2cbb759..12d47883 100755 --- a/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/CLAUDE.md.template +++ b/packages/mosaic/framework/templates/agent/projects/nestjs-nextjs/CLAUDE.md.template @@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 7. Update `docs/TASKS.md` status + issue/internal ref before coding. -8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. +8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 9. Open PR to `main` for delivery changes (no direct push to `main`). -10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. +10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha `. 11. Merge PRs that pass required checks and review gates with squash strategy only. 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. diff --git a/packages/mosaic/framework/templates/agent/projects/python-fastapi/AGENTS.md.template b/packages/mosaic/framework/templates/agent/projects/python-fastapi/AGENTS.md.template index 2205eb59..296bf2d6 100755 --- a/packages/mosaic/framework/templates/agent/projects/python-fastapi/AGENTS.md.template +++ b/packages/mosaic/framework/templates/agent/projects/python-fastapi/AGENTS.md.template @@ -9,7 +9,7 @@ 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 3. Completion is forbidden at PR-open stage. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. -5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. @@ -87,7 +87,7 @@ Reference: 5. Do not mark implementation complete until PR is merged. 6. Do not mark implementation complete until CI/pipeline status is terminal green. 7. Close linked issues/tasks only after merge + green CI. -8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. ## Container Release Strategy (When Applicable) diff --git a/packages/mosaic/framework/templates/agent/projects/python-fastapi/CLAUDE.md.template b/packages/mosaic/framework/templates/agent/projects/python-fastapi/CLAUDE.md.template index 036651d7..afd3fa2a 100755 --- a/packages/mosaic/framework/templates/agent/projects/python-fastapi/CLAUDE.md.template +++ b/packages/mosaic/framework/templates/agent/projects/python-fastapi/CLAUDE.md.template @@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 7. Update `docs/TASKS.md` status + issue/internal ref before coding. -8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. +8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 9. Open PR to `main` for delivery changes (no direct push to `main`). -10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. +10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha `. 11. Merge PRs that pass required checks and review gates with squash strategy only. 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. diff --git a/packages/mosaic/framework/templates/agent/projects/python-library/AGENTS.md.template b/packages/mosaic/framework/templates/agent/projects/python-library/AGENTS.md.template index 2557b6e0..bf3830ab 100755 --- a/packages/mosaic/framework/templates/agent/projects/python-library/AGENTS.md.template +++ b/packages/mosaic/framework/templates/agent/projects/python-library/AGENTS.md.template @@ -9,7 +9,7 @@ 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 3. Completion is forbidden at PR-open stage. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. -5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. @@ -84,7 +84,7 @@ Reference: 5. Do not mark implementation complete until PR is merged. 6. Do not mark implementation complete until CI/pipeline status is terminal green. 7. Close linked issues/tasks only after merge + green CI. -8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. ## Container Release Strategy (When Applicable) diff --git a/packages/mosaic/framework/templates/agent/projects/python-library/CLAUDE.md.template b/packages/mosaic/framework/templates/agent/projects/python-library/CLAUDE.md.template index f671359a..1be2922b 100755 --- a/packages/mosaic/framework/templates/agent/projects/python-library/CLAUDE.md.template +++ b/packages/mosaic/framework/templates/agent/projects/python-library/CLAUDE.md.template @@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 7. Update `docs/TASKS.md` status + issue/internal ref before coding. -8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. +8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 9. Open PR to `main` for delivery changes (no direct push to `main`). -10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. +10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha `. 11. Merge PRs that pass required checks and review gates with squash strategy only. 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. diff --git a/packages/mosaic/framework/templates/agent/projects/typescript/AGENTS.md.template b/packages/mosaic/framework/templates/agent/projects/typescript/AGENTS.md.template index 0deed88e..31315cd6 100755 --- a/packages/mosaic/framework/templates/agent/projects/typescript/AGENTS.md.template +++ b/packages/mosaic/framework/templates/agent/projects/typescript/AGENTS.md.template @@ -9,7 +9,7 @@ 2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions. 3. Completion is forbidden at PR-open stage. 4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed. -5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. 6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`). 7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop. 8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow. @@ -85,7 +85,7 @@ Reference: 5. Do not mark implementation complete until PR is merged. 6. Do not mark implementation complete until CI/pipeline status is terminal green. 7. Close linked issues/tasks only after merge + green CI. -8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`. +8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically. ## Container Release Strategy (When Applicable) diff --git a/packages/mosaic/framework/templates/agent/projects/typescript/CLAUDE.md.template b/packages/mosaic/framework/templates/agent/projects/typescript/CLAUDE.md.template index 9843d675..ed9b0715 100755 --- a/packages/mosaic/framework/templates/agent/projects/typescript/CLAUDE.md.template +++ b/packages/mosaic/framework/templates/agent/projects/typescript/CLAUDE.md.template @@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close 5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding. 6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref. 7. Update `docs/TASKS.md` status + issue/internal ref before coding. -8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`. +8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`. 9. Open PR to `main` for delivery changes (no direct push to `main`). -10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`. +10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B -R --sha `. 11. Merge PRs that pass required checks and review gates with squash strategy only. 12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`). 13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green. diff --git a/packages/mosaic/framework/tools/_lib/credentials.sh b/packages/mosaic/framework/tools/_lib/credentials.sh index dd05972c..64d4d63a 100755 --- a/packages/mosaic/framework/tools/_lib/credentials.sh +++ b/packages/mosaic/framework/tools/_lib/credentials.sh @@ -15,13 +15,23 @@ # # After loading, service-specific env vars are exported. # Run `load_credentials --help` for details. +# +# Resolution order (first match wins): +# 1. $MOSAIC_CREDENTIALS_FILE (explicit override — never second-guessed) +# 2. $HOME/.config/mosaic/credentials.json +# 3. /etc/mosaic/credentials.json (host-level fallback) +# The /etc fallback exists for HOME-redirected profile environments, where +# $HOME points at a per-profile directory that has no credentials file. +# Operators symlink /etc/mosaic/credentials.json to the host's canonical +# file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation. if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then - for _cand in "$HOME/.config/mosaic/credentials.json"; do + for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi done : "${MOSAIC_CREDENTIALS_FILE:=$HOME/.config/mosaic/credentials.json}" fi +export MOSAIC_CREDENTIALS_FILE _mosaic_require_jq() { if ! command -v jq &>/dev/null; then diff --git a/packages/mosaic/framework/tools/_lib/manifest.sh b/packages/mosaic/framework/tools/_lib/manifest.sh new file mode 100644 index 00000000..20b79cc3 --- /dev/null +++ b/packages/mosaic/framework/tools/_lib/manifest.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# Shared bash reader for framework-manifest.txt (#791). +# +# This is the bash half of the SSOT ownership resolver; the TypeScript half is +# packages/mosaic/src/framework/manifest.ts. BOTH read the same +# framework-manifest.txt and MUST resolve identical ownership for any path — the +# parity test (manifest-parity.spec.ts) invokes this file's `resolve` CLI and +# compares it against the TS resolver, so the two can never drift (the #631 +# two-copies failure class this closes). +# +# Ownership resolution (deny-wins / fail-safe): +# 1. operator glob matches -> operator +# 2. else framework glob -> framework +# 3. else -> operator (UNKNOWN defaults to operator, #791) +# +# Globs are compiled once at load into exact-prefix checks or POSIX EREs, so the +# hot resolver (manifest_is_framework) forks no subprocesses — the installer +# calls it once per file across the whole tree. +# +# Usage as a library (source it, then): +# manifest_load [manifest-file] # populates + compiles the manifest +# manifest_is_framework # rc 0 = framework-owned, rc 1 = operator +# manifest_resolve # echoes: framework | operator +# manifest_subtree_roots # echoes shipped framework `dir/**` roots +# +# Usage as a CLI (parity harness): +# bash manifest.sh resolve +# bash manifest.sh subtree-roots +# bash manifest.sh classify # reads paths on stdin -> "\t" + +MANIFEST_FRAMEWORK=() +MANIFEST_OPERATOR=() + +# Compiled forms (parallel arrays). _*_KIND[i] is "exact" or "re". +_MF_KIND=(); _MF_EXACT=(); _MF_RE=() +_MO_KIND=(); _MO_EXACT=(); _MO_RE=() +_MF_ROOTS=() + +_manifest_default_root() { cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd; } + +# Normalize a path/glob: backslashes -> slashes, strip leading ./ and /, strip +# trailing / (mirrors normalizeRel in manifest.ts). +_manifest_norm() { + local p="$1" + p="${p//\\//}" + p="${p#./}" + while [[ "$p" == /* ]]; do p="${p#/}"; done + while [[ "$p" == */ ]]; do p="${p%/}"; done + printf '%s' "$p" +} + +# Translate a normalized glob into a POSIX ERE body (mirrors globToRegExpBody). +_manifest_glob_to_ere() { + local pattern; pattern="$(_manifest_norm "$1")" + local out="" c n i len=${#pattern} trailing + for (( i = 0; i < len; i++ )); do + c="${pattern:i:1}" + if [[ "$c" == "*" ]]; then + n="${pattern:i+1:1}" + if [[ "$n" == "*" ]]; then + i=$((i + 1)) + trailing=0 + if [[ "${pattern:i+1:1}" == "/" ]]; then i=$((i + 1)); trailing=1; fi + if [[ "$out" == */ ]]; then + out="${out%/}(/.*)?" + elif [[ "$trailing" -eq 1 ]]; then + out="$out(.*/)?" + else + out="$out.*" + fi + else + out="$out[^/]*" + fi + else + case "$c" in + .|+|\?|^|\$|\{|\}|\(|\)|\||\[|\]|\\) out="$out\\$c" ;; + *) out="$out$c" ;; + esac + fi + done + printf '%s' "$out" +} + +# Compile one raw glob into (kind, exact, re) appended to the given section. +# $1 = raw glob, $2 = section letter (F|O). +_manifest_compile_one() { + local norm; norm="$(_manifest_norm "$1")" + [[ -n "$norm" ]] || return 0 + if [[ "$norm" == *"*"* ]]; then + local re="^$(_manifest_glob_to_ere "$norm")\$" + if [[ "$2" == F ]]; then + _MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re") + else + _MO_KIND+=(re); _MO_EXACT+=(""); _MO_RE+=("$re") + fi + else + if [[ "$2" == F ]]; then + _MF_KIND+=(exact); _MF_EXACT+=("$norm"); _MF_RE+=("") + else + _MO_KIND+=(exact); _MO_EXACT+=("$norm"); _MO_RE+=("") + fi + fi + [[ "$2" == F && "$norm" == */"**" ]] && _MF_ROOTS+=("${norm%/**}") + return 0 +} + +_manifest_compile() { + _MF_KIND=(); _MF_EXACT=(); _MF_RE=(); _MF_ROOTS=() + _MO_KIND=(); _MO_EXACT=(); _MO_RE=() + local g + for g in "${MANIFEST_FRAMEWORK[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" F; done + for g in "${MANIFEST_OPERATOR[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" O; done + # Explicit success: an empty operator array makes the final `[[ -n "" ]] && …` + # short-circuit to rc 1, which would otherwise become this function's (and + # manifest_load's) return code — a spurious failure (#791 B2). Never rely on + # the last loop's exit status here. + return 0 +} + +# Load + compile the manifest. Rejects a malformed file the same way +# parseManifest() does (entry before a section header / unknown header). +manifest_load() { + local file="${1:-}" + [[ -n "$file" ]] || file="$(_manifest_default_root)/framework-manifest.txt" + # Fail CLOSED on a missing/unreadable manifest. Without this, `done < "$file"` + # aborts on a raw redirection error with no explanation; downstream that reads + # as "no framework paths" and an upgrade could no-op silently (#791 B2/B3). + if [[ ! -r "$file" ]]; then + echo "manifest: cannot read manifest file: $file — refusing to sync (fail-closed)." >&2 + return 1 + fi + MANIFEST_FRAMEWORK=() + MANIFEST_OPERATOR=() + local section="" line + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line#"${line%%[![:space:]]*}"}" # ltrim + line="${line%"${line##*[![:space:]]}"}" # rtrim + [[ -z "$line" || "${line:0:1}" == "#" ]] && continue + case "$line" in + "[framework]") section=framework; continue ;; + "[operator]") section=operator; continue ;; + "["*) echo "manifest: unknown section header: $line" >&2; return 1 ;; + esac + if [[ -z "$section" ]]; then + echo "manifest: entry before any [section] header: $line" >&2 + return 1 + fi + if [[ "$section" == framework ]]; then + MANIFEST_FRAMEWORK+=("$line") + else + MANIFEST_OPERATOR+=("$line") + fi + done < "$file" + # An empty or comment-only manifest defines NO framework-owned paths. Treating + # that as valid would make every path resolve operator and an upgrade prune + # nothing / write nothing — a silent no-op indistinguishable from success. + # Fail loud instead, mirroring parseManifest()'s throw in manifest.ts (#791 B2). + if [[ ${#MANIFEST_FRAMEWORK[@]} -eq 0 ]]; then + echo "manifest: no [framework] entries in $file — refusing to sync (empty or malformed manifest)." >&2 + return 1 + fi + # An entry like `/` or `./` normalizes to nothing and compiles to a glob that + # matches no path — so a manifest whose only [framework] entries are degenerate + # passes the count guard above but leaves the framework matcher empty: every + # path resolves operator, the exact silent no-op we fail closed against. Require + # at least one entry with a real (non-slash, non-dot) character. Mirrors + # parseManifest()'s `isUsableFrameworkGlob` `/[^/.]/` test in manifest.ts (#791 blocker-B). + local _g _usable=0 + for _g in "${MANIFEST_FRAMEWORK[@]:-}"; do + if [[ "$(_manifest_norm "$_g")" =~ [^/.] ]]; then _usable=1; break; fi + done + if [[ "$_usable" -eq 0 ]]; then + echo "manifest: no usable [framework] entries in $file (every entry is empty or a bare dot segment) — refusing to sync (malformed manifest)." >&2 + return 1 + fi + _manifest_compile + return 0 +} + +# Fork-free: does $1 (a mosaic-home-relative path) match an operator glob? +_mo_matches() { + local path="$1" i n=${#_MO_KIND[@]} re pat + for (( i = 0; i < n; i++ )); do + if [[ "${_MO_KIND[i]}" == exact ]]; then + pat="${_MO_EXACT[i]}" + [[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0 + else + re="${_MO_RE[i]}" + [[ "$path" =~ $re ]] && return 0 + fi + done + return 1 +} + +# Fork-free: does $1 match a framework glob? +_mf_matches() { + local path="$1" i n=${#_MF_KIND[@]} re pat + for (( i = 0; i < n; i++ )); do + if [[ "${_MF_KIND[i]}" == exact ]]; then + pat="${_MF_EXACT[i]}" + [[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0 + else + re="${_MF_RE[i]}" + [[ "$path" =~ $re ]] && return 0 + fi + done + return 1 +} + +# The installer hot path — no subshell. rc 0 = framework-owned, rc 1 = operator +# (deny-wins / fail-safe). Assumes an already-clean POSIX relative path. +manifest_is_framework() { + _mo_matches "$1" && return 1 + _mf_matches "$1" && return 0 + return 1 +} + +# Echo the ownership of a path: framework | operator. Normalizes first, so it is +# safe for CLI / test callers passing unnormalized input. +manifest_resolve() { + local path; path="$(_manifest_norm "$1")" + if manifest_is_framework "$path"; then echo framework; else echo operator; fi +} + +# Echo each shipped framework subtree root (a `dir/**` entry, without the /**). +manifest_subtree_roots() { + local r + for r in "${_MF_ROOTS[@]:-}"; do [[ -n "$r" ]] && printf '%s\n' "$r"; done +} + +# CLI dispatch — only when executed directly, never when sourced. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + set -o pipefail + # Propagate a fail-closed manifest_load (missing/empty/malformed) as a non-zero + # exit instead of continuing to resolve against empty compiled arrays — that is + # what lets the parity test assert bash and TS reject the same bad inputs (#791 B2). + manifest_load "${MANIFEST_FILE:-}" || exit 1 + cmd="${1:-}" + case "$cmd" in + resolve) manifest_resolve "${2:?path required}" ;; + subtree-roots) manifest_subtree_roots ;; + classify) + while IFS= read -r p; do + [[ -z "$p" ]] && continue + printf '%s\t%s\n' "$(manifest_resolve "$p")" "$p" + done + ;; + *) + echo "usage: manifest.sh {resolve |subtree-roots|classify}" >&2 + exit 2 + ;; + esac +fi diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets b/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets index f8e79f09..363fab78 100755 --- a/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets +++ b/packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets @@ -4,6 +4,22 @@ set -euo pipefail MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" backup_stamp="$(date +%Y%m%d%H%M%S)" +# ─── Install-ordering guard opt-out (#869 Point-1 C2) ─────────────────────── +# Explicit, per-invocation CLI flag ONLY — deliberately NOT read from an +# environment variable, so it can never sit as a silently-inherited default in +# a shell profile or CI env. Absent (the default) => hard fail-loud path. +allow_inactive_enforcement=0 +for arg in "$@"; do + case "$arg" in + --allow-inactive-enforcement) allow_inactive_enforcement=1 ;; + esac +done + +# Tracks whether the Claude settings install-ordering guard (below) reported a +# degraded (enforcement-not-wired) outcome, so this script's own exit status +# reflects it even though the rest of the runtime-asset sync must still run. +guard_degraded=0 + copy_file_managed() { local src="$1" local dst="$2" @@ -24,6 +40,103 @@ copy_file_managed() { cp "$src" "$dst" } +# ─── Install-ordering guard for settings.json (#869 Point-1 C2) ───────────── +# +# settings.json is where #828's enforcement hooks (PreToolUse mutator-gate.py, +# Stop receipt-observer-client.py) get wired unconditionally. Before copying +# it, delegate to `mosaic __link-claude-settings` (packages/mosaic/src/commands/ +# install-ordering-guard.ts) so the wiring decision is made by importing the +# C1 activation probe (`leaseEnforcementActivatable()`) directly, rather than +# re-implementing the capability/supervisor checks in shell. That subcommand: +# - activatable -> writes settings.json with hooks intact, exits 0 +# - NOT activatable -> writes settings.json with hooks STRIPPED, +# prints an actionable message, exits 1 +# - NOT activatable + opt-out -> writes settings.json with hooks intact, +# prints a loud warning, exits 0 +# The `mosaic` CLI is expected on PATH at this point ("No executables are +# placed on PATH — the mosaic npm CLI is the only binary", per install.sh). +# If it is not resolvable at all, that is itself strong evidence the +# activation half is absent, so the same fail-loud default applies via a +# minimal python3 fallback (this repo already depends on python3 for the +# lease broker itself). +copy_claude_settings_guarded() { + local src="$1" + local dst="$2" + + local guard_args=(__link-claude-settings "$src" "$dst") + if [[ "$allow_inactive_enforcement" == "1" ]]; then + guard_args+=(--allow-inactive-enforcement) + fi + + if command -v mosaic >/dev/null 2>&1; then + if mosaic "${guard_args[@]}"; then + return 0 + fi + echo "[mosaic-link] Enforcement hooks were NOT wired into $dst (see message above)." >&2 + guard_degraded=1 + return 0 + fi + + echo "[mosaic-link] ERROR: 'mosaic' CLI not found on PATH — cannot confirm lease-enforcement" >&2 + echo "[mosaic-link] activation capability. enforcement requested but activation half absent —" >&2 + echo "[mosaic-link] needs a published CLI carrying launch-runtime activation + a broker" >&2 + echo "[mosaic-link] supervisor; refusing to wire a dead gate (see #869)." >&2 + + if [[ "$allow_inactive_enforcement" == "1" ]]; then + echo "[mosaic-link] WARNING: --allow-inactive-enforcement set — wiring $dst AS-IS (with" >&2 + echo "[mosaic-link] enforcement hooks) despite being unable to confirm activation." >&2 + copy_file_managed "$src" "$dst" + return 0 + fi + + mkdir -p "$(dirname "$dst")" + if command -v python3 >/dev/null 2>&1; then + python3 - "$src" "$dst" <<'PYEOF' +import json, sys + +src, dest = sys.argv[1], sys.argv[2] +with open(src) as f: + data = json.load(f) + +hooks = data.get("hooks", {}) + +pre = hooks.get("PreToolUse", []) +hooks["PreToolUse"] = [ + t for t in pre + if not any("mutator-gate.py" in h.get("command", "") for h in t.get("hooks", [])) +] +if not hooks["PreToolUse"]: + del hooks["PreToolUse"] + +stop = hooks.get("Stop", []) +new_stop = [] +for t in stop: + kept = [h for h in t.get("hooks", []) if "receipt-observer-client.py" not in h.get("command", "")] + if kept: + t = dict(t) + t["hooks"] = kept + new_stop.append(t) +if new_stop: + hooks["Stop"] = new_stop +elif "Stop" in hooks: + del hooks["Stop"] + +if hooks: + data["hooks"] = hooks +else: + data.pop("hooks", None) + +with open(dest, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") +PYEOF + else + cp "$src" "$dst" + fi + guard_degraded=1 + return 0 +} + remove_legacy_path() { local p="$1" @@ -110,6 +223,13 @@ for runtime_file in \ fi src="$MOSAIC_HOME/runtime/claude/$runtime_file" [[ -f "$src" ]] || continue + if [[ "$runtime_file" == "settings.json" ]]; then + # Install-ordering guard (#869 Point-1 C2): gate enforcement-hook wiring + # on confirmed activation instead of the plain copy_file_managed used for + # every other runtime file. See copy_claude_settings_guarded() above. + copy_claude_settings_guarded "$src" "$HOME/.claude/$runtime_file" + continue + fi copy_file_managed "$src" "$HOME/.claude/$runtime_file" done @@ -167,3 +287,12 @@ fi echo "[mosaic-link] Runtime assets synced (non-symlink mode)" echo "[mosaic-link] Canonical source: $MOSAIC_HOME" + +# Propagate the install-ordering guard's outcome (#869 Point-1 C2): every +# other runtime asset above is best-effort/non-fatal, but a degraded +# (enforcement-not-wired) settings.json must make THIS script's own exit +# status non-zero so callers (framework/install.sh, finalize.ts) can surface +# it — never silently. +if [[ "$guard_degraded" == "1" ]]; then + exit 1 +fi diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills b/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills index cf6af7c1..c4c3c202 100755 --- a/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills +++ b/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills @@ -161,6 +161,7 @@ link_targets=( ) canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")" +local_real="$(readlink -f "$MOSAIC_LOCAL_SKILLS_DIR")" # Build an associative array from the colon-separated whitelist for O(1) lookup. # When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed. @@ -203,7 +204,14 @@ link_skill_into_target() { link_path="$target_dir/$name" if [[ -L "$link_path" ]]; then - ln -sfn "$skill_path" "$link_path" + local raw_target resolved_target + raw_target="$(readlink "$link_path")" + resolved_target="$(node -e 'const p=require("node:path"); process.stdout.write(p.resolve(p.dirname(process.argv[1]), process.argv[2]));' "$link_path" "$raw_target")" + if [[ "$resolved_target" == "$canonical_real/"* || "$resolved_target" == "$local_real/"* ]]; then + ln -sfn "$skill_path" "$link_path" + else + echo "[mosaic-skills] Preserve foreign runtime symlink: $link_path" + fi return fi @@ -234,14 +242,10 @@ prune_stale_links_in_target() { continue fi - resolved="$(readlink -f "$link_path" 2>/dev/null || true)" - if [[ -z "$resolved" ]]; then - rm -f "$link_path" - echo "[mosaic-skills] Removed stale broken skill link: $link_path" - continue - fi - - if [[ "$resolved" == "$MOSAIC_HOME/"* ]]; then + # -m resolves lexical dangling targets too. If resolution fails, ownership + # is unproven and the link must be preserved. + resolved="$(readlink -m "$link_path" 2>/dev/null || true)" + if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then rm -f "$link_path" echo "[mosaic-skills] Removed stale retired skill link: $link_path" fi diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills.ps1 b/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills.ps1 index c2a8fd51..8bf30c5a 100644 --- a/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills.ps1 +++ b/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills.ps1 @@ -79,9 +79,26 @@ function Link-SkillIntoTarget { $linkPath = Join-Path $TargetDir $name - # Already a junction/symlink — recreate + # Recreate only Mosaic-owned junctions/symlinks. Foreign reparse points are + # runtime-owned and must never be clobbered by install/upgrade auto-sync. $existing = Get-Item $linkPath -Force -ErrorAction SilentlyContinue if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + $rawTarget = @($existing.Target)[0] + $candidate = if ([System.IO.Path]::IsPathRooted($rawTarget)) { + $rawTarget + } + else { + Join-Path (Split-Path $linkPath -Parent) $rawTarget + } + $resolvedTarget = [System.IO.Path]::GetFullPath($candidate) + $canonicalRoot = [System.IO.Path]::GetFullPath($MosaicSkillsDir).TrimEnd('\') + '\' + $localRoot = [System.IO.Path]::GetFullPath($MosaicLocalSkillsDir).TrimEnd('\') + '\' + $owned = $resolvedTarget.StartsWith($canonicalRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + $resolvedTarget.StartsWith($localRoot, [System.StringComparison]::OrdinalIgnoreCase) + if (-not $owned) { + Write-Host "[mosaic-skills] Preserve foreign runtime symlink: $linkPath" + return + } Remove-Item $linkPath -Force } elseif ($existing) { diff --git a/packages/mosaic/framework/tools/_scripts/test-install-ordering-guard.sh b/packages/mosaic/framework/tools/_scripts/test-install-ordering-guard.sh new file mode 100644 index 00000000..7bc82fd8 --- /dev/null +++ b/packages/mosaic/framework/tools/_scripts/test-install-ordering-guard.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# Regression harness for issue #869 Point-1 C2 — the install-ordering guard +# wired into mosaic-link-runtime-assets. +# +# Root cause under test: mosaic-link-runtime-assets copies +# runtime/claude/settings.json (which embeds the PreToolUse mutator-gate.py +# hook and the Stop receipt-observer-client.py hook) straight into +# ~/.claude/settings.json, unconditionally. If the lease-broker activation +# half cannot be confirmed on this host, wiring those hooks bricks it with a +# fail-closed gate that can never be satisfied. +# +# This harness never invokes a real `mosaic` CLI build — it stubs the +# `__link-claude-settings` contract with a fake `mosaic` on PATH so the shell +# WIRING (does mosaic-link-runtime-assets call out correctly? does it +# propagate a degraded outcome? does it still copy every other runtime file? +# does --allow-inactive-enforcement forward through?) is exercised +# independently of the TS guard's own logic (already covered by +# install-ordering-guard.spec.ts). It also exercises the no-mosaic-on-PATH +# python3 fallback directly. +# +# Scenarios: +# 1. probe=true (fake mosaic exits 0) -> settings.json copied, script exits 0. +# 2. probe=false (fake mosaic exits 1) -> script exits 1 (guard_degraded +# propagated), but every OTHER runtime file is still copied. +# 3. probe=false + --allow-inactive-enforcement -> the flag is forwarded to +# the fake mosaic stub. +# 4. No `mosaic` on PATH at all (activation unconfirmable) -> the python3 +# fallback strips the enforcement hooks itself and the script exits 1. +# 5. No `mosaic` on PATH + --allow-inactive-enforcement -> the python3 +# fallback wires the hooks AS-IS and the script exits 0. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LINK_SCRIPT="$SCRIPT_DIR/mosaic-link-runtime-assets" + +TMP_ROOT=$(mktemp -d) +trap 'rm -rf "$TMP_ROOT"' EXIT + +fail=0 +fail_msg() { + echo "FAIL: $*" >&2 + fail=1 +} + +FIXTURE_SETTINGS='{ + "model": "opus", + "hooks": { + "PreToolUse": [ + { "matcher": ".*", "hooks": [ { "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude" } ] }, + { "matcher": "Write|Edit|MultiEdit", "hooks": [ { "type": "command", "command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh" } ] } + ], + "Stop": [ + { "hooks": [ + { "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude" }, + { "type": "command", "command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh" } + ] } + ] + } +}' + +# Sets up a fresh $MOSAIC_HOME/runtime/claude/{settings.json,CLAUDE.md, +# hooks-config.json,context7-integration.md} + fresh $HOME, echoes both paths +# space-separated for the caller to `read`. +new_scenario_dirs() { + local scenario="$1" + local base="$TMP_ROOT/$scenario" + local mosaic_home="$base/mosaic-home" + local home="$base/home" + mkdir -p "$mosaic_home/runtime/claude" "$home" + printf '%s' "$FIXTURE_SETTINGS" > "$mosaic_home/runtime/claude/settings.json" + echo "claude.md fixture" > "$mosaic_home/runtime/claude/CLAUDE.md" + echo '{"hooks":{}}' > "$mosaic_home/runtime/claude/hooks-config.json" + echo "context7 fixture" > "$mosaic_home/runtime/claude/context7-integration.md" + echo "$mosaic_home" "$home" +} + +settings_has_marker() { + local file="$1" marker="$2" + [[ -f "$file" ]] && grep -q "$marker" "$file" +} + +# A fake `mosaic` binary implementing only the __link-claude-settings contract +# this harness needs: writes dest verbatim (fixture is unmodified either way — +# this stub only exercises the CALL CONTRACT, not the TS strip logic, which +# has its own vitest coverage) and exits with the code the scenario wants. +# Records the args it was called with so the harness can assert forwarding. +make_fake_mosaic() { + local bin_dir="$1" exit_code="$2" + mkdir -p "$bin_dir" + cat > "$bin_dir/mosaic" < "$bin_dir/mosaic.args" +if [[ "\$1" == "__link-claude-settings" ]]; then + cp "\$2" "\$3" + exit $exit_code +fi +exit 0 +EOF + chmod +x "$bin_dir/mosaic" +} + +# --- Scenario 1: probe=true (fake mosaic exits 0) --------------------------- +read -r MOSAIC_HOME_1 HOME_1 < <(new_scenario_dirs scenario1) +BIN_1="$TMP_ROOT/scenario1/bin" +make_fake_mosaic "$BIN_1" 0 + +OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_1" HOME="$HOME_1" PATH="$BIN_1:$PATH" "$LINK_SCRIPT" 2>&1) +STATUS=$? +[[ "$STATUS" -eq 0 ]] || fail_msg "scenario1 (probe=true): expected exit 0, got $STATUS. Output: $OUTPUT" +[[ -f "$HOME_1/.claude/settings.json" ]] || fail_msg "scenario1: settings.json was not copied" + +# --- Scenario 2: probe=false (fake mosaic exits 1) -------------------------- +read -r MOSAIC_HOME_2 HOME_2 < <(new_scenario_dirs scenario2) +BIN_2="$TMP_ROOT/scenario2/bin" +make_fake_mosaic "$BIN_2" 1 + +OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_2" HOME="$HOME_2" PATH="$BIN_2:$PATH" "$LINK_SCRIPT" 2>&1) +STATUS=$? +[[ "$STATUS" -ne 0 ]] || fail_msg "scenario2 (probe=false, default): expected non-zero exit, got 0. Output: $OUTPUT" +[[ -f "$HOME_2/.claude/CLAUDE.md" ]] || fail_msg "scenario2: CLAUDE.md was NOT copied even though it is independent of the settings.json guard" +[[ -f "$HOME_2/.claude/hooks-config.json" ]] || fail_msg "scenario2: hooks-config.json was NOT copied" +[[ -f "$HOME_2/.claude/context7-integration.md" ]] || fail_msg "scenario2: context7-integration.md was NOT copied" +case "$OUTPUT" in + *"NOT be wired"*|*"NOT wired"*) ;; + *) fail_msg "scenario2: expected an actionable degraded-wiring message in output, got: $OUTPUT" ;; +esac + +# --- Scenario 3: probe=false + --allow-inactive-enforcement forwards the flag +read -r MOSAIC_HOME_3 HOME_3 < <(new_scenario_dirs scenario3) +BIN_3="$TMP_ROOT/scenario3/bin" +make_fake_mosaic "$BIN_3" 0 + +MOSAIC_HOME="$MOSAIC_HOME_3" HOME="$HOME_3" PATH="$BIN_3:$PATH" "$LINK_SCRIPT" --allow-inactive-enforcement >/dev/null 2>&1 +RECORDED_ARGS="$(cat "$BIN_3/mosaic.args" 2>/dev/null || true)" +case "$RECORDED_ARGS" in + *"--allow-inactive-enforcement"*) ;; + *) fail_msg "scenario3: --allow-inactive-enforcement was not forwarded to the mosaic CLI invocation (got: '$RECORDED_ARGS')" ;; +esac + +# --- Scenario 4: no `mosaic` on PATH at all -> python3 fallback strips hooks +read -r MOSAIC_HOME_4 HOME_4 < <(new_scenario_dirs scenario4) +EMPTY_BIN="$TMP_ROOT/scenario4/empty-bin" +mkdir -p "$EMPTY_BIN" +# A PATH containing only python3 (for the fallback) + core utils, no mosaic. +FALLBACK_PATH="$EMPTY_BIN:/usr/bin:/bin" + +OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_4" HOME="$HOME_4" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" 2>&1) +STATUS=$? +[[ "$STATUS" -ne 0 ]] || fail_msg "scenario4 (no mosaic on PATH, default): expected non-zero exit, got 0. Output: $OUTPUT" +if settings_has_marker "$HOME_4/.claude/settings.json" "mutator-gate.py"; then + fail_msg "scenario4: mutator-gate.py hook was wired even though mosaic could not be resolved (activation unconfirmable)" +fi +if settings_has_marker "$HOME_4/.claude/settings.json" "receipt-observer-client.py"; then + fail_msg "scenario4: receipt-observer-client.py hook was wired even though mosaic could not be resolved" +fi +if ! settings_has_marker "$HOME_4/.claude/settings.json" "prevent-memory-write.sh"; then + fail_msg "scenario4: the unrelated prevent-memory-write.sh hook was incorrectly dropped too" +fi + +# --- Scenario 5: no `mosaic` on PATH + --allow-inactive-enforcement -------- +read -r MOSAIC_HOME_5 HOME_5 < <(new_scenario_dirs scenario5) + +OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_5" HOME="$HOME_5" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" --allow-inactive-enforcement 2>&1) +STATUS=$? +[[ "$STATUS" -eq 0 ]] || fail_msg "scenario5 (no mosaic, opt-out): expected exit 0, got $STATUS. Output: $OUTPUT" +if ! settings_has_marker "$HOME_5/.claude/settings.json" "mutator-gate.py"; then + fail_msg "scenario5: mutator-gate.py hook should have been wired (explicit opt-out set)" +fi +case "$OUTPUT" in + *"WARNING"*"--allow-inactive-enforcement"*) ;; + *) fail_msg "scenario5: expected a loud WARNING mentioning --allow-inactive-enforcement, got: $OUTPUT" ;; +esac + +if [[ "$fail" -eq 0 ]]; then + echo "install-ordering-guard regression passed (5/5 scenarios)" +fi + +exit "$fail" diff --git a/packages/mosaic/framework/tools/codex/README.md b/packages/mosaic/framework/tools/codex/README.md index b01ee753..03eb8343 100644 --- a/packages/mosaic/framework/tools/codex/README.md +++ b/packages/mosaic/framework/tools/codex/README.md @@ -70,6 +70,8 @@ Security vulnerability review focusing on: ~/.config/mosaic/tools/codex/codex-security-review.sh -n 42 ``` +PR mode resolves the provider's PR diff rather than relying on the caller's checked-out branch. On Gitea, it fetches the base and `refs/pull//head` refs and diffs those explicit refs. If the refs cannot be fetched or the resulting diff is empty, the command exits nonzero before Codex runs or a review is posted. + ### Review Against Base Branch ```bash @@ -253,7 +255,7 @@ Run the script from inside a git repository. ### "No changes found to review" -The specified mode (--uncommitted, --base, etc.) found no changes to review. +The specified non-PR mode (`--uncommitted`, `--base`, etc.) found no changes to review. PR mode instead fails closed with an actionable error when it cannot construct a non-empty provider diff; verify the PR number, remote, provider login, and ref access before retrying. ### "Codex produced no output" diff --git a/packages/mosaic/framework/tools/codex/common.sh b/packages/mosaic/framework/tools/codex/common.sh index 197f1ec8..22414057 100755 --- a/packages/mosaic/framework/tools/codex/common.sh +++ b/packages/mosaic/framework/tools/codex/common.sh @@ -44,38 +44,47 @@ build_diff_context() { diff_text=$(git show "$value" 2>/dev/null) ;; pr) - # For PRs, we need to fetch the PR diff - detect_platform + # Provider detection writes its result to stdout; suppress it so it cannot + # be mistaken for diff content when this function is used in a substitution. + detect_platform >/dev/null if [[ "$PLATFORM" == "github" ]]; then - diff_text=$(gh pr diff "$value" 2>/dev/null) + diff_text=$(gh pr diff "$value" 2>/dev/null) || { + echo "Error: Failed to fetch the diff for PR #${value}." >&2 + return 1 + } elif [[ "$PLATFORM" == "gitea" ]]; then - # tea doesn't have a direct pr diff command, use git - local pr_base - pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | grep "^${value}" | awk '{print $2}') - if [[ -n "$pr_base" ]]; then - diff_text=$(git diff "${pr_base}...HEAD" 2>/dev/null) - else - # Fallback: fetch PR info via API - local repo_info - repo_info=$(get_repo_info) - local remote_url - remote_url=$(git remote get-url origin 2>/dev/null) - local host - host=$(echo "$remote_url" | sed -E 's|.*://([^/]+).*|\1|; s|.*@([^:]+).*|\1|') - diff_text=$(curl -s "https://${host}/api/v1/repos/${repo_info}/pulls/${value}" \ - -H "Authorization: token $(tea login list --output simple 2>/dev/null | head -1 | awk '{print $2}')" \ - 2>/dev/null | jq -r '.diff_url // empty') - if [[ -n "$diff_text" && "$diff_text" != "null" ]]; then - diff_text=$(curl -s "$diff_text" 2>/dev/null) - else - diff_text=$(git diff "main...HEAD" 2>/dev/null) - fi + local pr_base base_ref pr_head_ref + pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | awk -v pr="$value" '$1 == pr { print $2; exit }') + if [[ -z "$pr_base" ]]; then + echo "Error: Could not resolve the base branch for Gitea PR #${value}." >&2 + return 1 fi + + base_ref="refs/remotes/origin/${pr_base}" + pr_head_ref="refs/remotes/origin/pr/${value}/head" + if ! git fetch --quiet origin \ + "+refs/heads/${pr_base}:${base_ref}" \ + "+refs/pull/${value}/head:${pr_head_ref}"; then + echo "Error: Failed to fetch the base and head refs for Gitea PR #${value}." >&2 + return 1 + fi + diff_text=$(git diff "${base_ref}...${pr_head_ref}") || { + echo "Error: Failed to diff the fetched refs for Gitea PR #${value}." >&2 + return 1 + } + else + echo "Error: Unsupported git platform while resolving PR #${value}." >&2 + return 1 fi ;; esac - echo "$diff_text" + if [[ "$mode" == "pr" && -z "${diff_text//[[:space:]]/}" ]]; then + echo "Error: Unable to construct a non-empty diff for PR #${value}; verify the PR refs and provider access." >&2 + return 1 + fi + + printf '%s\n' "$diff_text" } # Format JSON findings as markdown for PR comments diff --git a/packages/mosaic/framework/tools/codex/test-pr-diff-context.sh b/packages/mosaic/framework/tools/codex/test-pr-diff-context.sh new file mode 100644 index 00000000..51893581 --- /dev/null +++ b/packages/mosaic/framework/tools/codex/test-pr-diff-context.sh @@ -0,0 +1,158 @@ +#!/bin/bash +# Hermetic regression coverage for Gitea PR diff construction and fail-closed reviews. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TMP_DIR=$(mktemp -d) +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { + echo "not ok - $*" >&2 + exit 1 +} + +assert_contains() { + local haystack="$1" needle="$2" + if [[ "$haystack" != *"$needle"* ]]; then + printf 'actual output:\n%s\n' "$haystack" >&2 + fail "expected output to contain: $needle" + fi +} + +# Prevent CI-provided repository context from leaking into the fixture repositories. +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR +export GIT_AUTHOR_NAME="Codex Fixture" +export GIT_AUTHOR_EMAIL="codex-fixture@example.test" +export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME" +export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL" +export GITEA_LOGIN="fixture" +export GITEA_TOKEN="fixture-token" +export GITEA_URL="file://$TMP_DIR" + +create_pr_fixture() { + local fixture_root="$1" head_mode="$2" + local origin="$fixture_root/origin.git" + local seed="$fixture_root/seed" + local work="$fixture_root/work" + local base_sha head_sha + + mkdir -p "$fixture_root" + git init --quiet --bare "$origin" + git init --quiet --initial-branch=release/next "$seed" + printf 'base\n' > "$seed/pr-change.ts" + git -C "$seed" add pr-change.ts + git -C "$seed" commit --quiet -m "fixture base" + base_sha=$(git -C "$seed" rev-parse HEAD) + git -C "$seed" remote add origin "$origin" + git -C "$seed" push --quiet origin release/next + git --git-dir="$origin" symbolic-ref HEAD refs/heads/release/next + + if [[ "$head_mode" == "changed" ]]; then + git -C "$seed" switch --quiet -c feature/pr-795 + printf 'actual-pr-change\n' > "$seed/pr-change.ts" + git -C "$seed" commit --quiet -am "fixture PR head" + head_sha=$(git -C "$seed" rev-parse HEAD) + git -C "$seed" push --quiet origin HEAD:refs/pull/795/head + else + head_sha="$base_sha" + git --git-dir="$origin" update-ref refs/pull/795/head "$head_sha" + fi + + # Gitea's provider-owned PR head ref now exists in the local bare origin. + git clone --quiet "$origin" "$work" + printf '%s\n' "$work" +} + +FAKE_BIN="$TMP_DIR/bin" +mkdir -p "$FAKE_BIN" +cat > "$FAKE_BIN/tea" <<'STUB' +#!/bin/bash +if [[ "$*" == "pr list --fields index,base --output simple" ]]; then + printf '795 release/next\n' + exit 0 +fi +exit 1 +STUB +cat > "$FAKE_BIN/codex" <<'STUB' +#!/bin/bash +printf 'CODEX %s\n' "$*" >> "$CODEX_LOG" +exit 99 +STUB +chmod +x "$FAKE_BIN/tea" "$FAKE_BIN/codex" +export PATH="$FAKE_BIN:$PATH" + +# The valid fixture is a fresh clone on the non-main base. The PR head exists only +# at refs/pull/795/head, so local HEAD cannot accidentally satisfy the assertion. +if [[ "${1:-all}" != "fail-closed" ]]; then + VALID_WORK=$(create_pr_fixture "$TMP_DIR/valid" changed) + ( + cd "$VALID_WORK" + # shellcheck source=common.sh + source "$SCRIPT_DIR/common.sh" + diff_context=$(build_diff_context pr 795) + assert_contains "$diff_context" "actual-pr-change" + + base_sha=$(git rev-parse refs/remotes/origin/release/next) + head_sha=$(git rev-parse refs/remotes/origin/pr/795/head) + local_sha=$(git rev-parse HEAD) + [[ "$local_sha" == "$base_sha" ]] || fail "fixture clone is not on the PR base" + [[ "$head_sha" != "$base_sha" ]] || fail "fixture PR head does not differ from its base" + git show-ref --verify --quiet refs/remotes/origin/pr/795/head || \ + fail "fetched PR head ref is missing" + [[ "$(git diff --name-only "${base_sha}...${head_sha}")" == "pr-change.ts" ]] || \ + fail "explicit PR refs do not contain the fixture change" + if git show-ref --verify --quiet refs/heads/main || \ + git show-ref --verify --quiet refs/remotes/origin/main; then + fail "fixture unexpectedly contains a main ref" + fi + ) + echo "ok - Gitea PR mode fetches and diffs explicit non-main base and PR head refs" +fi + +# Build an empty PR entirely inside another local repository. Both review wrappers +# must emit the PR-numbered error before Codex or the stubbed post path can execute. +if [[ "${1:-all}" != "pr-head" ]]; then + EMPTY_WORK=$(create_pr_fixture "$TMP_DIR/empty" empty) + SANDBOX="$TMP_DIR/sandbox" + mkdir -p "$SANDBOX/tools/codex/schemas" "$SANDBOX/tools/git" + cp "$SCRIPT_DIR/common.sh" \ + "$SCRIPT_DIR/codex-code-review.sh" \ + "$SCRIPT_DIR/codex-security-review.sh" \ + "$SANDBOX/tools/codex/" + cp "$SCRIPT_DIR/schemas/code-review-schema.json" \ + "$SCRIPT_DIR/schemas/security-review-schema.json" \ + "$SANDBOX/tools/codex/schemas/" + cp "$SCRIPT_DIR/../git/detect-platform.sh" "$SANDBOX/tools/git/" + + cat > "$SANDBOX/tools/git/pr-review.sh" <<'STUB' +#!/bin/bash +printf 'POST %s\n' "$*" >> "$POST_LOG" +STUB + chmod +x "$SANDBOX/tools/git/pr-review.sh" + + POST_LOG="$TMP_DIR/post.log" + CODEX_LOG="$TMP_DIR/codex.log" + export POST_LOG CODEX_LOG + + for review_kind in code security; do + : > "$POST_LOG" + : > "$CODEX_LOG" + review_script="$SANDBOX/tools/codex/codex-${review_kind}-review.sh" + set +e + ( + cd "$EMPTY_WORK" + "$review_script" -n 795 + ) >"$TMP_DIR/${review_kind}.stdout" 2>"$TMP_DIR/${review_kind}.stderr" + review_status=$? + set -e + + stderr_text=$(cat "$TMP_DIR/${review_kind}.stderr") + [[ "$review_status" -ne 0 ]] || fail "${review_kind} review returned success for an empty PR diff" + [[ ! -s "$CODEX_LOG" ]] || fail "Codex ran for an empty ${review_kind} PR diff" + [[ ! -s "$POST_LOG" ]] || fail "${review_kind} review auto-post ran for an empty PR diff" + assert_contains "$stderr_text" "Error:" + assert_contains "$stderr_text" "PR #795" + echo "ok - empty ${review_kind} PR diff fails closed before Codex and auto-post" + done +fi diff --git a/packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh b/packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh new file mode 100755 index 00000000..9f31e0aa --- /dev/null +++ b/packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +AGENT_NAME=${MOSAIC_AGENT_NAME:-} +RUNTIME=${MOSAIC_AGENT_RUNTIME:-} +MODEL=${MOSAIC_AGENT_MODEL:-} +REASONING=${MOSAIC_AGENT_REASONING:-} +TOOL_POLICY=${MOSAIC_AGENT_TOOL_POLICY:-} + +[ -n "$AGENT_NAME" ] || { echo 'ERROR: MOSAIC_AGENT_NAME is required' >&2; exit 64; } +[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo 'ERROR: invalid agent name' >&2; exit 64; } +[ "$RUNTIME" = 'pi' ] || { echo 'ERROR: invalid runtime policy' >&2; exit 64; } +[ "$MODEL" = 'openai/gpt-5.6-sol' ] || { echo 'ERROR: invalid model policy' >&2; exit 64; } +[ "$REASONING" = 'high' ] || { echo 'ERROR: invalid reasoning policy' >&2; exit 64; } +[ "$TOOL_POLICY" = 'operator-interaction' ] || { echo 'ERROR: invalid tool policy' >&2; exit 64; } + +printf '{"agentName":"%s","runtime":"%s","model":"%s","reasoning":"%s","toolPolicy":"%s"}\n' \ + "$AGENT_NAME" "$RUNTIME" "$MODEL" "$REASONING" "$TOOL_POLICY" diff --git a/packages/mosaic/framework/tools/fleet/start-agent-session.sh b/packages/mosaic/framework/tools/fleet/start-agent-session.sh index 7bb9731b..60e73d78 100755 --- a/packages/mosaic/framework/tools/fleet/start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/start-agent-session.sh @@ -1,30 +1,207 @@ #!/usr/bin/env bash set -euo pipefail -AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} -# Absent socket ⇒ the LITERAL default tmux socket (no -L). The roster's -# socket_name is honored when set; absent never silently becomes mosaic-fleet -# (spawn stays consistent with the onboarding cheat-sheet + fleet ps observe). -MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-} -MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi} -MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-} -MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME} -MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-} -MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run} -MOSAIC_HEARTBEAT_INTERVAL=${MOSAIC_HEARTBEAT_INTERVAL:-15} +# FCM-M2-001 boundary: only a roster-derived .env.generated projection and a +# separately parsed data-only .env.local can influence launch. Never source an +# environment file and never accept a command string from either file. -if [ -z "$AGENT_NAME" ]; then - echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2 +MODE=launch +case "${1:-}" in + --stop) + MODE=stop + AGENT_NAME=${2:-} + ;; + --interaction) + MODE=interaction + AGENT_NAME=${2:-} + ;; + *) AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} ;; +esac +MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic} + +fail() { + echo "ERROR: $*" >&2 exit 64 -fi +} + +hash_value() { + printf '%s' "$1" | sha256sum | awk '{print $1}' +} + +fail_env() { + local code="$1" + local key="$2" + local value="$3" + echo "ERROR: agent environment rejected: code=${code} key=${key} sha256=$(hash_value "$value")" >&2 + exit 64 +} + +safe_agent_name() { + [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] +} + +safe_policy_name() { + [[ "$1" =~ ^[a-z][a-z0-9-]*$ ]] +} + +safe_path() { + [[ "$1" == /* ]] || return 1 + [[ "$1" != *".."* ]] || return 1 + [[ ! "$1" =~ [[:space:]\"\'\`\$\\\;\|\&\<\>\(\)\{\}] ]] +} + +assert_private_regular_file() { + local file="$1" + [ -f "$file" ] && [ ! -L "$file" ] || fail_env unsafe-file '(file)' "$file" + local mode + mode=$(stat -c '%a' -- "$file") || fail_env unsafe-file '(file)' "$file" + (( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(file)' "$file" +} + +assert_managed_directory() { + local directory="$1" + [ -d "$directory" ] && [ ! -L "$directory" ] || fail_env unsafe-directory '(directory)' "$directory" + local mode + mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory" + (( (8#$mode & 8#022) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory" +} + +assert_private_directory() { + local directory="$1" + assert_managed_directory "$directory" + local mode + mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory" + (( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory" +} + +[ -n "$AGENT_NAME" ] || fail "agent name argument or MOSAIC_AGENT_NAME is required" +safe_agent_name "$AGENT_NAME" || fail_env unsafe-agent-name MOSAIC_AGENT_NAME "$AGENT_NAME" +safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME" + +FLEET_DIR="$MOSAIC_HOME/fleet" +AGENT_ENV_DIR="$FLEET_DIR/agents" +assert_managed_directory "$MOSAIC_HOME" +assert_managed_directory "$FLEET_DIR" +assert_private_directory "$AGENT_ENV_DIR" + +GENERATED_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.generated" +LOCAL_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.local" + +declare -A GENERATED_VALUES=() +declare -A LOCAL_VALUES=() +declare -A SEEN_KEYS=() + +is_sensitive_key() { + [[ "$1" =~ (API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN) ]] +} + +is_generated_key() { + case "$1" in + MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;; + *) return 1 ;; + esac +} + +is_local_key() { + case "$1" in + MOSAIC_RUNTIME_BIN|MOSAIC_HEARTBEAT_RUN_DIR|MOSAIC_HEARTBEAT_INTERVAL|MOSAIC_CLAUDE_JSON|CLAUDE_CONFIG_DIR) return 0 ;; + *) return 1 ;; + esac +} + +validate_generated_value() { + local key="$1" + local value="$2" + case "$key" in + MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;; + MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;; + MOSAIC_AGENT_RUNTIME) + case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac + ;; + MOSAIC_AGENT_MODEL) [[ "$value" =~ ^[A-Za-z0-9._/:+-]*$ ]] || fail_env unsafe-model "$key" "$value" ;; + MOSAIC_AGENT_REASONING) + case "$value" in ''|low|medium|high) ;; *) fail_env unsupported-reasoning "$key" "$value" ;; esac + ;; + MOSAIC_AGENT_TOOL_POLICY) [ -z "$value" ] || safe_policy_name "$value" || fail_env unsafe-tool-policy "$key" "$value" ;; + MOSAIC_AGENT_WORKDIR) safe_path "$value" || fail_env unsafe-path "$key" "$value" ;; + MOSAIC_TMUX_SOCKET) [[ "$value" =~ ^[A-Za-z0-9_.-]*$ ]] || fail_env unsafe-socket "$key" "$value" ;; + esac +} + +validate_local_value() { + local key="$1" + local value="$2" + if [ "$key" = MOSAIC_HEARTBEAT_INTERVAL ]; then + [[ "$value" =~ ^[1-9][0-9]*$ ]] || fail_env invalid-interval "$key" "$value" + else + safe_path "$value" || fail_env unsafe-path "$key" "$value" + fi +} + +load_environment_file() { + local file="$1" + local kind="$2" + [ -e "$file" ] || { + [ "$kind" = generated ] && fail_env missing-file '(generated)' "$file" + return 0 + } + assert_private_regular_file "$file" + SEEN_KEYS=() + + local line key value + while IFS= read -r line || [ -n "$line" ]; do + [ -z "$line" ] && continue + if [[ ! "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]]; then + fail_env malformed-line '(malformed)' "$line" + fi + key=${BASH_REMATCH[1]} + value=${BASH_REMATCH[2]} + [ -z "${SEEN_KEYS[$key]+set}" ] || fail_env duplicate-key "$key" "$value" + SEEN_KEYS[$key]=1 + is_sensitive_key "$key" && fail_env sensitive-key "$key" "$value" + + if [ "$kind" = generated ]; then + is_generated_key "$key" || fail_env unknown-key "$key" "$value" + validate_generated_value "$key" "$value" + GENERATED_VALUES[$key]=$value + else + is_generated_key "$key" && fail_env generated-key-shadow "$key" "$value" + is_local_key "$key" || fail_env unknown-key "$key" "$value" + validate_local_value "$key" "$value" + LOCAL_VALUES[$key]=$value + fi + done < "$file" +} + +load_environment_file "$GENERATED_ENV" generated +for required_key in \ + MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \ + MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do + [ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" '' +done +load_environment_file "$LOCAL_ENV" local + +[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \ + fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" + +MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]} +MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]} +MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]} +MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]} +MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]} +MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]} +MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]} +MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-} +MOSAIC_HEARTBEAT_RUN_DIR=${LOCAL_VALUES[MOSAIC_HEARTBEAT_RUN_DIR]:-$MOSAIC_HOME/fleet/run} +MOSAIC_HEARTBEAT_INTERVAL=${LOCAL_VALUES[MOSAIC_HEARTBEAT_INTERVAL]:-15} +MOSAIC_CLAUDE_JSON=${LOCAL_VALUES[MOSAIC_CLAUDE_JSON]:-} +CLAUDE_CONFIG_DIR=${LOCAL_VALUES[CLAUDE_CONFIG_DIR]:-} if ! command -v tmux >/dev/null 2>&1; then echo "ERROR: tmux is required" >&2 exit 69 fi -# tmux wrapper: pass -L only when a socket is configured. An absent/empty socket -# means the default tmux socket (no -L), keeping spawn == observe == cheat-sheet. _tmux() { if [ -n "$MOSAIC_TMUX_SOCKET" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" "$@" @@ -33,139 +210,90 @@ _tmux() { fi } +assert_owned_tmux_server() { + local owner_file="$MOSAIC_HOME/fleet/run/holder-owner" + [ -f "$owner_file" ] && [ ! -L "$owner_file" ] || fail "private tmux ownership identity is missing" + local owner_mode + owner_mode=$(stat -c '%a' -- "$owner_file") || fail "private tmux ownership identity is unreadable" + (( (8#$owner_mode & 8#077) == 0 )) || fail "private tmux ownership identity has unsafe permissions" + local owner + owner=$(tr -d '\n' < "$owner_file") + [[ "$owner" =~ ^[a-f0-9-]{36}$ ]] || fail "private tmux ownership identity is malformed" + _tmux has-session -t '=_holder:0.0' 2>/dev/null || fail "owned tmux holder session is absent" + local environment expected + environment=$(_tmux show-environment -g 2>/dev/null) || fail "owned tmux global environment is unreadable" + expected=$(printf '%s\n' \ + "HOME=$HOME" \ + 'PATH=/usr/bin:/bin' \ + "PWD=$HOME" \ + "MOSAIC_FLEET_OWNER=$owner" \ + 'MOSAIC_TMUX_HOLDER=_holder' \ + "MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort) + [ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \ + fail "tmux server ownership or environment validation failed" +} + +# Validate exact server ownership before querying, cleaning, or creating any +# managed session. An unmanaged or contaminated named socket is never repaired. +assert_owned_tmux_server + +if [ "$MODE" = interaction ]; then + [ "$MOSAIC_AGENT_RUNTIME" = pi ] || fail "operator interaction service requires runtime pi" + [ "$MOSAIC_AGENT_MODEL" = openai/gpt-5.6-sol ] || \ + fail "operator interaction service requires the pinned model" + [ "$MOSAIC_AGENT_REASONING" = high ] || \ + fail "operator interaction service requires high reasoning" + [ "$MOSAIC_AGENT_TOOL_POLICY" = operator-interaction ] || \ + fail "operator interaction service requires the operator-interaction tool policy" +fi + +if [ "$MODE" = stop ]; then + _tmux kill-session -t "=${AGENT_NAME}" >/dev/null 2>&1 || true + exit 0 +fi + if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}" exit 0 fi -if [ -z "$MOSAIC_AGENT_COMMAND" ]; then - # Map the roster's per-agent model_hint to `--model` so workers launch on the - # configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset. - MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}" -fi +# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under +# that home. Derive the pane home from the canonical path when available so an +# inherited pane/session HOME cannot become runtime authority. +PANE_HOME=$HOME +case "$MOSAIC_HOME" in + */.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;; +esac -# ── Derive a runtime-bin PATH prefix ───────────────────────────────────────── -# Precedence: -# 1. $MOSAIC_RUNTIME_BIN (explicit override) -# 2. $(npm config get prefix)/bin (if npm is on PATH) -# 3. Fallbacks: $HOME/.npm-global/bin and $HOME/.local/bin -# -# Only directories that already exist are included. The prefix is baked into -# the pane command regardless of what the LAUNCHER process's $PATH contains, -# because the tmux pane inherits the tmux SERVER environment (not this script's -# environment). A dir on the launcher's PATH may be absent from the server PATH, -# so every existing candidate must always be included. Dedup within the -# constructed prefix avoids listing the same dir twice. _build_runtime_bin_prefix() { local candidates=() - - if [ -n "${MOSAIC_RUNTIME_BIN:-}" ]; then - candidates+=("$MOSAIC_RUNTIME_BIN") - fi - + if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi if command -v npm >/dev/null 2>&1; then local npm_prefix npm_prefix=$(npm config get prefix 2>/dev/null) || true - if [ -n "$npm_prefix" ]; then - candidates+=("${npm_prefix}/bin") - fi + if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi fi + candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin") - candidates+=("$HOME/.npm-global/bin") - candidates+=("$HOME/.local/bin") - - local prefix="" + local prefix="" dir for dir in "${candidates[@]}"; do [ -d "$dir" ] || continue - if [ -z "$prefix" ]; then - prefix="$dir" - else - case ":${prefix}:" in - *":${dir}:"*) ;; # already in our prefix — skip - *) prefix="${prefix}:${dir}" ;; - esac - fi + case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac done - printf '%s' "$prefix" } MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix) +PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin -# ── Build the pane command ──────────────────────────────────────────────────── -# The pane command must: -# - Export the augmented PATH so the runtime binary is found. -# - exec the agent command so the runtime is the pane's foreground process -# (makes `fleet ps` pane_current_command check reliable; no DRIFT false-positive). -# -# Quoting strategy: single-quote the inner shell snippet so that variable -# references in MOSAIC_AGENT_COMMAND are NOT expanded here — they expand inside -# the pane shell. However, MOSAIC_RUNTIME_BIN_PREFIX and PATH must be expanded -# NOW (in this script) because the pane shell inherits the tmux server -# environment, not this script's env. -# -# We build the snippet as a double-quoted here-string embedded in a printf call -# to avoid nested quoting problems. -# -# MOSAIC_AGENT_NAME must also be exported INTO the pane: panes inherit the tmux -# server environment (not this script's, and not the systemd unit's), so the -# name would otherwise be empty in-pane and the runtime's native heartbeat -# (which gates on MOSAIC_AGENT_NAME) would never fire. %q-quote it so it is a -# safe single bash token regardless of the name's characters. -AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME") - -# MOSAIC_AGENT_CLASS must ALSO be exported INTO the pane, for the same reason as -# MOSAIC_AGENT_NAME above: the pane inherits the tmux SERVER environment (not this -# script's env, and not the systemd unit's EnvironmentFile), so the per-agent class -# written to agents/.env would otherwise be invisible in-pane. The launcher -# composes the persona contract from process.env.MOSAIC_AGENT_CLASS at launch -# (compose-contract -> readPersonaContractBlock); without this export it sees an -# undefined class and silently injects NO persona contract. %q-quote it so it is a -# safe single bash token; an empty/unset class %q-quotes to '' and is a harmless -# no-op downstream (readPersonaContractBlock returns '' for an empty class). -AGENT_CLASS_Q=$(printf '%q' "${MOSAIC_AGENT_CLASS:-}") - -if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then - PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}" -else - PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; exec ${MOSAIC_AGENT_COMMAND}" -fi - -mkdir -p "$MOSAIC_AGENT_WORKDIR" - -# ── Pre-trust the workdir for the Claude runtime ───────────────────────────── -# Claude Code shows a one-time "Is this a project you trust?" folder-trust gate -# the first time it opens a directory. A fleet-launched agent has no human to -# answer it, so the pane stalls forever at the prompt while its heartbeat keeps -# reporting "healthy" (the pane process IS alive — it's just blocked). -# -# IMPORTANT: --dangerously-skip-permissions does NOT bypass this gate, and -# neither does `trustedProjectDirectories` in settings.json (verified empirically -# 2026-06-24). The ONLY thing the gate honors is the per-project record in -# ~/.claude.json: projects[""].hasTrustDialogAccepted == true (exactly what -# answering the prompt writes). So we pre-seed that record here. -# -# Idempotent, atomic, best-effort: any failure is non-fatal (the agent still -# launches — worst case it stalls on the gate, i.e. the pre-fix status quo). -# Only the claude runtime needs this; codex/pi have no such gate. _ensure_claude_workdir_trusted() { local workdir="$1" - # The path claude keys on is the resolved cwd it is launched in. - local rp - rp=$(cd "$workdir" 2>/dev/null && pwd -P) || rp="$workdir" - # ~/.claude.json lives next to the claude config dir; honor CLAUDE_CONFIG_DIR. + local resolved + resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir" local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}" claude_json="${claude_json:-$HOME/.claude.json}" - - if ! command -v python3 >/dev/null 2>&1; then - echo "WARNING: python3 not found; cannot pre-trust '$rp' for claude (agent may stall on the folder-trust gate)" >&2 - return 1 - fi - - # Serialize concurrent agent launches that share ~/.claude.json (flock if available). - local lock="${claude_json}.mosaic-lock" - _seed() { - MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$rp" python3 - <<'PY' + command -v python3 >/dev/null 2>&1 || return 1 + MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY' import json, os, sys, tempfile cj = os.environ["MOSAIC_CJ"] d = os.environ["MOSAIC_TRUST_DIR"] @@ -174,22 +302,19 @@ try: if not isinstance(data, dict): data = {} except Exception: - # Never corrupt an unreadable/partial file — bail without writing. sys.exit(2) projects = data.setdefault("projects", {}) entry = projects.get(d) if not isinstance(entry, dict): entry = {} projects[d] = entry -if entry.get("hasTrustDialogAccepted") is True: - sys.exit(0) # already trusted — nothing to do entry["hasTrustDialogAccepted"] = True tmp_dir = os.path.dirname(cj) or "." fd, tmp = tempfile.mkstemp(dir=tmp_dir, prefix=".claude.json.mosaic.") try: with os.fdopen(fd, "w") as f: json.dump(data, f, indent=2) - os.replace(tmp, cj) # atomic + os.replace(tmp, cj) except Exception: try: os.unlink(tmp) @@ -197,56 +322,56 @@ except Exception: pass sys.exit(3) PY - } - if command -v flock >/dev/null 2>&1; then - ( flock 9; _seed ) 9>"$lock" 2>/dev/null || _seed - else - _seed - fi } -case "$MOSAIC_AGENT_RUNTIME" in - claude) - _ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" \ - || echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2 - ;; -esac +if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then + _ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \ + echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2 +fi -# ── Launch the tmux session (no exec — we continue to wire the heartbeat) ──── +LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME") +if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi +if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi + +# The tmux holder owns a named server. Explicitly clear the pane environment +# so server/session variables cannot cross the launch boundary; retain only +# trusted bootstrap, generated, and approved local data as argv assignments. +LAUNCH_ENV=( + /usr/bin/env + -i + "HOME=$PANE_HOME" + "PATH=$PANE_PATH" + "MOSAIC_HOME=$MOSAIC_HOME" + "MOSAIC_AGENT_NAME=$AGENT_NAME" + "MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS" + "MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME" + "MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL" + "MOSAIC_AGENT_REASONING=$MOSAIC_AGENT_REASONING" + "MOSAIC_AGENT_TOOL_POLICY=$MOSAIC_AGENT_TOOL_POLICY" + "MOSAIC_AGENT_WORKDIR=$MOSAIC_AGENT_WORKDIR" + "MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" + "MOSAIC_HEARTBEAT_RUN_DIR=$MOSAIC_HEARTBEAT_RUN_DIR" +) + +mkdir -p "$MOSAIC_AGENT_WORKDIR" _tmux new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \ - bash -c "$PANE_SHELL_SNIPPET" + "${LAUNCH_ENV[@]}" "${LAUNCH_COMMAND[@]}" -# ── Resolve the pane PID (retry briefly to let the session initialise) ──────── PANE_PID="" for _retry in 1 2 3 4 5; do - PANE_PID=$(_tmux list-panes \ - -t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true) + PANE_PID=$(_tmux list-panes -t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true) [ -n "$PANE_PID" ] && break sleep 0.2 done -# ── Spawn the heartbeat sidecar (detached, best-effort) ────────────────────── -# The sidecar writes ~/.config/mosaic/fleet/run/.hb atomically while the -# pane process is alive, then exits so the file goes stale (fleet ps shows stale -# then PANE=dead). It is runtime-agnostic: it only cares about the pane PID. _start_heartbeat_sidecar() { - local agent="$1" - local pane_pid="$2" - local run_dir="$3" - local interval="$4" + local agent="$1" pane_pid="$2" run_dir="$3" interval="$4" local hb_file="${run_dir}/${agent}.hb" - mkdir -p "$run_dir" - - # Write the sidecar as a self-contained bash one-liner so it carries no - # references to any variables from this script's environment. local sidecar_script sidecar_script=$(printf \ - 'hb=%q; pid=%q; iv=%q; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do nat="$hb.native"; if [ -f "$nat" ] && [ "$(( $(date +%%s) - $(stat -c %%Y "$nat" 2>/dev/null || echo 0) ))" -lt "$(( iv * 2 ))" ]; then sleep "$iv"; continue; fi; tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \ + 'hb=%q; pid=%q; iv=%q; native="$hb.native"; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do now=$(date +%%s); marker=$(stat -c %%Y -- "$native" 2>/dev/null || true); if [ -z "$marker" ] || [ -L "$native" ] || (( now - marker > iv * 2 + 1 )); then tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; fi; sleep "$iv"; done' \ "$hb_file" "$pane_pid" "$interval") - - # setsid + disown ensures the sidecar survives this script exiting. - # stderr/stdout go to /dev/null; failures are non-fatal. if command -v setsid >/dev/null 2>&1; then setsid bash -c "$sidecar_script" /dev/null 2>&1 & else @@ -256,7 +381,6 @@ _start_heartbeat_sidecar() { } if [ -n "$PANE_PID" ]; then - # Guard: do not let sidecar startup failures abort the launcher (set -e). _start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \ "$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \ echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2 diff --git a/packages/mosaic/framework/tools/fleet/start-interaction-service.sh b/packages/mosaic/framework/tools/fleet/start-interaction-service.sh new file mode 100755 index 00000000..b972b6a8 --- /dev/null +++ b/packages/mosaic/framework/tools/fleet/start-interaction-service.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +AGENT_NAME=${1:-} + +fail() { + echo "ERROR: $*" >&2 + exit 64 +} + +[ -n "$AGENT_NAME" ] || fail "agent name argument is required" + +# The shared launcher strictly validates the generated/local data boundary +# before it applies this interaction service's pinned profile checks. +exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" --interaction "$AGENT_NAME" diff --git a/packages/mosaic/framework/tools/fleet/start-tmux-holder.sh b/packages/mosaic/framework/tools/fleet/start-tmux-holder.sh new file mode 100755 index 00000000..1422647e --- /dev/null +++ b/packages/mosaic/framework/tools/fleet/start-tmux-holder.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +# A holder may create only the configured named socket. Existing servers are +# accepted only when their private install-derived ownership identity, exact +# holder session, and complete approved global environment all match. + +MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic} +MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-fleet} +MOSAIC_TMUX_HOLDER=${MOSAIC_TMUX_HOLDER:-_holder} +OWNER_FILE="$MOSAIC_HOME/fleet/run/holder-owner" +TMUX_BIN=/usr/bin/tmux + +fail() { + echo "ERROR: refusing unmanaged Mosaic tmux server on socket ${MOSAIC_TMUX_SOCKET}: $1" >&2 + exit 64 +} + +[ -x "$TMUX_BIN" ] || fail "tmux binary is unavailable" +[ -f "$OWNER_FILE" ] && [ ! -L "$OWNER_FILE" ] || fail "private ownership identity is missing" +owner_mode=$(stat -c '%a' -- "$OWNER_FILE") || fail "private ownership identity is unreadable" +(( (8#$owner_mode & 8#077) == 0 )) || fail "private ownership identity has unsafe permissions" +MOSAIC_FLEET_OWNER=$(tr -d '\n' < "$OWNER_FILE") +[[ "$MOSAIC_FLEET_OWNER" =~ ^[a-f0-9-]{36}$ ]] || fail "private ownership identity is malformed" + +_tmux() { + "$TMUX_BIN" -L "$MOSAIC_TMUX_SOCKET" "$@" +} + +server_running() { + _tmux list-sessions >/dev/null 2>&1 +} + +assert_owned_server() { + _tmux has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || fail "exact holder session is absent" + local environment + environment=$(_tmux show-environment -g 2>/dev/null) || fail "global environment is unreadable" + local expected + expected=$(printf '%s\n' \ + "HOME=$HOME" \ + 'PATH=/usr/bin:/bin' \ + "PWD=$HOME" \ + "MOSAIC_FLEET_OWNER=$MOSAIC_FLEET_OWNER" \ + "MOSAIC_TMUX_HOLDER=$MOSAIC_TMUX_HOLDER" \ + "MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort) + [ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \ + fail "global environment does not match the owned-server contract" +} + +if server_running; then + assert_owned_server +else + cd "$HOME" || fail "trusted home is unavailable" + # Start the tmux server itself under the approved environment. The holder pane + # receives the same closed environment rather than arbitrary server globals. + /usr/bin/env -i \ + "HOME=$HOME" \ + PATH=/usr/bin:/bin \ + "MOSAIC_FLEET_OWNER=$MOSAIC_FLEET_OWNER" \ + "MOSAIC_TMUX_HOLDER=$MOSAIC_TMUX_HOLDER" \ + "MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" \ + "$TMUX_BIN" -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" \ + /usr/bin/env -i "HOME=$HOME" PATH=/usr/bin:/bin /bin/sh -c 'while true; do sleep 3600; done' +fi diff --git a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh index 837ef930..5d1c27d6 100755 --- a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh @@ -3,370 +3,410 @@ set -euo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) START="$SCRIPT_DIR/start-agent-session.sh" -SOCKET="mosaic-agent-test-$RANDOM-$$" -AGENT="agent-$RANDOM" -WORKDIR=$(mktemp -d) - -# Keep a single cleanup trap that accumulates resources. -CLEANUP_DIRS=("$WORKDIR") -CLEANUP_SOCKETS=("$SOCKET") -trap '_cleanup' EXIT -_cleanup() { - for s in "${CLEANUP_SOCKETS[@]:-}"; do - tmux -L "$s" kill-server >/dev/null 2>&1 || true - done - for d in "${CLEANUP_DIRS[@]:-}"; do - rm -rf "$d" - done -} +INTERACTION_START="$SCRIPT_DIR/start-interaction-service.sh" +ROOT=$(mktemp -d) +FAKE_BIN=$(mktemp -d) +TMUX_CALLS=$(mktemp) +trap 'rm -rf "$ROOT" "$FAKE_BIN" "$TMUX_CALLS"' EXIT fail() { echo "FAIL: $*" >&2 exit 1 } -# ── Test 1: basic session creation with workdir check ───────────────────────── -MOSAIC_TMUX_SOCKET="$SOCKET" \ -MOSAIC_AGENT_WORKDIR="$WORKDIR" \ -MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \ - "$START" "$AGENT" - -tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created" -# Retry: pane_current_path briefly reflects the tmux server's cwd until the pane -# process establishes its own cwd (the -c start dir). Poll until it settles. -actual_dir="" -for _ in $(seq 1 30); do - actual_dir=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}') - [ "$actual_dir" = "$WORKDIR" ] && break - sleep 0.1 -done -[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir (expected $WORKDIR)" - -# ── Test 2: idempotency (duplicate start prints 'already running') ───────────── -MOSAIC_TMUX_SOCKET="$SOCKET" \ -MOSAIC_AGENT_WORKDIR="$WORKDIR" \ -MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \ - "$START" "$AGENT" >/tmp/mosaic-start-agent-idempotent.out - -grep -qF 'already running' /tmp/mosaic-start-agent-idempotent.out || fail "duplicate start was not idempotent" - -# ── Test 3: runtime-bin PATH prefix is baked into the pane command ──────────── -# -# We capture the command the script would hand to tmux by injecting a fake -# 'tmux' shim into PATH. The shim: -# - Intercepts 'new-session' calls and records its arguments to a file. -# - For 'has-session' calls, exits 1 (session does not exist) so the script -# proceeds to launch instead of printing "already running". -# - For 'list-panes' calls, returns empty so PANE_PID stays unset and the -# heartbeat sidecar is NOT spawned (heartbeat is not the focus of this test; -# test 6 and 7 cover that path). This prevents any real-filesystem side -# effects or leaked background processes. -# - For all other subcommands, exits 0. -# -# Assertions: -# a) 'export PATH=' with the synthetic MOSAIC_RUNTIME_BIN prefix appears. -# b) 'exec' appears so the runtime replaces the wrapper shell. -# c) MOSAIC_AGENT_COMMAND with flags is forwarded intact. - -FAKE_BIN=$(mktemp -d) -FAKE_RUNTIME_BIN=$(mktemp -d) -TMUX_ARGS_FILE=$(mktemp) -HB_RUN_DIR3=$(mktemp -d) -CLEANUP_DIRS+=("$FAKE_BIN" "$FAKE_RUNTIME_BIN" "$HB_RUN_DIR3") - -# Write the fake tmux shim (uses only positional args, no sourced vars). -cat > "$FAKE_BIN/tmux" < "$FAKE_BIN/tmux" <<'SHIM' #!/usr/bin/env bash -# Fake tmux: record new-session args; report has-session as missing. -subcmd="\$3" # argv: tmux -L ... -if [ "\$subcmd" = "has-session" ]; then - exit 1 # session not found → script will attempt new-session -fi -if [ "\$subcmd" = "new-session" ]; then - printf '%s\n' "\$@" > "$TMUX_ARGS_FILE" - exit 0 -fi -if [ "\$subcmd" = "list-panes" ]; then - # Return empty: no sidecar spawned (heartbeat is not the focus of this test). - echo "" - exit 0 -fi -exit 0 +set -euo pipefail +printf '%s\0' "$@" >> "${MOSAIC_TEST_TMUX_CALLS:?}" +args=("$@") +index=0 +if [ "${args[0]:-}" = -L ]; then index=2; fi +case "${args[$index]:-}" in + has-session) + for argument in "${args[@]}"; do + [ "$argument" = '=_holder:0.0' ] && exit 0 + done + exit 1 + ;; + show-environment) + printf '%s\n' \ + "HOME=${MOSAIC_TEST_HOME:?}" \ + 'PATH=/usr/bin:/bin' \ + "PWD=${MOSAIC_TEST_HOME:?}" \ + "MOSAIC_FLEET_OWNER=${MOSAIC_TEST_FLEET_OWNER:?}" \ + 'MOSAIC_TMUX_HOLDER=_holder' \ + 'MOSAIC_TMUX_SOCKET=mosaic-test' + exit 0 + ;; + list-panes) printf '%s\n' "${MOSAIC_TEST_PANE_PID:-}"; exit 0 ;; + new-session) + if [ "${MOSAIC_TEST_EXECUTE_PANE:-}" = 1 ]; then + for ((index = 0; index < ${#args[@]}; index++)); do + if [ "${args[$index]}" = /usr/bin/env ]; then + "${args[@]:$index}" + break + fi + done + fi + exit 0 + ;; + *) exit 0 ;; +esac SHIM chmod +x "$FAKE_BIN/tmux" -SOCKET3="mosaic-agent-test3-$RANDOM-$$" -AGENT3="agent3-$RANDOM" -WORKDIR3=$(mktemp -d) -CLEANUP_DIRS+=("$WORKDIR3") - -PATH="$FAKE_BIN:$PATH" \ -MOSAIC_TMUX_SOCKET="$SOCKET3" \ -MOSAIC_AGENT_WORKDIR="$WORKDIR3" \ -MOSAIC_AGENT_RUNTIME="pi" \ -MOSAIC_AGENT_CLASS="code" \ -MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \ -MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \ -MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \ - "$START" "$AGENT3" - -all_args=$(cat "$TMUX_ARGS_FILE" 2>/dev/null || true) -rm -f "$TMUX_ARGS_FILE" - -echo "--- captured tmux new-session args ---" -echo "$all_args" -echo "--- end args ---" - -# a) PATH prefix containing FAKE_RUNTIME_BIN must appear. -echo "$all_args" | grep -qF "export PATH=" || fail "pane command does not export PATH" -echo "$all_args" | grep -qF "$FAKE_RUNTIME_BIN" || fail "pane command does not include MOSAIC_RUNTIME_BIN in PATH prefix" - -# b) exec must appear so the runtime replaces the wrapper shell. -echo "$all_args" | grep -qF "exec " || fail "pane command does not use exec" - -# c) Full MOSAIC_AGENT_COMMAND (with flags) must be forwarded. -echo "$all_args" | grep -qF "mosaic yolo pi --model openai-codex/gpt-5.5:high" || \ - fail "pane command does not forward MOSAIC_AGENT_COMMAND with flags intact" - -# d) MOSAIC_AGENT_NAME and the per-agent MOSAIC_AGENT_CLASS must BOTH be exported -# INTO the pane. The pane inherits the tmux SERVER environment (not this -# script's env, nor the systemd unit's EnvironmentFile), so any per-agent var -# the launcher needs in-pane must be re-exported in the snippet. CLASS is -# load-bearing: the launcher composes the persona contract from -# process.env.MOSAIC_AGENT_CLASS, so a missing export silently drops the -# persona (regression guard for the A3a pane-propagation gap). -echo "$all_args" | grep -qF "export MOSAIC_AGENT_NAME=" || \ - fail "pane command does not export MOSAIC_AGENT_NAME into the pane" -echo "$all_args" | grep -qF "export MOSAIC_AGENT_CLASS=code" || \ - fail "pane command does not export MOSAIC_AGENT_CLASS into the pane (persona would silently drop)" - -# ── Test 4: when no extra runtime-bin dirs exist, exec still appears ─────────── -TMUX_ARGS_FILE2=$(mktemp) -FAKE_BIN2=$(mktemp -d) -HB_RUN_DIR4=$(mktemp -d) -CLEANUP_DIRS+=("$FAKE_BIN2" "$HB_RUN_DIR4") - -cat > "$FAKE_BIN2/tmux" < "$FAKE_BIN/mosaic" <<'SHIM' #!/usr/bin/env bash -subcmd="\$3" -if [ "\$subcmd" = "has-session" ]; then exit 1; fi -if [ "\$subcmd" = "new-session" ]; then - printf '%s\n' "\$@" > "$TMUX_ARGS_FILE2" - exit 0 +set -euo pipefail +env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment" +SHIM +chmod +x "$FAKE_BIN/mosaic" + +write_generated() { + local home="$1" + local agent="$2" + mkdir -p "$home/fleet/agents" "$home/fleet/run" + chmod 700 "$home" "$home/fleet" "$home/fleet/agents" "$home/fleet/run" + printf '123e4567-e89b-12d3-a456-426614174000\n' > "$home/fleet/run/holder-owner" + chmod 600 "$home/fleet/run/holder-owner" + cat > "$home/fleet/agents/$agent.env.generated" < "$TMUX_CALLS" +HOME_UNSAFE_PARENT="$ROOT/unsafe-parent" +write_generated "$HOME_UNSAFE_PARENT" "coder-parent" +chmod 777 "$HOME_UNSAFE_PARENT/fleet/agents" +if output=$(run_start "$HOME_UNSAFE_PARENT" coder-parent 2>&1); then + fail "generated file under a world-writable parent was accepted" fi -exit 0 -SHIM2 -chmod +x "$FAKE_BIN2/tmux" +[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection" +echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing" -SOCKET4="mosaic-agent-test4-$RANDOM-$$" -AGENT4="agent4-$RANDOM" -WORKDIR4=$(mktemp -d) -CLEANUP_DIRS+=("$WORKDIR4") - -# MOSAIC_RUNTIME_BIN points to a non-existent dir so prefix will be empty; -# .npm-global/bin and .local/bin may or may not exist but we just want exec. -PATH="$FAKE_BIN2:$PATH" \ -MOSAIC_TMUX_SOCKET="$SOCKET4" \ -MOSAIC_AGENT_WORKDIR="$WORKDIR4" \ -MOSAIC_AGENT_RUNTIME="pi" \ -MOSAIC_RUNTIME_BIN="/nonexistent-dir-$$" \ -MOSAIC_AGENT_COMMAND="mosaic yolo pi" \ -MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR4" \ - "$START" "$AGENT4" - -all_args4=$(cat "$TMUX_ARGS_FILE2" 2>/dev/null || true) -rm -f "$TMUX_ARGS_FILE2" -rm -rf "$WORKDIR4" - -echo "$all_args4" | grep -qF "exec " || fail "pane command (no prefix dirs) does not use exec" -echo "$all_args4" | grep -qF "mosaic yolo pi" || fail "pane command does not include agent command when no prefix" - -# ── Test 5: candidate dir already in LAUNCHER $PATH is still baked into pane ── -# -# Regression guard for the bug where _build_runtime_bin_prefix() used to skip -# a candidate because it was already present in the launcher process's $PATH. -# That check was wrong: the pane inherits the tmux SERVER environment, not the -# launcher's env. Even if a dir is on the launcher's PATH it must always be -# baked into the pane's PATH export. -# -# We prove this by setting PATH to include FAKE_RUNTIME_BIN5 (the candidate), -# then asserting the generated new-session command still exports it. -TMUX_ARGS_FILE5=$(mktemp) -FAKE_BIN5=$(mktemp -d) -FAKE_RUNTIME_BIN5=$(mktemp -d) # this dir IS on the launcher's PATH below -HB_RUN_DIR5=$(mktemp -d) -CLEANUP_DIRS+=("$FAKE_BIN5" "$FAKE_RUNTIME_BIN5" "$HB_RUN_DIR5") - -cat > "$FAKE_BIN5/tmux" < "$TMUX_ARGS_FILE5" - exit 0 +: > "$TMUX_CALLS" +HOME_SYMLINK_PARENT="$ROOT/symlink-parent" +write_generated "$HOME_SYMLINK_PARENT" "coder-symlink-parent" +mv "$HOME_SYMLINK_PARENT/fleet/agents" "$HOME_SYMLINK_PARENT/private-agents" +ln -s "$HOME_SYMLINK_PARENT/private-agents" "$HOME_SYMLINK_PARENT/fleet/agents" +if output=$(run_start "$HOME_SYMLINK_PARENT" coder-symlink-parent 2>&1); then + fail "generated file under a symlinked parent was accepted" fi -if [ "\$subcmd" = "list-panes" ]; then - # Return empty: no sidecar spawned (heartbeat is not the focus of this test). - echo "" - exit 0 -fi -exit 0 -SHIM5 -chmod +x "$FAKE_BIN5/tmux" +[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection" +echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing" -SOCKET5="mosaic-agent-test5-$RANDOM-$$" -AGENT5="agent5-$RANDOM" -WORKDIR5=$(mktemp -d) -CLEANUP_DIRS+=("$WORKDIR5") -CLEANUP_SOCKETS+=("$SOCKET5") +# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A +# symlink or group/world-writable ancestor must fail before environment parsing, +# workdir creation, or tmux effects. The malformed local input proves parsing +# was not reached when the ancestor rejection is reported. +assert_managed_ancestor_rejected() { + local ancestor="$1" + local hazard="$2" + local home="$ROOT/managed-${ancestor//\//-}-${hazard}" + local agent="coder-managed-${ancestor//\//-}-${hazard}" + local node + write_generated "$home" "$agent" + printf 'MOSAIC_AGENT_COMMAND=must-not-be-parsed\n' > "$home/fleet/agents/$agent.env.local" + chmod 600 "$home/fleet/agents/$agent.env.local" + rm -rf "$home/work" -# FAKE_RUNTIME_BIN5 is deliberately placed on the LAUNCHER PATH so that the -# old (buggy) code would have skipped it. The correct code must still include -# it in the pane PATH export. -PATH="$FAKE_BIN5:$FAKE_RUNTIME_BIN5:$PATH" \ -MOSAIC_TMUX_SOCKET="$SOCKET5" \ -MOSAIC_AGENT_WORKDIR="$WORKDIR5" \ -MOSAIC_AGENT_RUNTIME="pi" \ -MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN5" \ -MOSAIC_AGENT_COMMAND="mosaic yolo pi" \ -MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR5" \ - "$START" "$AGENT5" + case "$ancestor" in + MOSAIC_HOME) node="$home" ;; + MOSAIC_HOME/fleet) node="$home/fleet" ;; + MOSAIC_HOME/fleet/agents) node="$home/fleet/agents" ;; + *) fail "unknown managed ancestor: $ancestor" ;; + esac -all_args5=$(cat "$TMUX_ARGS_FILE5" 2>/dev/null || true) -rm -f "$TMUX_ARGS_FILE5" -rm -rf "$WORKDIR5" + if [ "$hazard" = symlink ]; then + local target="${node}-target" + mv "$node" "$target" + ln -s "$target" "$node" + else + chmod 777 "$node" + fi -echo "--- test 5: launcher-PATH candidate must still appear in pane export ---" -echo "$all_args5" -echo "--- end test 5 args ---" + : > "$TMUX_CALLS" + if output=$(run_start "$home" "$agent" 2>&1); then + fail "${hazard} $ancestor was accepted" + fi + [ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection" + [ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection" + echo "$output" | grep -qF "code=unsafe-" || fail "managed ancestor diagnostic missing" + if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then + fail "environment parsing ran before $hazard $ancestor rejection" + fi +} -echo "$all_args5" | grep -qF "export PATH=" || \ - fail "test5: pane command does not export PATH when candidate is on launcher PATH" -echo "$all_args5" | grep -qF "$FAKE_RUNTIME_BIN5" || \ - fail "test5: candidate dir (already on launcher PATH) was NOT baked into pane PATH — regression" - -# ── Test 6: heartbeat sidecar — pane PID resolved + .hb file written ────────── -# -# Uses a real tmux session (same socket as test 1 which already has $AGENT) so -# list-panes returns a real pane PID. We override MOSAIC_HEARTBEAT_RUN_DIR to -# a temp dir and set a 1-second interval, then wait up to 3 s for the .hb file -# to appear and check its content. - -HB_RUN_DIR=$(mktemp -d) -CLEANUP_DIRS+=("$HB_RUN_DIR") - -# Re-use the session+agent created in Test 1 (still alive on $SOCKET / $AGENT). -# We need to invoke the script for a NEW agent on the same socket to exercise -# the heartbeat path with a real pane PID. -AGENT6="agent6-$RANDOM" -MOSAIC_TMUX_SOCKET="$SOCKET" \ -MOSAIC_AGENT_WORKDIR="$WORKDIR" \ -MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \ -MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR" \ -MOSAIC_HEARTBEAT_INTERVAL="1" \ - "$START" "$AGENT6" - -HB_FILE="$HB_RUN_DIR/${AGENT6}.hb" - -# Wait up to 5 seconds for the heartbeat file to appear. -_waited=0 -until [ -f "$HB_FILE" ] || [ "$_waited" -ge 5 ]; do - sleep 0.5 - _waited=$((_waited + 1)) +for managed_ancestor in MOSAIC_HOME MOSAIC_HOME/fleet MOSAIC_HOME/fleet/agents; do + assert_managed_ancestor_rejected "$managed_ancestor" symlink + assert_managed_ancestor_rejected "$managed_ancestor" group-world-writable done -[ -f "$HB_FILE" ] || fail "test6: heartbeat file not written at $HB_FILE within 5s" +# A local file cannot shadow any roster-derived generated key. Validation must +# happen before fake tmux receives even a has-session call. +: > "$TMUX_CALLS" +HOME_SHADOW="$ROOT/shadow" +write_generated "$HOME_SHADOW" "coder1" +printf 'MOSAIC_AGENT_RUNTIME=codex\n' > "$HOME_SHADOW/fleet/agents/coder1.env.local" +chmod 600 "$HOME_SHADOW/fleet/agents/coder1.env.local" +if output=$(run_start "$HOME_SHADOW" coder1 2>&1); then + fail "generated-key shadow was accepted" +fi +[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection" +echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key" +echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash" +if echo "$output" | grep -qF 'codex'; then + fail "shadow diagnostic leaked value" +fi -hb_content=$(cat "$HB_FILE") -echo "--- test 6: heartbeat file content ---" -echo "$hb_content" -echo "--- end test 6 ---" +# Arbitrary command compatibility is quarantined/rejected as data. Diagnostics +# may name the key and hash but must never echo the privileged command text. +: > "$TMUX_CALLS" +HOME_COMMAND="$ROOT/command" +write_generated "$HOME_COMMAND" "coder2" +COMMAND_VALUE='mosaic yolo codex --dangerous' +printf 'MOSAIC_AGENT_COMMAND=%s\n' "$COMMAND_VALUE" > "$HOME_COMMAND/fleet/agents/coder2.env.local" +chmod 600 "$HOME_COMMAND/fleet/agents/coder2.env.local" +if output=$(run_start "$HOME_COMMAND" coder2 2>&1); then + fail "arbitrary command override was accepted" +fi +[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection" +echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key" +echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash" +if echo "$output" | grep -qF "$COMMAND_VALUE"; then + fail "command diagnostic leaked command value" +fi -# Verify required fields are present. -echo "$hb_content" | grep -qE '^ts=[0-9]{4}-[0-9]{2}-[0-9]{2}T' || \ - fail "test6: heartbeat ts field missing or malformed" -echo "$hb_content" | grep -qE '^pid=[0-9]+' || \ - fail "test6: heartbeat pid field missing or malformed" -echo "$hb_content" | grep -qF 'status=ok' || \ - fail "test6: heartbeat status=ok missing" +# Group/world-readable local input is not trusted even when its syntax is safe. +: > "$TMUX_CALLS" +HOME_PERMS="$ROOT/perms" +write_generated "$HOME_PERMS" "coder3" +printf 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n' > "$HOME_PERMS/fleet/agents/coder3.env.local" +chmod 644 "$HOME_PERMS/fleet/agents/coder3.env.local" +if output=$(run_start "$HOME_PERMS" coder3 2>&1); then + fail "world-readable local input was accepted" +fi +[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection" +echo "$output" | grep -qF 'code=unsafe-permissions' || fail "permission diagnostic missing" -# ── Test 7: heartbeat sidecar — targets correct .hb path per agent name ──────── -# -# Uses the fake-tmux shim approach (like tests 3-5) to capture the sidecar -# invocation without needing a real session. A fake setsid shim records its -# arguments so we can assert the sidecar script targets the expected .hb path -# and uses the configured interval. +# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and +# computed PATH only. The pane command itself must not carry loader, shell +# control, arbitrary sentinel, or stale bootstrap variables. +: > "$TMUX_CALLS" +HOME_PANE_BOUNDARY="$ROOT/pane-boundary/.config/mosaic" +write_generated "$HOME_PANE_BOUNDARY" "coder-pane-boundary" +PANE_TRUSTED_HOME="${HOME_PANE_BOUNDARY%/.config/mosaic}" +PANE_STALE_HOME="$ROOT/stale-home" +PANE_STALE_PATH="$ROOT/stale-bin" +PANE_BASH_ENV="$ROOT/pane-boundary.bash-env" +printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \ + "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local" +chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local" +LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \ +BASH_ENV="$PANE_BASH_ENV" \ +MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \ +HOME="$PANE_STALE_HOME" \ +PATH="$PANE_STALE_PATH" \ + /usr/bin/env -i \ + "HOME=$PANE_TRUSTED_HOME" \ + "PATH=$FAKE_BIN:/usr/bin:/bin" \ + "MOSAIC_HOME=$HOME_PANE_BOUNDARY" \ + "MOSAIC_TEST_TMUX_CALLS=$TMUX_CALLS" \ + "MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \ + MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ + MOSAIC_TEST_EXECUTE_PANE=1 \ + "$START" coder-pane-boundary +pane_args=$(tr '\0' '\n' < "$TMUX_CALLS") +echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \ + fail "pane did not restore trusted HOME" +echo "$pane_args" | grep -qF "HOME=$PANE_STALE_HOME" && \ + fail "pane inherited stale HOME" +echo "$pane_args" | grep -qF "$PANE_STALE_PATH" && fail "pane inherited stale PATH" +for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do + echo "$pane_args" | grep -qF "$blocked" && fail "pane inherited $blocked" +done -FAKE_BIN7=$(mktemp -d) -FAKE_RUNTIME_BIN7=$(mktemp -d) -SETSID_ARGS_FILE=$(mktemp) -HB_RUN_DIR7=$(mktemp -d) -CLEANUP_DIRS+=("$FAKE_BIN7" "$FAKE_RUNTIME_BIN7" "$HB_RUN_DIR7") +after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1) +[ -n "$after_pane_env" ] || fail "pane command did not use absolute env" +printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \ + fail "pane command did not clear its environment" +pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment") +echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \ + fail "runtime pane did not receive trusted HOME" +echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH" +for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do + echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked" +done -AGENT7="my-fleet-agent-$RANDOM" -INTERVAL7="42" +write_interaction_generated() { + local home="$1" + local agent="$2" + mkdir -p "$home/fleet/agents" "$home/fleet/run" "$home/work" + chmod 700 "$home" "$home/fleet" "$home/fleet/agents" "$home/fleet/run" + printf '123e4567-e89b-12d3-a456-426614174000\n' > "$home/fleet/run/holder-owner" + chmod 600 "$home/fleet/run/holder-owner" + cat > "$home/fleet/agents/$agent.env.generated" < "$FAKE_BIN7/tmux" < argument for inspection, then -# background an actual bash subshell so disown succeeds in the caller. -cat > "$FAKE_BIN7/setsid" <<'SETSID_SHIM' -#!/usr/bin/env bash -# argv: setsid bash -c -# Record the full argument list to the capture file, then exit cleanly. -printf '%s\0' "$@" > __SETSID_ARGS_FILE__ -exit 0 -SETSID_SHIM -# Patch the placeholder with the real capture-file path (avoids heredoc expansion issues). -sed -i "s|__SETSID_ARGS_FILE__|${SETSID_ARGS_FILE}|g" "$FAKE_BIN7/setsid" -chmod +x "$FAKE_BIN7/setsid" +write_heartbeat_local() { + local home="$1" + local agent="$2" + mkdir -p "$home/run" + cat > "$home/fleet/agents/$agent.env.local" </dev/null && return 0 + sleep 0.1 + done + fail "heartbeat sidecar did not resume after native marker became stale or absent" +} -PATH="$FAKE_BIN7:$PATH" \ -MOSAIC_TMUX_SOCKET="$SOCKET7" \ -MOSAIC_AGENT_WORKDIR="$WORKDIR7" \ -MOSAIC_AGENT_RUNTIME="pi" \ -MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN7" \ -MOSAIC_AGENT_COMMAND="mosaic yolo pi" \ -MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR7" \ -MOSAIC_HEARTBEAT_INTERVAL="$INTERVAL7" \ - "$START" "$AGENT7" +# A fresh Pi-native marker is authoritative: the shell sidecar may start but +# must not overwrite Pi's busy/ok/model heartbeat. It must resume only when +# the marker is stale or absent. +HOME_NATIVE_FRESH="$ROOT/native-fresh" +write_generated "$HOME_NATIVE_FRESH" "coder-native-fresh" +write_heartbeat_local "$HOME_NATIVE_FRESH" "coder-native-fresh" +FRESH_HB="$HOME_NATIVE_FRESH/run/coder-native-fresh.hb" +printf 'ts=native\npid=1\nstatus=busy\nmodel=authoritative-model\n' > "$FRESH_HB" +touch "$FRESH_HB.native" +MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_FRESH" coder-native-fresh +sleep 0.3 +fresh_content=$(cat "$FRESH_HB") +[ "$fresh_content" = 'ts=native +pid=1 +status=busy +model=authoritative-model' ] || fail "fresh native heartbeat was overwritten" -# Give the background setsid shim a moment to finish writing the capture file. -sleep 0.5 +HOME_NATIVE_STALE="$ROOT/native-stale" +write_generated "$HOME_NATIVE_STALE" "coder-native-stale" +write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale" +STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb" +printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB" +touch -d '10 seconds ago' "$STALE_HB.native" +MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale +wait_for_sidecar_status "$STALE_HB" -setsid_args=$(cat "$SETSID_ARGS_FILE" 2>/dev/null | tr '\0' '\n' || true) -rm -f "$SETSID_ARGS_FILE" -rm -rf "$WORKDIR7" +HOME_NATIVE_ABSENT="$ROOT/native-absent" +write_generated "$HOME_NATIVE_ABSENT" "coder-native-absent" +write_heartbeat_local "$HOME_NATIVE_ABSENT" "coder-native-absent" +ABSENT_HB="$HOME_NATIVE_ABSENT/run/coder-native-absent.hb" +printf 'ts=native\npid=1\nstatus=busy\nmodel=absent-model\n' > "$ABSENT_HB" +MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_ABSENT" coder-native-absent +wait_for_sidecar_status "$ABSENT_HB" -echo "--- test 7: captured setsid args ---" -echo "$setsid_args" -echo "--- end test 7 ---" +# The interaction wrapper delegates to the shared strict parser before applying +# its pinned policy, so malformed projection data wins over profile diagnostics. +: > "$TMUX_CALLS" +HOME_INTERACTION_MALFORMED="$ROOT/interaction-malformed" +write_interaction_generated "$HOME_INTERACTION_MALFORMED" "interaction-malformed" +printf 'UNTRUSTED_BOOTSTRAP=value\n' >> "$HOME_INTERACTION_MALFORMED/fleet/agents/interaction-malformed.env.generated" +if output=$(run_interaction "$HOME_INTERACTION_MALFORMED" interaction-malformed 2>&1); then + fail "interaction wrapper accepted malformed generated data" +fi +[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection" +echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use shared strict parser first" -# The sidecar script (bash -c