Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3831d5e377 |
@@ -8,7 +8,6 @@ coverage
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
__pycache__/
|
||||
docs/reports/
|
||||
|
||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||
pnpm typecheck && pnpm lint && pnpm format:check
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||
# 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
|
||||
# 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
|
||||
|
||||
@@ -4,15 +4,6 @@ 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
|
||||
|
||||
@@ -41,11 +41,6 @@ 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
|
||||
@@ -103,12 +98,6 @@ 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.
|
||||
- |
|
||||
|
||||
+5
-104
@@ -1,5 +1,5 @@
|
||||
# Build, publish npm packages, and push Docker images
|
||||
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
||||
# Runs only on main branch push/tag
|
||||
|
||||
variables:
|
||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||
@@ -23,21 +23,9 @@ variables:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
- &main_image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main, next]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
|
||||
steps:
|
||||
@@ -115,84 +103,6 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
publish-next-npm:
|
||||
image: *node_image
|
||||
# Durable @next integration-line publish. Runs only on next; never writes
|
||||
# the latest dist-tag and never commits the computed prerelease versions.
|
||||
when:
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
environment:
|
||||
NPM_TOKEN:
|
||||
from_secret: gitea_token
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- |
|
||||
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
|
||||
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$CI_PIPELINE_NUMBER" ]; then
|
||||
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
||||
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
||||
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const updated = [];
|
||||
|
||||
function walk(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const packagePath = path.join(fullPath, 'package.json');
|
||||
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
||||
walk(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePackage(packagePath) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
||||
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
||||
if (!stableMatch) {
|
||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
||||
}
|
||||
const [, major, minor, patch] = stableMatch;
|
||||
const oldVersion = manifest.version;
|
||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root);
|
||||
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
||||
for (const line of updated) console.log('[publish-next] ' + line);
|
||||
NODE
|
||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
||||
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
||||
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
|
||||
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
|
||||
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||
depends_on:
|
||||
- build
|
||||
|
||||
# TODO: Uncomment when ready to publish to npmjs.org
|
||||
# publish-npmjs:
|
||||
# image: *node_image
|
||||
@@ -224,17 +134,8 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next gateway publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||
@@ -245,7 +146,7 @@ steps:
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
@@ -271,7 +172,7 @@ steps:
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
|
||||
+1
-4
@@ -25,10 +25,7 @@ FROM node:24-alpine
|
||||
# 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
|
||||
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq
|
||||
|
||||
# Pin pnpm to the repo's packageManager version via corepack.
|
||||
RUN corepack enable && corepack prepare [email protected] --activate
|
||||
|
||||
@@ -30,16 +30,6 @@ This installs both components:
|
||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||
|
||||
### Install lanes
|
||||
|
||||
| Lane | Command | Use when | Source |
|
||||
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
||||
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
|
||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
||||
|
||||
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
|
||||
|
||||
After install, the wizard runs automatically or you can invoke it manually:
|
||||
|
||||
```bash
|
||||
@@ -211,21 +201,8 @@ git clone [email protected]:mosaicstack/stack.git
|
||||
cd stack
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# Optional local queue service only. This does not start PostgreSQL.
|
||||
docker compose up -d valkey
|
||||
|
||||
@@ -253,7 +230,6 @@ 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)
|
||||
@@ -371,9 +347,7 @@ The CLI also performs a background update check on every invocation (cached for
|
||||
bash tools/install.sh --check # Version check only
|
||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||
bash tools/install.sh --next # Prerelease lane: source build from next
|
||||
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||
```
|
||||
|
||||
@@ -1,519 +0,0 @@
|
||||
/**
|
||||
* Federation M3 single-gateway integration tests (FED-M3-10).
|
||||
*
|
||||
* Covers MILESTONES.md M3 acceptance:
|
||||
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
|
||||
* - #7: max_rows_per_query caps list results.
|
||||
*
|
||||
* Strategy:
|
||||
* - Real PostgreSQL via @mosaicstack/db.
|
||||
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
|
||||
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
|
||||
*
|
||||
* Run:
|
||||
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
|
||||
* src/__tests__/integration/federation-m3-list.integration.test.ts
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import * as crypto from 'node:crypto';
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
and,
|
||||
createDb,
|
||||
eq,
|
||||
federationGrants,
|
||||
federationPeers,
|
||||
inArray,
|
||||
missionTasks,
|
||||
missions,
|
||||
projects,
|
||||
tasks,
|
||||
teamMembers,
|
||||
teams,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
users,
|
||||
} from '@mosaicstack/db';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { GrantsService } from '../../federation/grants.service.js';
|
||||
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
|
||||
import { FederationScopeService } from '../../federation/server/scope.service.js';
|
||||
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
|
||||
import { ListController } from '../../federation/server/verbs/list.controller.js';
|
||||
import {
|
||||
makeMosaicIssuedCert,
|
||||
makeSelfSignedCert,
|
||||
} from '../../federation/__tests__/helpers/test-cert.js';
|
||||
|
||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
|
||||
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
|
||||
|
||||
interface TestIds {
|
||||
readonly subjectUserId: string;
|
||||
readonly otherUserId: string;
|
||||
readonly peerId: string;
|
||||
readonly revokedPeerId: string;
|
||||
readonly activeGrantId: string;
|
||||
readonly revokedGrantId: string;
|
||||
readonly subjectProjectId: string;
|
||||
readonly subjectMissionId: string;
|
||||
readonly otherProjectId: string;
|
||||
readonly teamId: string;
|
||||
readonly unauthorizedTeamId: string;
|
||||
readonly teamProjectId: string;
|
||||
readonly taskIds: readonly string[];
|
||||
readonly excludedTaskIds: readonly string[];
|
||||
readonly subjectNoteId: string;
|
||||
readonly otherUserNoteId: string;
|
||||
}
|
||||
|
||||
function pemToDer(pem: string): Buffer {
|
||||
return Buffer.from(
|
||||
pem
|
||||
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||
.replace(/-----END CERTIFICATE-----/, '')
|
||||
.replace(/\s+/g, ''),
|
||||
'base64',
|
||||
);
|
||||
}
|
||||
|
||||
function makeFederationRequest(certPem: string): FastifyRequest {
|
||||
return {
|
||||
raw: {
|
||||
socket: {
|
||||
getPeerCertificate: () => ({
|
||||
raw: pemToDer(certPem),
|
||||
serialNumber: CERT_SERIAL_HEX,
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as FastifyRequest;
|
||||
}
|
||||
|
||||
function makeGuardContext(request: FastifyRequest): {
|
||||
readonly context: ExecutionContext;
|
||||
readonly sent: { statusCode?: number; payload?: unknown };
|
||||
} {
|
||||
const sent: { statusCode?: number; payload?: unknown } = {};
|
||||
const reply = {
|
||||
status: (statusCode: number) => {
|
||||
sent.statusCode = statusCode;
|
||||
return {
|
||||
header: () => ({
|
||||
send: (payload: unknown) => {
|
||||
sent.payload = payload;
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
} as unknown as FastifyReply;
|
||||
|
||||
const context = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
getResponse: () => reply,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
return { context, sent };
|
||||
}
|
||||
|
||||
async function insertUser(db: Db, id: string, label: string): Promise<void> {
|
||||
await db.insert(users).values({
|
||||
id,
|
||||
name: `${RUN_ID}-${label}`,
|
||||
email: `${RUN_ID}-${label}@federation-test.invalid`,
|
||||
emailVerified: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function seedFixtures(db: Db): Promise<TestIds> {
|
||||
const subjectUserId = `${RUN_ID}-subject`;
|
||||
const otherUserId = `${RUN_ID}-other`;
|
||||
const peerId = crypto.randomUUID();
|
||||
const revokedPeerId = crypto.randomUUID();
|
||||
const activeGrantId = crypto.randomUUID();
|
||||
const revokedGrantId = crypto.randomUUID();
|
||||
const subjectProjectId = crypto.randomUUID();
|
||||
const subjectMissionId = crypto.randomUUID();
|
||||
const otherProjectId = crypto.randomUUID();
|
||||
const teamId = crypto.randomUUID();
|
||||
const unauthorizedTeamId = crypto.randomUUID();
|
||||
const teamProjectId = crypto.randomUUID();
|
||||
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
|
||||
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
|
||||
const subjectNoteId = crypto.randomUUID();
|
||||
const otherUserNoteId = crypto.randomUUID();
|
||||
|
||||
await insertUser(db, subjectUserId, 'subject');
|
||||
await insertUser(db, otherUserId, 'other');
|
||||
|
||||
await db.insert(teams).values([
|
||||
{
|
||||
id: teamId,
|
||||
name: `${RUN_ID} allowed team`,
|
||||
slug: `${RUN_ID}-allowed-team`,
|
||||
ownerId: subjectUserId,
|
||||
managerId: subjectUserId,
|
||||
},
|
||||
{
|
||||
id: unauthorizedTeamId,
|
||||
name: `${RUN_ID} unauthorized team`,
|
||||
slug: `${RUN_ID}-unauthorized-team`,
|
||||
ownerId: otherUserId,
|
||||
managerId: otherUserId,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(teamMembers).values([
|
||||
{ teamId, userId: subjectUserId, role: 'member' },
|
||||
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
|
||||
]);
|
||||
|
||||
await db.insert(projects).values([
|
||||
{
|
||||
id: subjectProjectId,
|
||||
name: `${RUN_ID} subject personal project`,
|
||||
ownerType: 'user',
|
||||
ownerId: subjectUserId,
|
||||
},
|
||||
{
|
||||
id: otherProjectId,
|
||||
name: `${RUN_ID} other personal project`,
|
||||
ownerType: 'user',
|
||||
ownerId: otherUserId,
|
||||
},
|
||||
{
|
||||
id: teamProjectId,
|
||||
name: `${RUN_ID} unauthorized team project`,
|
||||
ownerType: 'team',
|
||||
teamId: unauthorizedTeamId,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(missions).values({
|
||||
id: subjectMissionId,
|
||||
name: `${RUN_ID} subject mission`,
|
||||
projectId: subjectProjectId,
|
||||
userId: subjectUserId,
|
||||
});
|
||||
|
||||
await db.insert(tasks).values([
|
||||
{
|
||||
id: taskIds[0],
|
||||
title: `${RUN_ID} visible task 1`,
|
||||
missionId: subjectMissionId,
|
||||
createdAt: new Date('2026-06-25T03:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: taskIds[1],
|
||||
title: `${RUN_ID} visible task 2`,
|
||||
projectId: subjectProjectId,
|
||||
createdAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: taskIds[2],
|
||||
title: `${RUN_ID} visible task 3`,
|
||||
projectId: subjectProjectId,
|
||||
createdAt: new Date('2026-06-25T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: excludedTaskIds[0],
|
||||
title: `${RUN_ID} other user task`,
|
||||
projectId: otherProjectId,
|
||||
createdAt: new Date('2026-06-25T04:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: excludedTaskIds[1],
|
||||
title: `${RUN_ID} unauthorized team task`,
|
||||
projectId: teamProjectId,
|
||||
createdAt: new Date('2026-06-25T05:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(missionTasks).values([
|
||||
{
|
||||
id: subjectNoteId,
|
||||
missionId: subjectMissionId,
|
||||
userId: subjectUserId,
|
||||
notes: `${RUN_ID} subject visible note`,
|
||||
createdAt: new Date('2026-06-25T03:30:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: otherUserNoteId,
|
||||
missionId: subjectMissionId,
|
||||
userId: otherUserId,
|
||||
notes: `${RUN_ID} other user note on subject mission`,
|
||||
createdAt: new Date('2026-06-25T04:30:00.000Z'),
|
||||
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(federationPeers).values([
|
||||
{
|
||||
id: peerId,
|
||||
commonName: `${RUN_ID}-active-peer`,
|
||||
displayName: `${RUN_ID} Active Peer`,
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: CERT_SERIAL_HEX,
|
||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||
state: 'active',
|
||||
},
|
||||
{
|
||||
id: revokedPeerId,
|
||||
commonName: `${RUN_ID}-revoked-peer`,
|
||||
displayName: `${RUN_ID} Revoked Peer`,
|
||||
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
|
||||
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
|
||||
certNotAfter: new Date(Date.now() + 86_400_000),
|
||||
state: 'active',
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(federationGrants).values([
|
||||
{
|
||||
id: activeGrantId,
|
||||
peerId,
|
||||
subjectUserId,
|
||||
status: 'active',
|
||||
scope: {
|
||||
resources: ['tasks', 'notes'],
|
||||
excluded_resources: [],
|
||||
filters: {
|
||||
tasks: { include_personal: true, include_teams: [] },
|
||||
notes: { include_personal: true, include_teams: [] },
|
||||
},
|
||||
max_rows_per_query: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: revokedGrantId,
|
||||
peerId,
|
||||
subjectUserId,
|
||||
status: 'revoked',
|
||||
revokedAt: new Date(),
|
||||
revokedReason: `${RUN_ID} revoked grant fixture`,
|
||||
scope: {
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 2,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
subjectUserId,
|
||||
otherUserId,
|
||||
peerId,
|
||||
revokedPeerId,
|
||||
activeGrantId,
|
||||
revokedGrantId,
|
||||
subjectProjectId,
|
||||
subjectMissionId,
|
||||
otherProjectId,
|
||||
teamId,
|
||||
unauthorizedTeamId,
|
||||
teamProjectId,
|
||||
taskIds,
|
||||
excludedTaskIds,
|
||||
subjectNoteId,
|
||||
otherUserNoteId,
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
|
||||
if (!ids) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(missionTasks)
|
||||
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(tasks)
|
||||
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(missions)
|
||||
.where(eq(missions.id, ids.subjectMissionId))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(projects)
|
||||
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(teamMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(teamMembers.userId, ids.subjectUserId),
|
||||
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(teams)
|
||||
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(federationGrants)
|
||||
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(federationPeers)
|
||||
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
|
||||
.catch(() => {});
|
||||
await db
|
||||
.delete(users)
|
||||
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
|
||||
let handle: DbHandle;
|
||||
let db: Db;
|
||||
let moduleRef: TestingModule;
|
||||
let guard: FederationAuthGuard;
|
||||
let listController: ListController;
|
||||
let ids: TestIds | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
handle = createDb(PG_URL);
|
||||
db = handle.db;
|
||||
ids = await seedFixtures(db);
|
||||
|
||||
moduleRef = await Test.createTestingModule({
|
||||
controllers: [ListController],
|
||||
providers: [
|
||||
{ provide: DB, useValue: db },
|
||||
GrantsService,
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = moduleRef.get(FederationAuthGuard);
|
||||
listController = moduleRef.get(ListController);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
|
||||
});
|
||||
|
||||
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
|
||||
const malformedOidCert = await makeSelfSignedCert();
|
||||
const request = makeFederationRequest(malformedOidCert);
|
||||
const { context, sent } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(false);
|
||||
expect(sent.statusCode).toBe(401);
|
||||
expect(sent.payload).toMatchObject({
|
||||
error: {
|
||||
code: 'unauthorized',
|
||||
message: expect.stringContaining('missing required OID'),
|
||||
},
|
||||
});
|
||||
expect(request.federationContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const revokedCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.revokedGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(revokedCert);
|
||||
const { context, sent } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(false);
|
||||
expect(sent.statusCode).toBe(403);
|
||||
expect(sent.payload).toMatchObject({
|
||||
error: {
|
||||
code: 'forbidden',
|
||||
message: 'Federation access denied',
|
||||
},
|
||||
});
|
||||
expect(request.federationContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const activeCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.activeGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(activeCert);
|
||||
const { context } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
const response = await listController.list('tasks', request, { limit: 100 });
|
||||
const returnedIds = response.items.map((item) => item['id']);
|
||||
|
||||
expect(response.items).toHaveLength(2);
|
||||
expect(response._truncated).toBe(true);
|
||||
expect(response.nextCursor).toEqual(expect.any(String));
|
||||
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
|
||||
expect(returnedIds).not.toContain(ids!.taskIds[2]);
|
||||
for (const excludedId of ids!.excludedTaskIds) {
|
||||
expect(returnedIds).not.toContain(excludedId);
|
||||
}
|
||||
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes another user mission task notes on the same authorized mission', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const activeCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.activeGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(activeCert);
|
||||
const { context } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
const response = await listController.list('notes', request, { limit: 10 });
|
||||
const returnedIds = response.items.map((item) => item['id']);
|
||||
|
||||
expect(returnedIds).toEqual([ids!.subjectNoteId]);
|
||||
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
|
||||
expect(response.items.every((item) => item._source === 'local')).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed for unsupported list resources', async () => {
|
||||
expect(ids).toBeDefined();
|
||||
const activeCert = await makeMosaicIssuedCert({
|
||||
grantId: ids!.activeGrantId,
|
||||
subjectUserId: ids!.subjectUserId,
|
||||
});
|
||||
const request = makeFederationRequest(activeCert);
|
||||
const { context } = makeGuardContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
|
||||
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Requested federation resource is not supported',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||
import { sql, type Db } from '@mosaicstack/db';
|
||||
import { createQueue } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { ProviderService } from '../agent/provider.service.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { AdminGuard } from './admin.guard.js';
|
||||
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
||||
|
||||
@@ -16,9 +14,6 @@ export class AdminHealthController {
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -60,14 +55,6 @@ export class AdminHealthController {
|
||||
}
|
||||
|
||||
private async checkCache(): Promise<ServiceStatusDto> {
|
||||
// On Local tier there is no Redis. The cache is intentionally absent, which
|
||||
// is a healthy state for this tier — report 'ok' rather than opening a new
|
||||
// ioredis connection on every admin health check (which would spam
|
||||
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
|
||||
// signals "no cache backend to measure" for this tier.
|
||||
if (this.mosaicConfig?.queue?.type === 'local') {
|
||||
return { status: 'ok', latencyMs: 0 };
|
||||
}
|
||||
const start = Date.now();
|
||||
const handle = createQueue();
|
||||
try {
|
||||
|
||||
@@ -72,13 +72,13 @@ const mockChatGateway = {
|
||||
broadcastSessionInfo: vi.fn(),
|
||||
};
|
||||
|
||||
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
|
||||
function buildService(): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
mockRegistry as never,
|
||||
mockAgentService as never,
|
||||
mockSystemOverride as never,
|
||||
mockSessionGC as never,
|
||||
redis as never,
|
||||
mockRedis as never,
|
||||
mockBrain as never,
|
||||
null,
|
||||
mockChatGateway as never,
|
||||
@@ -131,22 +131,6 @@ 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 };
|
||||
|
||||
@@ -23,10 +23,7 @@ export class CommandExecutorService {
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
|
||||
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
|
||||
@Optional()
|
||||
@Inject(COMMANDS_REDIS)
|
||||
private readonly redis: QueueHandle['redis'] | null,
|
||||
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => ReloadService))
|
||||
@@ -446,16 +443,14 @@ export class CommandExecutorService {
|
||||
byte.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
const key = `mosaic:auth:poll:${tokenHash}`;
|
||||
if (this.redis) {
|
||||
// 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(
|
||||
key,
|
||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||
'EX',
|
||||
300,
|
||||
);
|
||||
}
|
||||
// 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(
|
||||
key,
|
||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||
'EX',
|
||||
300,
|
||||
);
|
||||
return {
|
||||
command: 'provider',
|
||||
success: true,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
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';
|
||||
@@ -18,17 +16,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
providers: [
|
||||
{
|
||||
provide: COMMANDS_QUEUE_HANDLE,
|
||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||
// On Local tier there is no Redis — skip the ioredis connection.
|
||||
// CommandExecutorService falls back to no-cache for /provider login on local.
|
||||
if (config?.queue?.type === 'local') return null;
|
||||
useFactory: (): QueueHandle => {
|
||||
return createQueue();
|
||||
},
|
||||
inject: [MOSAIC_CONFIG],
|
||||
},
|
||||
{
|
||||
provide: COMMANDS_REDIS,
|
||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
inject: [COMMANDS_QUEUE_HANDLE],
|
||||
},
|
||||
CommandRegistryService,
|
||||
@@ -44,13 +38,9 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
],
|
||||
})
|
||||
export class CommandsModule implements OnApplicationShutdown {
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(COMMANDS_QUEUE_HANDLE)
|
||||
private readonly handle: QueueHandle | null,
|
||||
) {}
|
||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle?.close().catch(() => {});
|
||||
await this.handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import { EnrollmentController } from './enrollment.controller.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
import { FederationController } from './federation.controller.js';
|
||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
||||
import { GetController } from './server/verbs/get.controller.js';
|
||||
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
|
||||
import { GrantsService } from './grants.service.js';
|
||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
||||
@@ -14,13 +12,7 @@ import { ListController } from './server/verbs/list.controller.js';
|
||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
EnrollmentController,
|
||||
FederationController,
|
||||
CapabilitiesController,
|
||||
ListController,
|
||||
GetController,
|
||||
],
|
||||
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
||||
providers: [
|
||||
AdminGuard,
|
||||
CaService,
|
||||
@@ -31,7 +23,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
FederationGetQueryService,
|
||||
],
|
||||
exports: [
|
||||
CaService,
|
||||
@@ -42,7 +33,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
||||
FederationAuthGuard,
|
||||
FederationScopeService,
|
||||
FederationListQueryService,
|
||||
FederationGetQueryService,
|
||||
],
|
||||
})
|
||||
export class FederationModule {}
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createPgliteDb,
|
||||
missionTasks,
|
||||
missions,
|
||||
projects,
|
||||
runPgliteMigrations,
|
||||
teams,
|
||||
users,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
||||
import { FederationGetQueryService } from '../get-query.service.js';
|
||||
|
||||
const CREDENTIAL_FILTER: FederationScopeQueryFilter = {
|
||||
resource: 'credentials',
|
||||
subjectUserId: 'user-1',
|
||||
includePersonal: true,
|
||||
teamIds: [],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 25,
|
||||
};
|
||||
|
||||
const SUBJECT_USER_ID = 'fed-m3-06-subject';
|
||||
const OTHER_USER_ID = 'fed-m3-06-other';
|
||||
const TEAM_ID = '06000000-0000-4000-8000-000000000001';
|
||||
const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002';
|
||||
const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101';
|
||||
const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102';
|
||||
const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103';
|
||||
const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201';
|
||||
const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202';
|
||||
const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203';
|
||||
const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301';
|
||||
const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302';
|
||||
const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303';
|
||||
const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304';
|
||||
|
||||
let dbHandle: DbHandle | undefined;
|
||||
|
||||
function makeService() {
|
||||
return new FederationGetQueryService({} as Db);
|
||||
}
|
||||
|
||||
function makeDbService() {
|
||||
if (!dbHandle) {
|
||||
throw new Error('test DB not initialized');
|
||||
}
|
||||
return new FederationGetQueryService(dbHandle.db);
|
||||
}
|
||||
|
||||
async function seedNotesFixture() {
|
||||
if (!dbHandle) {
|
||||
throw new Error('test DB not initialized');
|
||||
}
|
||||
|
||||
await dbHandle.db.insert(users).values([
|
||||
{
|
||||
id: SUBJECT_USER_ID,
|
||||
name: 'Federation Subject',
|
||||
email: `${SUBJECT_USER_ID}@example.test`,
|
||||
emailVerified: false,
|
||||
},
|
||||
{
|
||||
id: OTHER_USER_ID,
|
||||
name: 'Federation Other',
|
||||
email: `${OTHER_USER_ID}@example.test`,
|
||||
emailVerified: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(teams).values([
|
||||
{
|
||||
id: TEAM_ID,
|
||||
name: 'FED-M3-06 Team',
|
||||
slug: 'fed-m3-06-team',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
managerId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_TEAM_ID,
|
||||
name: 'FED-M3-06 Unauthorized Team',
|
||||
slug: 'fed-m3-06-unauthorized-team',
|
||||
ownerId: OTHER_USER_ID,
|
||||
managerId: OTHER_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(projects).values([
|
||||
{
|
||||
id: PERSONAL_PROJECT_ID,
|
||||
name: 'FED-M3-06 Personal Project',
|
||||
ownerId: SUBJECT_USER_ID,
|
||||
ownerType: 'user',
|
||||
},
|
||||
{
|
||||
id: TEAM_PROJECT_ID,
|
||||
name: 'FED-M3-06 Team Project',
|
||||
teamId: TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_PROJECT_ID,
|
||||
name: 'FED-M3-06 Unauthorized Project',
|
||||
teamId: UNAUTHORIZED_TEAM_ID,
|
||||
ownerType: 'team',
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(missions).values([
|
||||
{
|
||||
id: PERSONAL_MISSION_ID,
|
||||
name: 'FED-M3-06 Personal Mission',
|
||||
projectId: PERSONAL_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: TEAM_MISSION_ID,
|
||||
name: 'FED-M3-06 Team Mission',
|
||||
projectId: TEAM_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
{
|
||||
id: UNAUTHORIZED_MISSION_ID,
|
||||
name: 'FED-M3-06 Unauthorized Mission',
|
||||
projectId: UNAUTHORIZED_PROJECT_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
await dbHandle.db.insert(missionTasks).values([
|
||||
{
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note on team mission',
|
||||
createdAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: OTHER_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
userId: OTHER_USER_ID,
|
||||
notes: 'other user note on team mission',
|
||||
createdAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||
missionId: PERSONAL_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note on personal mission',
|
||||
createdAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||
missionId: UNAUTHORIZED_MISSION_ID,
|
||||
userId: SUBJECT_USER_ID,
|
||||
notes: 'subject note outside grant-visible missions',
|
||||
createdAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
describe('FederationGetQueryService', () => {
|
||||
beforeAll(async () => {
|
||||
dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`);
|
||||
await runPgliteMigrations(dbHandle);
|
||||
await seedNotesFixture();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await dbHandle?.close();
|
||||
dbHandle = undefined;
|
||||
});
|
||||
|
||||
it('denies sensitive resources in native RBAC for M3 get reads', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'credentials',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: 'credentials federation get access is not implemented in M3',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows personal memory reads without requiring team lookup', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'memory',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses subject team membership as the native RBAC upper bound for task and note reads', async () => {
|
||||
const service = makeService();
|
||||
const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']);
|
||||
(
|
||||
service as unknown as {
|
||||
listSubjectTeamIds: (subjectUserId: string) => Promise<string[]>;
|
||||
}
|
||||
).listSubjectTeamIds = listSubjectTeamIds;
|
||||
|
||||
await expect(
|
||||
service.evaluateReadAccess({
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'tasks',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
allowed: true,
|
||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
||||
});
|
||||
expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('does not query storage for sensitive get resources even if scope allowed them', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'credentials federation get is not implemented',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for unsupported resources instead of returning undefined', async () => {
|
||||
const service = makeService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
...CREDENTIAL_FILTER,
|
||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||
},
|
||||
id: 'row-1',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Unsupported federation get resource: unknown-resource',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leak another user mission task note through team-scoped get reads', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: OTHER_TEAM_NOTE_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Note is outside the federated scope',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: true,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Note is outside the federated scope',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a subject note only when subject ownership and authorized mission intersect', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: 'found',
|
||||
item: {
|
||||
id: SUBJECT_TEAM_NOTE_ID,
|
||||
missionId: TEAM_MISSION_ID,
|
||||
content: 'subject note on team mission',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not return subject personal notes when includePersonal is false', async () => {
|
||||
const service = makeDbService();
|
||||
|
||||
await expect(
|
||||
service.get({
|
||||
filter: {
|
||||
resource: 'notes',
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
includePersonal: false,
|
||||
teamIds: [TEAM_ID],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 10,
|
||||
},
|
||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
status: 'denied',
|
||||
reason: 'Note is outside the federated scope',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
||||
import type {
|
||||
FederationScopeEvaluationResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../../scope.service.js';
|
||||
import { GetController } from '../get.controller.js';
|
||||
import type { FederationGetQueryResult } from '../get-query.service.js';
|
||||
|
||||
const FEDERATION_CONTEXT = {
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
scope: { resources: ['tasks'], max_rows_per_query: 25 },
|
||||
};
|
||||
|
||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
||||
resource: 'tasks',
|
||||
subjectUserId: 'user-1',
|
||||
includePersonal: true,
|
||||
teamIds: ['team-1'],
|
||||
limit: 1,
|
||||
maxRowsPerQuery: 25,
|
||||
};
|
||||
|
||||
function makeRequest(): FastifyRequest {
|
||||
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
|
||||
}
|
||||
|
||||
function allowedScope(
|
||||
filter: FederationScopeQueryFilter = TASK_FILTER,
|
||||
): FederationScopeEvaluationResult {
|
||||
return { allowed: true, filter };
|
||||
}
|
||||
|
||||
function makeController(opts?: {
|
||||
scopeResult?: FederationScopeEvaluationResult;
|
||||
queryResult?: FederationGetQueryResult;
|
||||
}) {
|
||||
const scope = {
|
||||
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
|
||||
};
|
||||
const query = {
|
||||
evaluateReadAccess: vi.fn(),
|
||||
get: vi.fn().mockResolvedValue(
|
||||
opts?.queryResult ?? {
|
||||
status: 'found',
|
||||
item: {
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
},
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
controller: new GetController(scope as never, query as never),
|
||||
scope,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GetController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => {
|
||||
expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get');
|
||||
expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id');
|
||||
expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]);
|
||||
});
|
||||
|
||||
it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
const response = await controller.get('tasks', 'task-1', makeRequest());
|
||||
|
||||
expect(scope.evaluateAccess).toHaveBeenCalledWith({
|
||||
context: FEDERATION_CONTEXT,
|
||||
resource: 'tasks',
|
||||
requestedLimit: 1,
|
||||
nativeRbac: query,
|
||||
});
|
||||
expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' });
|
||||
expect(response).toEqual({
|
||||
item: {
|
||||
id: 'task-1',
|
||||
title: 'Federated task',
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
||||
_source: 'local',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a federation error envelope when auth guard context is missing', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(
|
||||
controller.get('tasks', 'task-1', {} as unknown as FastifyRequest),
|
||||
).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'unauthorized',
|
||||
message: 'Federation context missing',
|
||||
},
|
||||
},
|
||||
status: 401,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
||||
const { controller, query } = makeController({
|
||||
scopeResult: {
|
||||
allowed: false,
|
||||
deny: {
|
||||
code: 'resource_excluded',
|
||||
stage: 'resource_exclusion',
|
||||
statusCode: 403,
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
grantId: 'grant-1',
|
||||
peerId: 'peer-1',
|
||||
subjectUserId: 'user-1',
|
||||
resource: 'credentials',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(controller.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
expect(query.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when the scoped query layer cannot find the resource id', async () => {
|
||||
const { controller } = makeController({ queryResult: { status: 'not_found' } });
|
||||
|
||||
await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({
|
||||
response: { error: { code: 'not_found' } },
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => {
|
||||
const { controller } = makeController({
|
||||
queryResult: { status: 'denied', reason: 'Task is outside the federated scope' },
|
||||
});
|
||||
|
||||
await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Task is outside the federated scope',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when the query layer denies an unsupported resource', async () => {
|
||||
const unsupportedFilter: FederationScopeQueryFilter = {
|
||||
...TASK_FILTER,
|
||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
||||
};
|
||||
const { controller } = makeController({
|
||||
scopeResult: allowedScope(unsupportedFilter),
|
||||
queryResult: {
|
||||
status: 'denied',
|
||||
reason: 'Unsupported federation get resource: unknown-resource',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({
|
||||
response: {
|
||||
error: {
|
||||
code: 'scope_violation',
|
||||
message: 'Unsupported federation get resource: unknown-resource',
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty ids before evaluating scope', async () => {
|
||||
const { controller, scope, query } = makeController();
|
||||
|
||||
await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({
|
||||
response: { error: { code: 'invalid_request' } },
|
||||
status: 400,
|
||||
});
|
||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
||||
expect(query.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,311 +0,0 @@
|
||||
/**
|
||||
* Federation get query layer (FED-M3-06).
|
||||
*
|
||||
* Read-only DB adapter used by GetController after FederationAuthGuard and
|
||||
* FederationScopeService have established the subject user, allowed resource,
|
||||
* native-RBAC intersection, and row cap. Audit writes are intentionally
|
||||
* deferred to M4.
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
insights,
|
||||
or,
|
||||
missionTasks,
|
||||
missions,
|
||||
preferences,
|
||||
projects,
|
||||
tasks,
|
||||
teamMembers,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../../../database/database.module.js';
|
||||
import type {
|
||||
FederationNativeRbacEvaluator,
|
||||
FederationNativeRbacRequest,
|
||||
FederationNativeRbacResult,
|
||||
FederationScopeQueryFilter,
|
||||
} from '../scope.service.js';
|
||||
|
||||
export interface FederationGetQueryRequest {
|
||||
readonly filter: FederationScopeQueryFilter;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export interface FederationGetQueryFoundResult<T extends object = Record<string, unknown>> {
|
||||
readonly status: 'found';
|
||||
readonly item: T;
|
||||
}
|
||||
|
||||
export interface FederationGetQueryNotFoundResult {
|
||||
readonly status: 'not_found';
|
||||
}
|
||||
|
||||
export interface FederationGetQueryDeniedResult {
|
||||
readonly status: 'denied';
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export type FederationGetQueryResult<T extends object = Record<string, unknown>> =
|
||||
| FederationGetQueryFoundResult<T>
|
||||
| FederationGetQueryNotFoundResult
|
||||
| FederationGetQueryDeniedResult;
|
||||
|
||||
type RowObject = Record<string, unknown>;
|
||||
|
||||
function firstRow<T>(rows: T[]): T | undefined {
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
function rowBelongsToAccessibleProjectOrMission(
|
||||
row: { projectId?: string | null; missionId?: string | null },
|
||||
projectIds: readonly string[],
|
||||
missionIds: readonly string[],
|
||||
): boolean {
|
||||
return (
|
||||
(typeof row.projectId === 'string' && projectIds.includes(row.projectId)) ||
|
||||
(typeof row.missionId === 'string' && missionIds.includes(row.missionId))
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FederationGetQueryService implements FederationNativeRbacEvaluator {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async evaluateReadAccess(
|
||||
request: FederationNativeRbacRequest,
|
||||
): Promise<FederationNativeRbacResult> {
|
||||
if (request.resource === 'credentials' || request.resource === 'api_keys') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `${request.resource} federation get access is not implemented in M3`,
|
||||
details: { resource: request.resource },
|
||||
};
|
||||
}
|
||||
|
||||
if (request.resource === 'memory') {
|
||||
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
|
||||
}
|
||||
|
||||
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
|
||||
return { allowed: true, access: { includePersonal: true, teamIds } };
|
||||
}
|
||||
|
||||
async get<T extends RowObject = RowObject>(
|
||||
request: FederationGetQueryRequest,
|
||||
): Promise<FederationGetQueryResult<T>> {
|
||||
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
|
||||
}
|
||||
|
||||
private async getByResource(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
switch (filter.resource) {
|
||||
case 'tasks':
|
||||
return this.getTask(filter, id);
|
||||
case 'notes':
|
||||
return this.getNote(filter, id);
|
||||
case 'memory':
|
||||
return this.getMemory(filter, id);
|
||||
case 'credentials':
|
||||
case 'api_keys':
|
||||
return { status: 'denied', reason: `${filter.resource} federation get is not implemented` };
|
||||
default:
|
||||
return {
|
||||
status: 'denied',
|
||||
reason: `Unsupported federation get resource: ${String(filter.resource)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, subjectUserId));
|
||||
|
||||
return rows.map((row) => row.teamId);
|
||||
}
|
||||
|
||||
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
|
||||
const clauses = [];
|
||||
if (filter.includePersonal) {
|
||||
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
|
||||
}
|
||||
if (filter.teamIds.length > 0) {
|
||||
// Project team ownership follows TeamsService.canAccessProject: team-owned
|
||||
// rows are authorized through projects.teamId, while ownerId remains the
|
||||
// user who created/bootstrapped the project.
|
||||
clauses.push(
|
||||
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
|
||||
);
|
||||
}
|
||||
|
||||
if (clauses.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
|
||||
if (projectIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({ id: missions.id })
|
||||
.from(missions)
|
||||
.where(inArray(missions.projectId, [...projectIds]));
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
private async getTask(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
const row = firstRow(
|
||||
await this.db
|
||||
.select({
|
||||
id: tasks.id,
|
||||
title: tasks.title,
|
||||
description: tasks.description,
|
||||
status: tasks.status,
|
||||
priority: tasks.priority,
|
||||
projectId: tasks.projectId,
|
||||
missionId: tasks.missionId,
|
||||
assignee: tasks.assignee,
|
||||
tags: tasks.tags,
|
||||
dueDate: tasks.dueDate,
|
||||
metadata: tasks.metadata,
|
||||
createdAt: tasks.createdAt,
|
||||
updatedAt: tasks.updatedAt,
|
||||
})
|
||||
.from(tasks)
|
||||
.where(eq(tasks.id, id))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) {
|
||||
return { status: 'denied', reason: 'Task is outside the federated scope' };
|
||||
}
|
||||
|
||||
return { status: 'found', item: row as RowObject };
|
||||
}
|
||||
|
||||
private async getNote(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
const row = firstRow(
|
||||
await this.db
|
||||
.select({
|
||||
id: missionTasks.id,
|
||||
missionId: missionTasks.missionId,
|
||||
taskId: missionTasks.taskId,
|
||||
userId: missionTasks.userId,
|
||||
status: missionTasks.status,
|
||||
content: missionTasks.notes,
|
||||
createdAt: missionTasks.createdAt,
|
||||
updatedAt: missionTasks.updatedAt,
|
||||
})
|
||||
.from(missionTasks)
|
||||
.where(eq(missionTasks.id, id))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
if (!row || row.content === null || row.content === '') {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
||||
const missionIds = await this.listMissionIds(projectIds);
|
||||
|
||||
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
||||
// Scope-visible missions must intersect with subject ownership; team scope
|
||||
// narrows mission IDs but never widens note reads to another user's rows.
|
||||
if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) {
|
||||
return { status: 'denied', reason: 'Note is outside the federated scope' };
|
||||
}
|
||||
|
||||
const item = { ...row } as RowObject;
|
||||
delete item['userId'];
|
||||
return { status: 'found', item };
|
||||
}
|
||||
|
||||
private async getMemory(
|
||||
filter: FederationScopeQueryFilter,
|
||||
id: string,
|
||||
): Promise<FederationGetQueryResult> {
|
||||
const [insightRow, preferenceRow] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
id: insights.id,
|
||||
userId: insights.userId,
|
||||
kind: insights.source,
|
||||
content: insights.content,
|
||||
category: insights.category,
|
||||
relevanceScore: insights.relevanceScore,
|
||||
metadata: insights.metadata,
|
||||
createdAt: insights.createdAt,
|
||||
updatedAt: insights.updatedAt,
|
||||
})
|
||||
.from(insights)
|
||||
.where(eq(insights.id, id))
|
||||
.limit(1)
|
||||
.then(firstRow),
|
||||
this.db
|
||||
.select({
|
||||
id: preferences.id,
|
||||
userId: preferences.userId,
|
||||
kind: preferences.category,
|
||||
key: preferences.key,
|
||||
value: preferences.value,
|
||||
source: preferences.source,
|
||||
mutable: preferences.mutable,
|
||||
createdAt: preferences.createdAt,
|
||||
updatedAt: preferences.updatedAt,
|
||||
})
|
||||
.from(preferences)
|
||||
.where(eq(preferences.id, id))
|
||||
.limit(1)
|
||||
.then(firstRow),
|
||||
]);
|
||||
|
||||
const candidates = [insightRow, preferenceRow].filter(
|
||||
(row): row is NonNullable<typeof row> => row !== undefined,
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
if (!filter.includePersonal) {
|
||||
return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' };
|
||||
}
|
||||
|
||||
const accessible = candidates.find((row) => row.userId === filter.subjectUserId);
|
||||
if (!accessible) {
|
||||
return { status: 'denied', reason: 'Memory row belongs to another subject user' };
|
||||
}
|
||||
|
||||
const item = { ...accessible } as RowObject;
|
||||
delete item['userId'];
|
||||
return { status: 'found', item };
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* Federation get verb (FED-M3-06).
|
||||
*
|
||||
* POST /api/federation/v1/get/:resource/:id
|
||||
*
|
||||
* Pipeline: FederationAuthGuard attaches the active grant context, then
|
||||
* FederationScopeService enforces grant scope + native RBAC intersection, then
|
||||
* the read-only query layer fetches one local row and tags it with `_source`.
|
||||
* Read audit-log writes are deferred to M4; this controller does not persist
|
||||
* request or response bodies.
|
||||
*/
|
||||
|
||||
import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import {
|
||||
FederationInvalidRequestError,
|
||||
FederationNotFoundError,
|
||||
FederationScopeViolationError,
|
||||
FederationUnauthorizedError,
|
||||
SOURCE_LOCAL,
|
||||
type FederationGetResponse,
|
||||
type SourceTag,
|
||||
} from '@mosaicstack/types';
|
||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
||||
import '../federation-context.js';
|
||||
import { FederationScopeService } from '../scope.service.js';
|
||||
import { FederationGetQueryService } from './get-query.service.js';
|
||||
|
||||
type FederatedRow = Record<string, unknown> & SourceTag;
|
||||
|
||||
function scopeDenyToHttpException(deny: {
|
||||
readonly statusCode: 400 | 403;
|
||||
readonly message: string;
|
||||
}): HttpException {
|
||||
const ErrorClass =
|
||||
deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError;
|
||||
return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode);
|
||||
}
|
||||
|
||||
@Controller('api/federation/v1/get')
|
||||
@UseGuards(FederationAuthGuard)
|
||||
export class GetController {
|
||||
constructor(
|
||||
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
|
||||
@Inject(FederationGetQueryService) private readonly query: FederationGetQueryService,
|
||||
) {}
|
||||
|
||||
@Post(':resource/:id')
|
||||
async get(
|
||||
@Param('resource') resource: string,
|
||||
@Param('id') id: string,
|
||||
@Req() request: FastifyRequest,
|
||||
): Promise<FederationGetResponse<FederatedRow>> {
|
||||
if (!request.federationContext) {
|
||||
throw new HttpException(
|
||||
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
||||
401,
|
||||
);
|
||||
}
|
||||
if (id.trim().length === 0) {
|
||||
throw new HttpException(
|
||||
new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const scopeResult = await this.scope.evaluateAccess({
|
||||
context: request.federationContext,
|
||||
resource,
|
||||
requestedLimit: 1,
|
||||
nativeRbac: this.query,
|
||||
});
|
||||
|
||||
if (!scopeResult.allowed) {
|
||||
throw scopeDenyToHttpException(scopeResult.deny);
|
||||
}
|
||||
|
||||
const result = await this.query.get({ filter: scopeResult.filter, id });
|
||||
if (result.status === 'not_found') {
|
||||
throw new HttpException(
|
||||
new FederationNotFoundError('Requested federation resource was not found').toEnvelope(),
|
||||
404,
|
||||
);
|
||||
}
|
||||
if (result.status === 'denied') {
|
||||
throw new HttpException(
|
||||
new FederationScopeViolationError(result.reason, {
|
||||
resource,
|
||||
id,
|
||||
grantId: request.federationContext.grantId,
|
||||
peerId: request.federationContext.peerId,
|
||||
subjectUserId: request.federationContext.subjectUserId,
|
||||
}).toEnvelope(),
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
return { item: { ...result.item, _source: SOURCE_LOCAL } };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
|
||||
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { SessionGCService } from './session-gc.service.js';
|
||||
import { REDIS } from './gc.tokens.js';
|
||||
|
||||
@@ -11,17 +9,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
providers: [
|
||||
{
|
||||
provide: GC_QUEUE_HANDLE,
|
||||
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||
// On Local tier there is no Redis — skip the ioredis connection entirely.
|
||||
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
|
||||
if (config?.queue?.type === 'local') return null;
|
||||
useFactory: (): QueueHandle => {
|
||||
return createQueue();
|
||||
},
|
||||
inject: [MOSAIC_CONFIG],
|
||||
},
|
||||
{
|
||||
provide: REDIS,
|
||||
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||
useFactory: (handle: QueueHandle) => handle.redis,
|
||||
inject: [GC_QUEUE_HANDLE],
|
||||
},
|
||||
SessionGCService,
|
||||
@@ -29,13 +23,9 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
||||
exports: [SessionGCService],
|
||||
})
|
||||
export class GCModule implements OnApplicationShutdown {
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(GC_QUEUE_HANDLE)
|
||||
private readonly handle: QueueHandle | null,
|
||||
) {}
|
||||
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle?.close().catch(() => {});
|
||||
await this.handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,19 +119,6 @@ describe('SessionGCService', () => {
|
||||
).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');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { QueueHandle } from '@mosaicstack/queue';
|
||||
import type { LogService } from '@mosaicstack/log';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
@@ -21,10 +21,7 @@ function escapeRedisGlobLiteral(value: string): string {
|
||||
@Injectable()
|
||||
export class SessionGCService {
|
||||
constructor(
|
||||
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
|
||||
@Optional()
|
||||
@Inject(REDIS)
|
||||
private readonly redis: QueueHandle['redis'] | null,
|
||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
@@ -32,10 +29,8 @@ export class SessionGCService {
|
||||
* 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 an empty population on the Local tier where Redis is disabled.
|
||||
*/
|
||||
private async scanKeys(pattern: string): Promise<string[]> {
|
||||
if (!this.redis) return [];
|
||||
const collected: string[] = [];
|
||||
let cursor = '0';
|
||||
do {
|
||||
@@ -52,14 +47,12 @@ export class SessionGCService {
|
||||
async collect(sessionId: string): Promise<GCResult> {
|
||||
const result: GCResult = { sessionId, cleaned: {} };
|
||||
|
||||
// 1. Valkey: delete all session-scoped keys (skipped on Local tier).
|
||||
if (this.redis) {
|
||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||
const valkeyKeys = await this.scanKeys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
}
|
||||
// 1. Valkey: delete all session-scoped keys
|
||||
const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`;
|
||||
const valkeyKeys = await this.scanKeys(pattern);
|
||||
if (valkeyKeys.length > 0) {
|
||||
await this.redis.del(...valkeyKeys);
|
||||
result.cleaned.valkeyKeys = valkeyKeys.length;
|
||||
}
|
||||
|
||||
// 2. PG: demote hot-tier agent logs for this session only.
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
|
||||
@Injectable()
|
||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CronService.name);
|
||||
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
|
||||
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
|
||||
|
||||
constructor(
|
||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||
@@ -26,12 +26,6 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||
|
||||
@@ -45,7 +39,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
|
||||
await this.summarization.runSummarization();
|
||||
});
|
||||
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
|
||||
this.registeredWorkers.push(summarizationWorker);
|
||||
|
||||
// M6-005: Tier management repeatable job
|
||||
await this.queueService.addRepeatableJob(
|
||||
@@ -57,7 +51,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
|
||||
await this.summarization.runTierManagement();
|
||||
});
|
||||
if (tierWorker) this.registeredWorkers.push(tierWorker);
|
||||
this.registeredWorkers.push(tierWorker);
|
||||
|
||||
// Retire any repeatable global GC schedule created by older deployments.
|
||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import { Injectable, Logger } 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 scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||
@@ -17,45 +15,16 @@ interface OverrideFragment {
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
interface LocalOverrideEntry {
|
||||
condensed: string;
|
||||
fragments: OverrideFragment[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SystemOverrideService implements OnApplicationShutdown {
|
||||
export class SystemOverrideService {
|
||||
private readonly logger = new Logger(SystemOverrideService.name);
|
||||
private readonly handle: QueueHandle | null;
|
||||
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
|
||||
private readonly localStore = new Map<string, LocalOverrideEntry>();
|
||||
private readonly handle: QueueHandle;
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {
|
||||
this.handle = this.mosaicConfig?.queue?.type === 'local' ? null : createQueue();
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.handle?.close().catch(() => {});
|
||||
constructor() {
|
||||
this.handle = createQueue();
|
||||
}
|
||||
|
||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
||||
if (!this.handle) {
|
||||
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((fragment) => fragment.text),
|
||||
);
|
||||
this.localStore.set(key, entry);
|
||||
this.logger.debug(
|
||||
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load existing fragments
|
||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
||||
const fragments: OverrideFragment[] = existing
|
||||
@@ -85,14 +54,10 @@ export class SystemOverrideService implements OnApplicationShutdown {
|
||||
}
|
||||
|
||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
||||
if (!this.handle) {
|
||||
return this.localStore.get(scopedSessionId(sessionId, scope))?.condensed ?? null;
|
||||
}
|
||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
||||
}
|
||||
|
||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
if (!this.handle) return;
|
||||
const pipeline = this.handle.redis.pipeline();
|
||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
@@ -100,11 +65,6 @@ export class SystemOverrideService implements OnApplicationShutdown {
|
||||
}
|
||||
|
||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
if (!this.handle) {
|
||||
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, scope),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { QueueService } from './queue.service.js';
|
||||
|
||||
const localConfig = {
|
||||
queue: { type: 'local' },
|
||||
} as MosaicConfig;
|
||||
|
||||
describe('QueueService local tier', () => {
|
||||
it('disables BullMQ and treats queue operations as local no-ops', async () => {
|
||||
const service = new QueueService(null, localConfig);
|
||||
|
||||
expect(service.isEnabled()).toBe(false);
|
||||
expect(service.getQueue('mosaic-test')).toBeNull();
|
||||
expect(service.registerWorker('mosaic-test', vi.fn())).toBeNull();
|
||||
|
||||
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({
|
||||
ok: false,
|
||||
message: 'BullMQ is disabled on local tier.',
|
||||
});
|
||||
await expect(service.pauseQueue('mosaic-test')).resolves.toEqual({
|
||||
ok: false,
|
||||
message: 'BullMQ is disabled on local tier.',
|
||||
});
|
||||
await expect(service.resumeQueue('mosaic-test')).resolves.toEqual({
|
||||
ok: false,
|
||||
message: 'BullMQ is disabled on local tier.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
|
||||
import type { LogService } from '@mosaicstack/log';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import type { JobDto, JobStatus } from './queue-admin.dto.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -110,42 +108,21 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly connection: ConnectionOptions;
|
||||
private readonly queues = new Map<string, Queue<MosaicJobData>>();
|
||||
private readonly workers = new Map<string, Worker<MosaicJobData>>();
|
||||
/** False on Local tier — BullMQ/Redis operations become no-ops. */
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(LOG_SERVICE)
|
||||
private readonly logService: LogService | null,
|
||||
@Optional()
|
||||
@Inject(MOSAIC_CONFIG)
|
||||
private readonly mosaicConfig: MosaicConfig | null,
|
||||
) {
|
||||
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
|
||||
this.connection = this.enabled
|
||||
? getConnection()
|
||||
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
|
||||
}
|
||||
|
||||
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
this.connection = getConnection();
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (this.enabled) {
|
||||
this.logger.log('QueueService initialised (BullMQ)');
|
||||
} else {
|
||||
this.logger.log(
|
||||
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
|
||||
);
|
||||
}
|
||||
this.logger.log('QueueService initialised (BullMQ)');
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.enabled) {
|
||||
await this.closeAll();
|
||||
}
|
||||
await this.closeAll();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -154,10 +131,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/**
|
||||
* Get or create a BullMQ Queue for the given queue name.
|
||||
* Returns null on Local tier where BullMQ is disabled.
|
||||
*/
|
||||
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
|
||||
if (!this.enabled) return null;
|
||||
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
|
||||
let queue = this.queues.get(name) as Queue<T> | undefined;
|
||||
if (!queue) {
|
||||
queue = new Queue<T>(name, { connection: this.connection });
|
||||
@@ -169,7 +144,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Add a BullMQ repeatable job (cron-style).
|
||||
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
|
||||
* No-op on Local tier.
|
||||
*/
|
||||
async addRepeatableJob<T extends MosaicJobData>(
|
||||
queueName: string,
|
||||
@@ -177,13 +151,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
data: T,
|
||||
cronExpression: string,
|
||||
): Promise<void> {
|
||||
if (!this.enabled) {
|
||||
this.logger.debug(
|
||||
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const queue = this.getQueue<T>(queueName)!;
|
||||
const queue = this.getQueue<T>(queueName);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (queue as Queue<any>).add(jobName, data, {
|
||||
repeat: { pattern: cronExpression },
|
||||
@@ -199,14 +167,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* safe retirement of previously registered system-wide jobs.
|
||||
*/
|
||||
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
||||
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)));
|
||||
@@ -221,18 +182,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Register a Worker for the given queue name with error handling and
|
||||
* exponential backoff.
|
||||
* Returns null on Local tier where BullMQ is disabled.
|
||||
*/
|
||||
registerWorker<T extends MosaicJobData>(
|
||||
queueName: string,
|
||||
handler: JobHandler<T>,
|
||||
): Worker<T> | null {
|
||||
if (!this.enabled) {
|
||||
this.logger.debug(
|
||||
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
|
||||
const worker = new Worker<T>(
|
||||
queueName,
|
||||
async (job) => {
|
||||
@@ -289,12 +240,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/**
|
||||
* Return queue health statistics for all managed queues.
|
||||
* Returns an empty healthy result on Local tier.
|
||||
*/
|
||||
async getHealthStatus(): Promise<QueueHealthStatus> {
|
||||
if (!this.enabled) {
|
||||
return { queues: {}, healthy: true };
|
||||
}
|
||||
const queues: QueueHealthStatus['queues'] = {};
|
||||
let healthy = true;
|
||||
|
||||
@@ -325,10 +272,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* List jobs across all managed queues, optionally filtered by status.
|
||||
* BullMQ jobs are fetched by state type from each queue.
|
||||
* Returns empty array on Local tier.
|
||||
*/
|
||||
async listJobs(status?: JobStatus): Promise<JobDto[]> {
|
||||
if (!this.enabled) return [];
|
||||
const jobs: JobDto[] = [];
|
||||
const states: JobStatus[] = status
|
||||
? [status]
|
||||
@@ -355,10 +300,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
|
||||
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
|
||||
* job IDs are not globally unique — they are scoped to their queue.
|
||||
* Returns an error on Local tier.
|
||||
*/
|
||||
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const sep = compositeId.lastIndexOf('__');
|
||||
if (sep === -1) {
|
||||
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
|
||||
@@ -390,7 +333,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Pause a queue by name.
|
||||
*/
|
||||
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const queue = this.queues.get(name);
|
||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||
await queue.pause();
|
||||
@@ -402,7 +344,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
* Resume a paused queue by name.
|
||||
*/
|
||||
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
|
||||
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
|
||||
const queue = this.queues.get(name);
|
||||
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
|
||||
await queue.resume();
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mosaic</title>
|
||||
<meta name="description" content="Mosaic Stack Dashboard" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
|
||||
/>
|
||||
<script>
|
||||
// set data-theme before first paint so the stored theme never flashes
|
||||
(function () {
|
||||
try {
|
||||
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
|
||||
} catch (error) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,10 +3,8 @@
|
||||
"version": "0.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build-web.mjs",
|
||||
"build:vite": "vite build",
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"dev:vite": "vite",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
@@ -21,7 +19,6 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
@@ -31,11 +28,9 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"jsdom": "^29.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,56 +3,41 @@
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
import { getSsoProvider } from '@/lib/sso-providers';
|
||||
|
||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||
const params = useParams<{ provider: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const provider = getSsoProvider(providerId);
|
||||
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const currentProvider = provider;
|
||||
|
||||
if (!currentProvider) {
|
||||
setError('Unknown SSO provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentProvider.enabled) {
|
||||
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeProvider = currentProvider;
|
||||
let cancelled = false;
|
||||
|
||||
async function redirectToProvider(): Promise<void> {
|
||||
try {
|
||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
||||
if (cancelled) return;
|
||||
const result = await signIn.oauth2({
|
||||
providerId: activeProvider.id,
|
||||
callbackURL,
|
||||
});
|
||||
|
||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||
if (!provider) {
|
||||
setError('Unknown SSO provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderName(provider.name);
|
||||
if (!provider.configured) {
|
||||
setError(`${provider.name} is not enabled in this deployment.`);
|
||||
return;
|
||||
}
|
||||
if (provider.loginMode !== 'oidc') {
|
||||
setError(`${provider.name} is not available for OIDC sign in.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signIn.oauth2({
|
||||
providerId: provider.id,
|
||||
callbackURL,
|
||||
});
|
||||
|
||||
if (!cancelled && result?.error) {
|
||||
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
||||
}
|
||||
if (!cancelled && result?.error) {
|
||||
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,22 +46,19 @@ export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [providerId, requestedCallbackURL]);
|
||||
}, [callbackURL, provider]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{providerName
|
||||
? `Redirecting you to ${providerName}...`
|
||||
{provider
|
||||
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
|
||||
: 'Preparing your sign-in request...'}
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
|
||||
<p>{error}</p>
|
||||
<Link
|
||||
href="/login"
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { api } from './api';
|
||||
|
||||
describe('api', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('fetches the supplied relative path with credentials and a JSON body', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
api<{ ok: boolean }>('/api/projects', {
|
||||
method: 'POST',
|
||||
body: { name: 'Mosaic' },
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/projects',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name: 'Mosaic' }),
|
||||
headers: expect.objectContaining({
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws the gateway JSON error with its statusCode', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ statusCode: 403, message: 'Forbidden' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(api('/api/admin/users')).rejects.toMatchObject({
|
||||
name: 'Error',
|
||||
message: 'Forbidden',
|
||||
statusCode: 403,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
||||
|
||||
export interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
}
|
||||
@@ -23,7 +25,7 @@ export async function api<T>(path: string, init?: ApiRequestInit): Promise<T> {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(path, {
|
||||
const res = await fetch(`${GATEWAY_URL}${path}`, {
|
||||
credentials: 'include',
|
||||
...rest,
|
||||
headers,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('auth client origin contract', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('uses the same-origin BetterAuth mount at /api/auth', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ session: null, user: null }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { authClient } = await import('./auth-client');
|
||||
await authClient.getSession();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const firstCall = fetchMock.mock.calls.at(0);
|
||||
expect(firstCall).toBeDefined();
|
||||
const requestURL = new URL(String(firstCall?.[0]), window.location.origin);
|
||||
expect(requestURL.origin).toBe(window.location.origin);
|
||||
expect(requestURL.pathname).toBe('/api/auth/get-session');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createAuthClient } from 'better-auth/react';
|
||||
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
||||
|
||||
// The gateway and BetterAuth client both use /api/auth. Omitting baseURL keeps
|
||||
// every browser request on the current origin in development and production.
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242',
|
||||
plugins: [adminClient(), genericOAuthClient()],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveAuthCallbackURL } from './auth-redirect';
|
||||
|
||||
const CURRENT_ORIGIN = 'https://mosaic.example';
|
||||
|
||||
describe('resolveAuthCallbackURL', () => {
|
||||
it('preserves a canonical same-origin path with search and hash', () => {
|
||||
expect(resolveAuthCallbackURL('/projects?view=active#current', CURRENT_ORIGIN)).toBe(
|
||||
'/projects?view=active#current',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
'chat',
|
||||
'//evil.example',
|
||||
'/..//evil.com',
|
||||
'/..//evil.com/x',
|
||||
'/./..//evil.com',
|
||||
'/../..//evil.com',
|
||||
'/foo/..//evil.com',
|
||||
'/\\evil.example',
|
||||
'/\n//evil.example',
|
||||
'/\r//evil.example',
|
||||
'/\t//evil.example',
|
||||
'https://evil.example/phish',
|
||||
])('falls back to chat for an unsafe callback target %#', (candidate) => {
|
||||
expect(resolveAuthCallbackURL(candidate, CURRENT_ORIGIN)).toBe('/chat');
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
const DEFAULT_AUTH_CALLBACK_URL = '/chat';
|
||||
|
||||
/**
|
||||
* Return a canonical same-origin path for post-auth navigation.
|
||||
*
|
||||
* Parsing before comparing origins rejects protocol-relative URLs, backslash
|
||||
* variants, and control characters that the WHATWG parser normalizes away.
|
||||
*/
|
||||
export function resolveAuthCallbackURL(candidate: string | null, currentOrigin: string): string {
|
||||
if (!candidate?.startsWith('/')) return DEFAULT_AUTH_CALLBACK_URL;
|
||||
|
||||
try {
|
||||
const expectedOrigin = new URL(currentOrigin).origin;
|
||||
const resolved = new URL(candidate, expectedOrigin);
|
||||
if (resolved.origin !== expectedOrigin || resolved.pathname.startsWith('//')) {
|
||||
return DEFAULT_AUTH_CALLBACK_URL;
|
||||
}
|
||||
|
||||
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
||||
} catch {
|
||||
return DEFAULT_AUTH_CALLBACK_URL;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { ioMock } = vi.hoisted(() => ({
|
||||
ioMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('socket.io-client', () => ({
|
||||
io: ioMock,
|
||||
}));
|
||||
|
||||
import { destroySocket, getSocket } from './socket';
|
||||
|
||||
describe('chat socket', () => {
|
||||
let disconnectHandler: (() => void) | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
disconnectHandler = undefined;
|
||||
ioMock.mockReset();
|
||||
|
||||
const mockSocket = {
|
||||
on: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === 'disconnect') disconnectHandler = handler;
|
||||
return mockSocket;
|
||||
}),
|
||||
offAny: vi.fn(() => mockSocket),
|
||||
disconnect: vi.fn(() => mockSocket),
|
||||
};
|
||||
|
||||
ioMock.mockReturnValue(mockSocket);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroySocket();
|
||||
});
|
||||
|
||||
it('creates one same-origin /chat namespace socket until it disconnects', () => {
|
||||
const first = getSocket();
|
||||
const second = getSocket();
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(ioMock).toHaveBeenCalledOnce();
|
||||
expect(ioMock).toHaveBeenCalledWith('/chat', {
|
||||
withCredentials: true,
|
||||
autoConnect: false,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
disconnectHandler?.();
|
||||
getSocket();
|
||||
expect(ioMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
|
||||
const GATEWAY_URL = process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:14242';
|
||||
|
||||
let socket: Socket | null = null;
|
||||
|
||||
export function getSocket(): Socket {
|
||||
if (!socket) {
|
||||
socket = io('/chat', {
|
||||
socket = io(`${GATEWAY_URL}/chat`, {
|
||||
withCredentials: true,
|
||||
autoConnect: false,
|
||||
transports: ['websocket', 'polling'],
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
|
||||
|
||||
describe('sso-providers', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('returns the enabled providers in login button order', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
|
||||
|
||||
expect(getEnabledSsoProviders()).toEqual([
|
||||
{
|
||||
id: 'workos',
|
||||
buttonLabel: 'Continue with WorkOS',
|
||||
description: 'Enterprise SSO via WorkOS',
|
||||
enabled: true,
|
||||
href: '/auth/provider/workos',
|
||||
},
|
||||
{
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
enabled: true,
|
||||
href: '/auth/provider/keycloak',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks disabled providers without exposing them in the enabled list', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
|
||||
|
||||
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
|
||||
expect(getSsoProvider('keycloak')).toEqual({
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
enabled: false,
|
||||
href: '/auth/provider/keycloak',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unknown providers', () => {
|
||||
expect(getSsoProvider('authentik')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
export type SsoProviderId = 'workos' | 'keycloak';
|
||||
|
||||
export interface SsoProvider {
|
||||
id: SsoProviderId;
|
||||
buttonLabel: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
|
||||
workos: {
|
||||
id: 'workos',
|
||||
buttonLabel: 'Continue with WorkOS',
|
||||
description: 'Enterprise SSO via WorkOS',
|
||||
},
|
||||
keycloak: {
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
},
|
||||
};
|
||||
|
||||
export function getEnabledSsoProviders(): SsoProvider[] {
|
||||
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
|
||||
.map((providerId) => getSsoProvider(providerId))
|
||||
.filter((provider): provider is SsoProvider => provider?.enabled === true);
|
||||
}
|
||||
|
||||
export function getSsoProvider(providerId: string): SsoProvider | null {
|
||||
if (!isSsoProviderId(providerId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...PROVIDER_METADATA[providerId],
|
||||
enabled: isSsoProviderEnabled(providerId),
|
||||
href: `/auth/provider/${providerId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function isSsoProviderId(value: string): value is SsoProviderId {
|
||||
return value === 'workos' || value === 'keycloak';
|
||||
}
|
||||
|
||||
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
|
||||
switch (providerId) {
|
||||
case 'workos':
|
||||
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
|
||||
case 'keycloak':
|
||||
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { ThemeProvider } from '@/providers/theme-provider';
|
||||
import { createAppRouter } from '@/routes';
|
||||
import '@/app/globals.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
throw new Error('missing #root element');
|
||||
}
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={createAppRouter()} />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { createBrowserRouter, Navigate, Outlet, type RouteObject } from 'react-router-dom';
|
||||
import { LoginPage } from '@/spa/pages/login';
|
||||
import { RegisterPage } from '@/spa/pages/register';
|
||||
import { SsoCallbackPage } from '@/spa/pages/sso-callback';
|
||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||
import { Placeholder } from '@/spa/placeholder';
|
||||
|
||||
function GuestLayout(): ReactElement {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-surface-bg px-4 py-8">
|
||||
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
element: <GuestGuard />,
|
||||
children: [
|
||||
{
|
||||
element: <GuestLayout />,
|
||||
children: [
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/register', element: <RegisterPage /> },
|
||||
{ path: '/auth/provider/:provider', element: <SsoCallbackPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/chat" replace /> },
|
||||
{ path: '/chat', element: <Placeholder title="Chat" /> },
|
||||
{ path: '/projects', element: <Placeholder title="Projects" /> },
|
||||
{ path: '/projects/:id', element: <Placeholder title="Project" /> },
|
||||
{ path: '/tasks', element: <Placeholder title="Tasks" /> },
|
||||
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function createAppRouter(): ReturnType<typeof createBrowserRouter> {
|
||||
return createBrowserRouter(routes);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { useSessionMock } = vi.hoisted(() => ({
|
||||
useSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useSession: useSessionMock,
|
||||
}));
|
||||
|
||||
import { AuthGuard, GuestGuard } from './guards';
|
||||
|
||||
interface RenderedRouter {
|
||||
container: HTMLDivElement;
|
||||
router: ReturnType<typeof createMemoryRouter>;
|
||||
}
|
||||
|
||||
const mountedRoots: Root[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
async function renderRouter(
|
||||
routeObjects: RouteObject[],
|
||||
initialEntry: string,
|
||||
): Promise<RenderedRouter> {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const router = createMemoryRouter(routeObjects, { initialEntries: [initialEntry] });
|
||||
const root = createRoot(container);
|
||||
mountedRoots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RouterProvider router={router} />);
|
||||
});
|
||||
|
||||
return { container, router };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
document.body.replaceChildren();
|
||||
useSessionMock.mockReset();
|
||||
});
|
||||
|
||||
const guestRoutes: RouteObject[] = [
|
||||
{
|
||||
path: '/login',
|
||||
element: <GuestGuard />,
|
||||
children: [{ index: true, element: <p>Guest page</p> }],
|
||||
},
|
||||
{ path: '/chat', element: <p>Chat page</p> },
|
||||
];
|
||||
|
||||
const authenticatedRoutes: RouteObject[] = [
|
||||
{
|
||||
path: '/chat',
|
||||
element: <AuthGuard />,
|
||||
children: [{ index: true, element: <p>Private page</p> }],
|
||||
},
|
||||
{ path: '/login', element: <p>Login page</p> },
|
||||
];
|
||||
|
||||
describe('GuestGuard', () => {
|
||||
it('renders the guest outlet while session lookup is pending', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||
|
||||
const view = await renderRouter(guestRoutes, '/login');
|
||||
|
||||
expect(view.container.textContent).toContain('Guest page');
|
||||
expect(view.router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('renders the guest outlet when no session exists', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||
|
||||
const view = await renderRouter(guestRoutes, '/login');
|
||||
|
||||
expect(view.container.textContent).toContain('Guest page');
|
||||
expect(view.router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('redirects an authenticated session to chat', async () => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const view = await renderRouter(guestRoutes, '/login');
|
||||
|
||||
expect(view.container.textContent).toContain('Chat page');
|
||||
expect(view.router.state.location.pathname).toBe('/chat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
it('renders the existing loading treatment while session lookup is pending', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: true });
|
||||
|
||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||
|
||||
expect(view.container.textContent).toContain('Loading...');
|
||||
expect(view.router.state.location.pathname).toBe('/chat');
|
||||
});
|
||||
|
||||
it('redirects an unauthenticated visitor to login', async () => {
|
||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
||||
|
||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||
|
||||
expect(view.container.textContent).toContain('Login page');
|
||||
expect(view.router.state.location.pathname).toBe('/login');
|
||||
});
|
||||
|
||||
it('renders the authenticated outlet when a session exists', async () => {
|
||||
useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false });
|
||||
|
||||
const view = await renderRouter(authenticatedRoutes, '/chat');
|
||||
|
||||
expect(view.container.textContent).toContain('Private page');
|
||||
expect(view.router.state.location.pathname).toBe('/chat');
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
export function GuestGuard(): ReactElement {
|
||||
const { data: session } = useSession();
|
||||
|
||||
return session ? <Navigate to="/chat" replace /> : <Outlet />;
|
||||
}
|
||||
|
||||
export function AuthGuard(): ReactElement {
|
||||
const { data: session, isPending } = useSession();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { useEffect, useState, type FormEvent, type ReactElement } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
|
||||
import { api } from '@/lib/api';
|
||||
import { authClient, signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
|
||||
export function LoginPage(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
||||
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
|
||||
SsoProviderDiscovery['id'] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
void api<SsoProviderDiscovery[]>('/api/sso/providers').then(
|
||||
(providers) => {
|
||||
if (active) setSsoProviders(providers.filter((provider) => provider.configured));
|
||||
},
|
||||
() => {
|
||||
if (active) setSsoProviders([]);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
const email = String(form.get('email') ?? '');
|
||||
const password = String(form.get('password') ?? '');
|
||||
|
||||
try {
|
||||
const result = await signIn.email({ email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Sign in failed');
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/chat', { replace: true });
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : 'Sign in failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
|
||||
setError(null);
|
||||
setSsoLoadingProviderId(providerId);
|
||||
|
||||
try {
|
||||
const result = await authClient.signIn.oauth2({
|
||||
providerId,
|
||||
callbackURL: '/chat',
|
||||
newUserCallbackURL: '/chat',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? `Sign in with ${providerId} failed`);
|
||||
setSsoLoadingProviderId(null);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : `Sign in with ${providerId} failed`);
|
||||
setSsoLoadingProviderId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<SsoProviderButtons
|
||||
providers={ssoProviders}
|
||||
loadingProviderId={ssoLoadingProviderId}
|
||||
onOidcSignIn={(providerId) => {
|
||||
void handleSsoSignIn(providerId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-text-muted">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-blue-400 hover:text-blue-300">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useState, type FormEvent, type ReactElement } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { signUp } from '@/lib/auth-client';
|
||||
|
||||
export function RegisterPage(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
const name = String(form.get('name') ?? '');
|
||||
const email = String(form.get('email') ?? '');
|
||||
const password = String(form.get('password') ?? '');
|
||||
|
||||
try {
|
||||
const result = await signUp.email({ name, email, password });
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Registration failed');
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/chat', { replace: true });
|
||||
} catch (caught: unknown) {
|
||||
setError(caught instanceof Error ? caught.message : 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Create account</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
disabled={loading}
|
||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-text-muted">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-blue-400 hover:text-blue-300">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { apiMock, oauth2Mock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
oauth2Mock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
signIn: { oauth2: oauth2Mock },
|
||||
}));
|
||||
|
||||
import { SsoCallbackPage } from './sso-callback';
|
||||
|
||||
const mountedRoots: Root[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
apiMock.mockResolvedValue([
|
||||
{
|
||||
id: 'authentik',
|
||||
name: 'Authentik',
|
||||
protocols: ['oidc'],
|
||||
configured: true,
|
||||
loginMode: 'oidc',
|
||||
callbackPath: '/api/auth/oauth2/callback/authentik',
|
||||
teamSync: { enabled: false, claim: null },
|
||||
samlFallback: { configured: false, loginUrl: null },
|
||||
warnings: [],
|
||||
},
|
||||
]);
|
||||
oauth2Mock.mockResolvedValue({ data: null, error: null });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of mountedRoots.splice(0)) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
document.body.replaceChildren();
|
||||
apiMock.mockReset();
|
||||
oauth2Mock.mockReset();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
describe('SsoCallbackPage', () => {
|
||||
it('rejects a control-character callback that normalizes to an external origin', async () => {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: '/auth/provider/:provider',
|
||||
element: <SsoCallbackPage />,
|
||||
},
|
||||
],
|
||||
{
|
||||
initialEntries: ['/auth/provider/authentik?callbackURL=%2F%0A%2F%2Fevil.example'],
|
||||
},
|
||||
);
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
mountedRoots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<RouterProvider router={router} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(oauth2Mock).toHaveBeenCalledWith({
|
||||
providerId: 'authentik',
|
||||
callbackURL: '/chat',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
||||
|
||||
export function SsoCallbackPage(): ReactElement {
|
||||
const { provider: providerId = '' } = useParams<'provider'>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestedCallbackURL = searchParams.get('callbackURL');
|
||||
const [providerName, setProviderName] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function redirectToProvider(): Promise<void> {
|
||||
try {
|
||||
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
|
||||
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
|
||||
if (cancelled) return;
|
||||
|
||||
const provider = providers.find((candidate) => candidate.id === providerId);
|
||||
if (!provider) {
|
||||
setError('Unknown SSO provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProviderName(provider.name);
|
||||
if (!provider.configured) {
|
||||
setError(`${provider.name} is not enabled in this deployment.`);
|
||||
return;
|
||||
}
|
||||
if (provider.loginMode !== 'oidc') {
|
||||
setError(`${provider.name} is not available for OIDC sign in.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signIn.oauth2({
|
||||
providerId: provider.id,
|
||||
callbackURL,
|
||||
});
|
||||
|
||||
if (!cancelled && result?.error) {
|
||||
setError(result.error.message ?? `${provider.name} sign in failed.`);
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void redirectToProvider();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [providerId, requestedCallbackURL]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{providerName
|
||||
? `Redirecting you to ${providerName}...`
|
||||
: 'Preparing your sign-in request...'}
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
||||
>
|
||||
<p>{error}</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Return to login
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
||||
If the redirect does not start automatically, return to the login page and try again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export function Placeholder({ title }: { title: string }): ReactElement {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<h1 className="text-xl font-medium">{title}</h1>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { isValidElement } from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RouteObject } from 'react-router-dom';
|
||||
import { routes } from '@/routes';
|
||||
import { Placeholder } from '@/spa/placeholder';
|
||||
|
||||
function collectPaths(routeObjects: RouteObject[]): string[] {
|
||||
return routeObjects.flatMap((route) => [
|
||||
...(route.path ? [route.path] : []),
|
||||
...(route.children ? collectPaths(route.children) : []),
|
||||
]);
|
||||
}
|
||||
|
||||
function findRoute(routeObjects: RouteObject[], path: string): RouteObject | undefined {
|
||||
for (const route of routeObjects) {
|
||||
if (route.path === path) return route;
|
||||
const nested = route.children ? findRoute(route.children, path) : undefined;
|
||||
if (nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('SPA route table', () => {
|
||||
it('covers every v1 parity route from the Phase P RFC', () => {
|
||||
expect(collectPaths(routes).sort()).toEqual(
|
||||
[
|
||||
'/',
|
||||
'/admin',
|
||||
'/auth/provider/:provider',
|
||||
'/chat',
|
||||
'/login',
|
||||
'/projects',
|
||||
'/projects/:id',
|
||||
'/register',
|
||||
'/settings',
|
||||
'/tasks',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('separates guest and authenticated route groups', () => {
|
||||
const guestPaths = collectPaths(routes.at(0)?.children ?? []);
|
||||
const authPaths = collectPaths(routes.at(1)?.children ?? []);
|
||||
expect(guestPaths).toContain('/login');
|
||||
expect(guestPaths).not.toContain('/chat');
|
||||
expect(authPaths).toContain('/chat');
|
||||
});
|
||||
|
||||
it.each(['/login', '/register', '/auth/provider/:provider'])(
|
||||
'renders a real guest page instead of the P1 placeholder at %s',
|
||||
(path) => {
|
||||
const element = findRoute(routes, path)?.element;
|
||||
expect(isValidElement(element)).toBe(true);
|
||||
if (!isValidElement(element)) throw new Error(`Missing route element for ${path}`);
|
||||
expect(element.type).not.toBe(Placeholder);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Vitest abort-controller realm', () => {
|
||||
it('provides a global signal accepted by Node native Request', () => {
|
||||
const controller = new AbortController();
|
||||
const request = new Request('https://mosaic.invalid/navigation', {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(request.signal).toBeInstanceOf(AbortSignal);
|
||||
controller.abort();
|
||||
expect(request.signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { transferableAbortController } from 'node:util';
|
||||
|
||||
// jsdom installs realm-local abort constructors while Node's undici Request
|
||||
// remains native. React Router passes a global AbortSignal to Request, so both
|
||||
// constructors must come from Node's native realm during tests.
|
||||
const nativeController = transferableAbortController();
|
||||
const nativeAbortController = nativeController.constructor;
|
||||
const nativeAbortSignal = nativeController.signal.constructor;
|
||||
|
||||
for (const target of [globalThis, window]) {
|
||||
Object.defineProperties(target, {
|
||||
AbortController: {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: nativeAbortController,
|
||||
},
|
||||
AbortSignal: {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: nativeAbortSignal,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// The proxy exists only in dev; in production the SPA is same-origin with the gateway
|
||||
// (served by it under Candidate A, or behind one FQDN under Candidate B) and every
|
||||
// request uses a relative path, so no origin may ever be configured here or in src/.
|
||||
const gatewayTarget = 'http://localhost:14242';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3100,
|
||||
proxy: {
|
||||
'/api': gatewayTarget,
|
||||
'/socket.io': { target: gatewayTarget, ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,21 +1,9 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
// tsconfig uses "jsx": "preserve" for Next; tests need esbuild to compile it
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
isolate: true,
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
},
|
||||
});
|
||||
|
||||
+4
-23
@@ -16,24 +16,10 @@
|
||||
|
||||
## 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).
|
||||
- [Generated environment boundary](fleet/reference/generated-env-boundary.md) — roster-derived launch projection, strict local data, legacy quarantine, and downstream interface evidence.
|
||||
- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — local-tmux schema v2 parsing and structural validation.
|
||||
- [Role classes and authority](fleet/reference/role-classes.md) — canonical role resolver and protected authority boundaries.
|
||||
- [Executable asset dispositions](fleet/migration/example-profile-disposition.md) — shipped v1 fixture/profile/service validation posture.
|
||||
|
||||
## Official channel plugins
|
||||
|
||||
@@ -96,8 +82,3 @@
|
||||
- [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.
|
||||
|
||||
+14
-14
@@ -52,20 +52,20 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
||||
> 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 |
|
||||
| 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 | not-started | 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 | Preview first; no unreviewed lifecycle inference |
|
||||
| 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 | Never starts a previously stopped agent or kills an unproven unmanaged session |
|
||||
| FCM-M5-001 | not-started | 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 | Must close every checklist item or record an approved deferral |
|
||||
| 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 | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
|
||||
|
||||
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# npm `@next` prerelease lane
|
||||
|
||||
Status: **IMPLEMENTED**
|
||||
|
||||
## Current behavior
|
||||
|
||||
`tools/install.sh --next` provides the prerelease integration lane for the permanent `next` branch.
|
||||
|
||||
The lane is fast-by-default:
|
||||
|
||||
1. Install framework files from the `next` source archive.
|
||||
2. Resolve the Gitea npm registry `next` dist-tag for the globally installed packages:
|
||||
|
||||
```bash
|
||||
npm view @mosaicstack/gateway@next version
|
||||
npm view @mosaicstack/mosaic@next version
|
||||
```
|
||||
|
||||
3. Require both resolved versions to share the same `next.<pipeline>` suffix, then install the exact resolved versions.
|
||||
4. If either `@next` package is missing, unreachable, mismatched, or fails to install, fall back to the source-build path at `next`.
|
||||
|
||||
`--next` never hard-fails solely because the prerelease npm dist-tag is unavailable.
|
||||
|
||||
## Published packages
|
||||
|
||||
The `next` publish pipeline publishes non-private `@mosaicstack/*` packages to the Mosaic Gitea npm registry:
|
||||
|
||||
```text
|
||||
https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||
```
|
||||
|
||||
Observed `next` dist-tags after enabling the pipeline:
|
||||
|
||||
```text
|
||||
@mosaicstack/mosaic@next -> 0.0.49-next.1633
|
||||
@mosaicstack/gateway@next -> 0.0.7-next.1633
|
||||
```
|
||||
|
||||
The gateway also publishes a Docker image as `gateway:sha-<short>` on `next` merges. The installer fast path uses the npm gateway package when available; the Docker image is for deployed gateway/runtime harness flows.
|
||||
|
||||
## Explicit source lanes
|
||||
|
||||
Source builds remain available and are still the authority for explicit ref validation:
|
||||
|
||||
- `--dev` always builds from source.
|
||||
- `--ref <ref>` / `MOSAIC_REF=<ref>` wins over `--next` and uses the source path for that exact ref.
|
||||
|
||||
## Pipeline shape
|
||||
|
||||
1. Trigger on `next` merges.
|
||||
2. Compute the next prerelease version from the upcoming stable version plus the Woodpecker pipeline number (`<target-stable>-next.<CI_PIPELINE_NUMBER>`).
|
||||
3. Build and publish non-private packages in CI.
|
||||
4. Publish to the Mosaic Gitea npm registry with dist-tag `next`.
|
||||
5. Keep `latest` untouched; only main/release promotion can update `latest`.
|
||||
6. Publish gateway Docker images from `next` as `gateway:sha-<short>` only.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- `@next` is mutable prerelease convenience, not a deployment pin.
|
||||
- Stable installs continue to use `@latest`.
|
||||
- Contributor validation remains available through `--dev --ref <branch>`.
|
||||
- Pipeline output traces every prerelease package back to the source commit on `next`.
|
||||
- The installer falls back to source rather than hard-failing on prerelease registry issues.
|
||||
@@ -5,17 +5,28 @@
|
||||
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.
|
||||
recorded in the M0 task/PR.
|
||||
|
||||
## 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.
|
||||
- [ ] `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.
|
||||
- [ ] `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.
|
||||
- [ ] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and
|
||||
observed state, including stopped-state preservation through migration, apply, and reboot.
|
||||
- [ ] `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.
|
||||
- [ ] `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.
|
||||
- [ ] `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.
|
||||
- [ ] `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
|
||||
|
||||
@@ -61,18 +72,15 @@ recorded in the M5 closure report and linked deferral evidence.
|
||||
|
||||
## 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.
|
||||
- [ ] Every retained or migrated YAML/JSON example, profile, and service preset validates through the
|
||||
same executable schema and shared baseline-plus-`roles.local` resolver used by the CLI.
|
||||
- [ ] Every retired example/profile/service preset has a replacement link and deprecation note; no
|
||||
unresolved legacy class or tool-policy alias remains silently shipped.
|
||||
- [ ] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded
|
||||
Tess/Ultron identity.
|
||||
- [ ] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed
|
||||
`mosaic agent` catalog.
|
||||
- [ ] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of
|
||||
legacy sensitive keys are never printed.
|
||||
- [ ] M5 release review verifies links, schema/example validation, and that all checklist rows have
|
||||
owner/evidence or an explicit approved deferral.
|
||||
|
||||
+11
-11
@@ -8,11 +8,11 @@ Generated environment files are rebuildable projections, not an operator-editabl
|
||||
| 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/<name>.env.generated from the roster. |
|
||||
| Optional local data | Reads a strict, data-only fleet/agents/<name>.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. |
|
||||
| Projection writer | Renders deterministic `fleet/agents/<name>.env.generated` from the roster. |
|
||||
| Optional local data | Reads a strict, data-only `fleet/agents/<name>.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 <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
||||
| runtime launch | Derives the fixed `mosaic yolo <runtime>` argument array from validated roster data, then seeds the runtime contract. |
|
||||
|
||||
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,
|
||||
@@ -20,7 +20,7 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
|
||||
|
||||
## Generated and local files
|
||||
|
||||
<name>.env.generated is complete, deterministic, and written only by Mosaic. Its ordered keys are:
|
||||
`<name>.env.generated` is complete, deterministic, and written only by Mosaic. Its ordered keys are:
|
||||
|
||||
```dotenv
|
||||
MOSAIC_AGENT_NAME=<roster name>
|
||||
@@ -33,12 +33,12 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||
```
|
||||
|
||||
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
|
||||
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.
|
||||
|
||||
<name>.env.local is optional and may contain only non-secret machine data:
|
||||
`<name>.env.local` is optional and may contain only non-secret machine data:
|
||||
|
||||
- `MOSAIC_RUNTIME_BIN`
|
||||
- `MOSAIC_HEARTBEAT_RUN_DIR`
|
||||
@@ -52,9 +52,9 @@ private, non-symlink paths. Violations fail closed before tmux interaction.
|
||||
|
||||
## Legacy input and diagnostics
|
||||
|
||||
A legacy <name>.env is input only during projection generation. Roster-owned keys are regenerated;
|
||||
A legacy `<name>.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
|
||||
<name>.env.quarantine. Neither legacy nor quarantine files are launch authority.
|
||||
`<name>.env.quarantine`. Neither legacy nor quarantine files are launch authority.
|
||||
|
||||
Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
|
||||
text, credentials, or other values.
|
||||
@@ -62,11 +62,11 @@ text, credentials, or other values.
|
||||
## Launch and stop behavior
|
||||
|
||||
The launcher obtains the agent's socket only from the validated generated projection. It creates or
|
||||
checks the exact =<agent-name> tmux target; it never uses an ambient socket or fuzzy session match.
|
||||
checks the exact `=<agent-name>` 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.
|
||||
|
||||
mosaic agent comms-block <exact-member> can inspect that exact roster member's resolved Fleet-Comms
|
||||
`mosaic agent comms-block <exact-member>` 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
|
||||
|
||||
@@ -14,7 +14,7 @@ parallel resolver. The current executable implementation and per-artifact outcom
|
||||
|
||||
| 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/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 |
|
||||
@@ -34,14 +34,14 @@ parallel resolver. The current executable implementation and per-artifact outcom
|
||||
|
||||
## 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. |
|
||||
| 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.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
+30
-30
@@ -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 <name> 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 <name> remains the **explicit** interactive-takeover path (separate verb, documented as the only one that can type). |
|
||||
| FR-5 | mosaic agent send <name> --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 <name>` 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 <name>` remains the **explicit** interactive-takeover path (separate verb, documented as the only one that can type). |
|
||||
| FR-5 | `mosaic agent send <name> --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/<agent>.hb.
|
||||
- **Response:** the runtime updates <agent>.hb with ts=<iso8601> pid=<pid> status=<ok|busy>
|
||||
- **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/<agent>.hb`.
|
||||
- **Response:** the runtime updates `<agent>.hb` with `ts=<iso8601> pid=<pid> status=<ok|busy>`
|
||||
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 <ms>). On each poll it runs classifySendResult: if
|
||||
controlled by `--verify-timeout <ms>`). 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 '=<agent>' -s
|
||||
'<agent>-watch-<pid>'), 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 '=<agent>' -s
|
||||
'<agent>-watch-<pid>'`), 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)
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# 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
|
||||
|
||||
<MOSAIC_HOME>/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.
|
||||
+13
-13
@@ -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/<agent>.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 <name> — 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/<agent>.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 <name>` — 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) |
|
||||
```
|
||||
|
||||
@@ -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 <sub> --json.
|
||||
and surfaced as `mosaic fleet backlog <sub> --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.
|
||||
|
||||
@@ -18,7 +18,7 @@ engine (no sqlite, no raw client).
|
||||
| ---------------------------------- | -------------------- | ---------------------------------------------------------------- |
|
||||
| `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 |
|
||||
| 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
|
||||
@@ -28,9 +28,9 @@ For embedded PGlite only, the local backlog routine may prepare its local schema
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -46,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). |
|
||||
@@ -65,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 <phase> filters to one board lane.
|
||||
(e.g. `M1`, `fleet`, `infra`). `list --phase <phase>` filters to one board lane.
|
||||
`priority` orders cards **within** the ready pool regardless of phase.
|
||||
|
||||
## Status lifecycle
|
||||
|
||||
```text-diagram
|
||||
```
|
||||
create
|
||||
│
|
||||
▼
|
||||
@@ -87,49 +87,51 @@ 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 <sec> (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 <id> force-releases a
|
||||
- **TTL:** `claim --ttl <sec>` (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 <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 <sub> --json
|
||||
## CLI — `mosaic fleet backlog <sub> --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 <sec> --id <id>] | Atomically claim the highest-priority ready card (or `--id`). Returns the card or `null`. |
|
||||
| reclaim [--id <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 <sec> --id <id>]` | Atomically claim the highest-priority ready card (or `--id`). Returns the card or `null`. |
|
||||
| `reclaim [--id <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
|
||||
|
||||
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
|
||||
```sh
|
||||
# Seed two cards, the second depends on the first.
|
||||
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
|
||||
```
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# Desired, Derived, and Observed Fleet State
|
||||
|
||||
## One writable authority
|
||||
|
||||
The canonical local v2 roster at <MOSAIC_HOME>/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:
|
||||
|
||||
- <name>.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.
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated Environment Launch Chain
|
||||
|
||||
The launcher consumes validated data, not shell configuration.
|
||||
|
||||
1. Read and validate the canonical roster.
|
||||
2. Render deterministic <name>.env.generated data from that roster.
|
||||
3. Parse optional <name>.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).
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,61 +0,0 @@
|
||||
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
|
||||
@@ -15,7 +15,7 @@ core.
|
||||
|
||||
Connectors implement one small, uniform interface (`src/fleet/connectors/types.ts`):
|
||||
|
||||
```typescript
|
||||
```ts
|
||||
interface OrchestratorConnector {
|
||||
readonly kind: 'tmux' | 'discord' | 'matrix';
|
||||
send(message: OutboundMessage): Promise<SendResult>; // 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
|
||||
kind: matrix # tmux | discord | 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 |
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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).
|
||||
@@ -4,20 +4,20 @@ Use the local roster-v2 control plane only. These commands change desired state
|
||||
|
||||
## Read and plan first
|
||||
|
||||
```fleet-synopsis
|
||||
```sh
|
||||
mosaic fleet get <name>
|
||||
mosaic fleet plan create --expected-generation <n> --agent '<json>'
|
||||
mosaic fleet plan update <name> --expected-generation <n> --agent '<json>'
|
||||
mosaic fleet plan delete <name> --expected-generation <n>
|
||||
```
|
||||
|
||||
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.
|
||||
`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`.
|
||||
Every successful command prints JSON. `get` returns `{ "generation", "agent" }`; mutation results contain `plan`, `applied`, `authoritativeRoster`, and `projections`.
|
||||
|
||||
## Create safely
|
||||
|
||||
```fleet-command
|
||||
```sh
|
||||
mosaic fleet create --expected-generation 7 --agent '{
|
||||
"name":"coder0",
|
||||
"alias":"Coder 0",
|
||||
@@ -34,20 +34,20 @@ mosaic fleet create --expected-generation 7 --agent '{
|
||||
}'
|
||||
```
|
||||
|
||||
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.
|
||||
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 <name> --expected-generation <n> --agent '<complete JSON agent payload>'
|
||||
mosaic fleet delete <name> --expected-generation <n>
|
||||
```sh
|
||||
mosaic fleet update coder0 --expected-generation 8 --agent '<complete JSON agent payload>'
|
||||
mosaic fleet delete coder0 --expected-generation 9
|
||||
```
|
||||
|
||||
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 <name> 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.
|
||||
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 <name>` 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
|
||||
|
||||
@@ -71,4 +71,4 @@ This is not a rollback and not a no-op: reload the roster because its generation
|
||||
|
||||
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 <mosaic-home>/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.
|
||||
The commands operate only on `<mosaic-home>/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.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Mosaic resolves persona contracts through two layers:
|
||||
|
||||
1. fleet/roles/<canonical-class>.md — seeded baseline contract.
|
||||
2. fleet/roles.local/<canonical-class>.md — operator override or custom role; this layer wins.
|
||||
1. `fleet/roles/<canonical-class>.md` — seeded baseline contract.
|
||||
2. `fleet/roles.local/<canonical-class>.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.
|
||||
@@ -34,11 +34,11 @@ A custom class remains supported when a readable contract exists for the exact i
|
||||
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.
|
||||
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.
|
||||
contracts. `agents[].alias`, Tess, and Ultron are display names and cannot select a class.
|
||||
|
||||
## Validation and authority boundaries
|
||||
|
||||
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
Use the canonical local roster-v2 command surface:
|
||||
|
||||
```fleet-synopsis
|
||||
```sh
|
||||
mosaic fleet apply --expected-generation <n> --dry-run
|
||||
mosaic fleet apply --expected-generation <n>
|
||||
mosaic fleet reconcile --expected-generation <n>
|
||||
mosaic fleet start <name> --expected-generation <n>
|
||||
mosaic fleet stop <name> --expected-generation <n>
|
||||
mosaic fleet restart <name> --expected-generation <n>
|
||||
mosaic fleet status [<name>]
|
||||
mosaic fleet status [name]
|
||||
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 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. `apply` and `reconcile` rebuild derived projections and enforce only persisted roster state: enabled `running` agents may start, while stopped or disabled agents are not started.
|
||||
|
||||
`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.
|
||||
`start`, `stop`, and `restart` are explicit one-shot exact-service actions. They do not persist a lifecycle change. Roster CRUD is the only way to change persisted desired state.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -11,7 +11,7 @@ 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
|
||||
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.
|
||||
@@ -59,7 +59,7 @@ rollback; those gates belong to FCM-M4-002. See [v1-to-v2 preview](./v1-to-v2.md
|
||||
|
||||
## Running the guard
|
||||
|
||||
```fleet-command
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/mosaic test -- v1-v2-migration.spec.ts \
|
||||
-t "validates all 13 shipped artifacts and executes ready previews for every v1 fixture"
|
||||
```
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
**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
|
||||
`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
|
||||
```bash
|
||||
mosaic fleet migrate-v1 preview \
|
||||
--source roster-v1.yaml \
|
||||
--decisions migration-decisions.json \
|
||||
@@ -44,25 +44,25 @@ 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 |
|
||||
| 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
|
||||
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.
|
||||
|
||||
|
||||
+46
-45
@@ -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 <name> | **read-only** join (grouped session / `-r`), no resize tyranny, no keystrokes |
|
||||
| mosaic agent attach <name> | explicit interactive takeover (the only path that can type) |
|
||||
| mosaic agent send <name> --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 <name>` | **read-only** join (grouped session / `-r`), no resize tyranny, no keystrokes |
|
||||
| `mosaic agent attach <name>` | explicit interactive takeover (the only path that can type) |
|
||||
| `mosaic agent send <name> --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,18 +304,19 @@ 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 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 \*\*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.
|
||||
- **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),
|
||||
@@ -343,7 +344,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.
|
||||
@@ -356,9 +357,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 <hint> to that
|
||||
agent's harness (claude or pi); settable today via mosaic fleet add|edit <agent> --model <hint>.
|
||||
`~/.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 <hint>` to that
|
||||
agent's harness (claude or pi); settable today via `mosaic fleet add|edit <agent> --model <hint>`.
|
||||
**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
|
||||
@@ -384,7 +385,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
|
||||
@@ -417,9 +418,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.)
|
||||
>
|
||||
|
||||
@@ -30,7 +30,7 @@ connector entry.
|
||||
|
||||
The preview evidence deliberately records:
|
||||
|
||||
- executable: false;
|
||||
- `executable: false`;
|
||||
- required backup artifacts;
|
||||
- source and candidate identities;
|
||||
- lifecycle observations and resulting desired states;
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Environment Quarantine Operations
|
||||
|
||||
Legacy <name>.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).
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
## Safe sequence
|
||||
|
||||
1. Read mosaic fleet doctor and mosaic fleet status.
|
||||
2. Run mosaic fleet apply --expected-generation <n> --dry-run.
|
||||
1. Read `mosaic fleet doctor` and `mosaic fleet status`.
|
||||
2. Run `mosaic fleet apply --expected-generation <n> --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 <n> 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.
|
||||
4. Run `mosaic fleet apply --expected-generation <n>` only after the plan is understood.
|
||||
|
||||
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.
|
||||
|
||||
@@ -25,4 +23,4 @@ The roster is never changed by reconciliation. If derived projection application
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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).
|
||||
@@ -4,17 +4,17 @@ FCM-M2-002 provides local roster-v2 create, get, update, delete, and plan operat
|
||||
|
||||
## CLI contract
|
||||
|
||||
The commands operate only on the canonical <mosaic-home>/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 }.
|
||||
The commands operate only on the canonical `<mosaic-home>/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
|
||||
```sh
|
||||
mosaic fleet get <name>
|
||||
mosaic fleet plan <create|update|delete> [<name>] --expected-generation <n> [--agent '<json>'] [--persisted-start]
|
||||
mosaic fleet plan <create|update|delete> [name] --expected-generation <n> [--agent '<json>'] [--persisted-start]
|
||||
mosaic fleet create --expected-generation <n> --agent '<json>' [--dry-run] [--persisted-start]
|
||||
mosaic fleet update <name> --expected-generation <n> --agent '<json>' [--dry-run]
|
||||
mosaic fleet delete <name> --expected-generation <n> [--dry-run]
|
||||
```
|
||||
|
||||
`get` returns the authoritative generation and the selected agent. plan create derives its name from `--agent`; plan update <name> and plan delete <name> 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.
|
||||
`get` returns the authoritative generation and the selected agent. `plan create` derives its name from `--agent`; `plan update <name>` and `plan delete <name>` 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
|
||||
|
||||
@@ -22,7 +22,7 @@ Each create, update, or delete request includes `expectedGeneration`. A request
|
||||
|
||||
`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 <name>.env.generated projection for the removed roster entry. Operator-owned <name>.env.local, legacy <name>.env, quarantine records, and unrelated projections remain untouched. An already-absent generated projection is treated as stale derived state, not as a failed mutation.
|
||||
Delete removes only the exact `<name>.env.generated` projection for the removed roster entry. Operator-owned `<name>.env.local`, legacy `<name>.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
|
||||
|
||||
@@ -40,4 +40,4 @@ Mutation results are JSON-safe objects with `applied`, `authoritativeRoster`, `p
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
+16
-35
@@ -1,46 +1,27 @@
|
||||
# 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.
|
||||
The local roster-v2 control plane is `mosaic fleet`.
|
||||
|
||||
## Roster-v2 desired-state commands
|
||||
|
||||
| Command | Effect | Generation | Output |
|
||||
| ---------------------------------------------------------- | ---------------------------------------------- | ---------- | -------------------------- |
|
||||
| mosaic fleet get <name> | Read one authoritative agent | no | One JSON object |
|
||||
| mosaic fleet plan <create\|update\|delete> ... | 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 <name> ... [--dry-run] | Replace mutable agent fields | required | One JSON object |
|
||||
| mosaic fleet delete <name> ... [--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 [<name>] ... [--dry-run] | Exact one-shot lifecycle action | required | One JSON object |
|
||||
| mosaic fleet status [<name>] | 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 <n>
|
||||
mosaic fleet reconcile --expected-generation <n>
|
||||
mosaic fleet start [<name>] --expected-generation <n> [--dry-run]
|
||||
mosaic fleet stop [<name>] --expected-generation <n> [--dry-run]
|
||||
mosaic fleet restart [<name>] --expected-generation <n> [--dry-run]
|
||||
mosaic fleet status [<name>]
|
||||
```text
|
||||
mosaic fleet apply --expected-generation <n> [--dry-run]
|
||||
mosaic fleet reconcile --expected-generation <n> [--dry-run]
|
||||
mosaic fleet start [name] --expected-generation <n> [--dry-run]
|
||||
mosaic fleet stop [name] --expected-generation <n> [--dry-run]
|
||||
mosaic fleet restart [name] --expected-generation <n> [--dry-run]
|
||||
mosaic fleet status [name]
|
||||
mosaic fleet verify
|
||||
mosaic fleet doctor
|
||||
mosaic fleet migrate-v1 preview --source <path> --decisions <path> --observations <path>
|
||||
```
|
||||
|
||||
`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.
|
||||
`migrate-v1 preview` is non-mutating: it emits value-free v1 inventory, a canonical semantically
|
||||
validated v2 candidate when ready, sanitized environment dispositions, and non-executable recovery
|
||||
evidence. It has no write, apply, canary, or rollback option. Missing preview inputs also return one stable
|
||||
blocked JSON object and a non-zero exit, rather than Commander text. See
|
||||
[the migration preview contract](../migration/v1-to-v2.md).
|
||||
|
||||
## JSON and exit behavior
|
||||
`apply` and `reconcile` use roster desired state. `start`, `stop`, and `restart` are exact local one-shot lifecycle effects and never persist a desired-state edit. `status`, `verify`, and `doctor` are observational.
|
||||
|
||||
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.
|
||||
Commands emit one JSON object. Handled precondition errors emit `{ "error": { "code": "..." } }` and exit non-zero. Partial derived/lifecycle effects use explicit `authoritativeRoster`, `projections`, `lifecycle`, and bounded `recovery` fields; they never claim rollback. Any additive `cleanup` diagnostic also exits non-zero, even where known effects are complete: it is not a clean completion and the lock requires inspection before retry.
|
||||
|
||||
## 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 <MOSAIC_HOME>/fleet/roster.yaml desired state. Remote/SSH reconciliation, connector mutation, arbitrary commands/channels, secret references, and gateway convergence are rejected or outside scope.
|
||||
This control plane is separate from the gateway-backed `mosaic agent` catalog. It is local-only and rejects remote/connector lifecycle mutation, arbitrary command/channel/secret input, and unproven tmux ownership.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Fleet Generated Environment Boundary
|
||||
|
||||
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** merged contract
|
||||
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** unreleased/card-local
|
||||
|
||||
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
|
||||
@@ -8,14 +8,14 @@ a second roster or a command configuration surface.
|
||||
|
||||
## Paths and ownership
|
||||
|
||||
For agent <name> under <MOSAIC_HOME>/fleet/agents/:
|
||||
For agent `<name>` under `<MOSAIC_HOME>/fleet/agents/`:
|
||||
|
||||
| Path | Owner | Purpose |
|
||||
| --------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| <name>.env.generated | Mosaic projection writer | Complete deterministic launch data rendered from the authoritative roster. |
|
||||
| <name>.env.local | Operator | Optional, constrained local machine data. It cannot shadow generated keys. |
|
||||
| <name>.env | Legacy input only | Read once during projection generation, then regenerated/relocated or privately quarantined. It is never a launch authority. |
|
||||
| <name>.env.quarantine | Mosaic quarantine | Mode-`0600` private record of forbidden legacy input; it is never read by the launcher. |
|
||||
| Path | Owner | Purpose |
|
||||
| ----------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `<name>.env.generated` | Mosaic projection writer | Complete deterministic launch data rendered from the authoritative roster. |
|
||||
| `<name>.env.local` | Operator | Optional, constrained local machine data. It cannot shadow generated keys. |
|
||||
| `<name>.env` | Legacy input only | Read once during projection generation, then regenerated/relocated or privately quarantined. It is never a launch authority. |
|
||||
| `<name>.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
|
||||
@@ -44,7 +44,7 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||
```
|
||||
|
||||
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add
|
||||
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
|
||||
@@ -62,7 +62,7 @@ Local paths must be safe absolute paths and the interval must be a positive inte
|
||||
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 <name>.hb.native marker, its native heartbeat remains authoritative; the
|
||||
Pi runtime writes a fresh `<name>.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
|
||||
@@ -78,14 +78,14 @@ This card does not add a USC site file, write a USC roster, or run a site canary
|
||||
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. |
|
||||
| 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 remains `in-progress` in `docs/TASKS.md`; unreleased. | 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 remains `not-started` in `docs/TASKS.md`; unreleased even though these checkout artifacts are inspectable. | 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 card-local and uncommitted; unreleased. | Render/write a roster-derived projection; local input is never authority. |
|
||||
|
||||
The canonical source remains <MOSAIC_HOME>/fleet/roster.yaml for the current local fleet path.
|
||||
The canonical source remains `<MOSAIC_HOME>/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.
|
||||
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
# 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.
|
||||
FCM-M3-001 uses the roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` fields as the only desired-state authority. Systemd, tmux, generated environment files, and heartbeats are derived or observed state.
|
||||
|
||||
| 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 <name> | Never | One-shot exact service start | Exact enabled roster name and proven ownership. |
|
||||
| fleet stop <name> | Never | One-shot exact service stop | Exact roster name and proven ownership. |
|
||||
| fleet restart <name> | 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. |
|
||||
| Command | Desired-state write | Runtime effect | Preconditions |
|
||||
| --------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `fleet apply` / `fleet reconcile` | Never | Rebuilds projections, then starts only enabled agents desired `running`; stops disabled or desired-`stopped` roster agents | Current generation; private managed paths; valid projections; proven holder ownership; no unmanaged named-socket sessions |
|
||||
| `fleet start <name>` | Never | One-shot exact `mosaic-agent@<name>.service` start | Current generation; exact enabled roster name; proven ownership |
|
||||
| `fleet stop <name>` | Never | One-shot exact service stop | Current generation; exact roster name; proven ownership |
|
||||
| `fleet restart <name>` | Never | One-shot exact service restart | Current generation; exact roster name; proven ownership |
|
||||
|
||||
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.
|
||||
A stopped roster agent is never started by `apply` or `reconcile`. Direct lifecycle commands are explicit one-shot actions and do not change persisted desired state. Use roster CRUD with the explicit persisted-start option to change that desired state.
|
||||
|
||||
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.
|
||||
All mutations require `--expected-generation <n>` and acquire one private roster-adjacent reconciliation lock before projection or lifecycle effects. Missing or stale generations and concurrent writers fail before effects; the lock is released after success, partial failure, or thrown lifecycle failure. Stale, ownership, unmanaged-session, unsupported-runtime, path, projection, and lifecycle-precondition failures return stable redacted JSON errors and a non-zero exit. No command targets a fuzzy tmux name, arbitrary socket, arbitrary command, channel, secret, or generated file as authority.
|
||||
|
||||
@@ -16,7 +16,7 @@ Only these legacy class aliases are recognized:
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -50,38 +50,36 @@ agents:
|
||||
|
||||
## 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. |
|
||||
| Field | Required | Constraint | Meaning |
|
||||
| ------------ | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `version` | yes | integer constant `2` | Identifies this contract. Version `1` is explicitly rejected by this compiler and remains on the existing v1 path until M4 migration. |
|
||||
| `generation` | yes | positive safe integer | Desired-state generation. M2 uses it for mutation guards; M1 does not mutate it. |
|
||||
| `transport` | yes | constant `tmux` | M1–M5 support local tmux only. |
|
||||
| `tmux` | yes | strict object | Explicit local socket and holder-session configuration. |
|
||||
| `defaults` | yes | strict object | Default work directory and one supported local runtime. |
|
||||
| `runtimes` | yes | non-empty object | Declared local runtime reset policy map. |
|
||||
| `agents` | yes | 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.<runtime>.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 |
|
||||
| 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.<runtime>.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
|
||||
|
||||
@@ -99,12 +97,12 @@ Semantic validation:
|
||||
`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
|
||||
`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.
|
||||
`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).
|
||||
@@ -114,7 +112,7 @@ lifecycle mutation.
|
||||
|
||||
## Fail-closed boundary
|
||||
|
||||
Every object is additionalProperties: false. The compiler rejects unknown, missing, malformed,
|
||||
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
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
# Local Fleet Status and Drift
|
||||
|
||||
mosaic fleet status [<name>], `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.
|
||||
`mosaic fleet status [name]`, `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, change desired state, start services, stop services, restart services, or mutate tmux.
|
||||
|
||||
## State dimensions
|
||||
The report distinguishes:
|
||||
|
||||
- **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.
|
||||
- `missing-session`: an enabled agent desired `running` has no exact roster-named tmux session.
|
||||
- `unexpected-session`: a desired-`stopped` agent still has its exact session.
|
||||
- `disabled-running`: a disabled roster agent has its exact session.
|
||||
- `unmanagedSessions`: sessions on the configured named socket that are neither the exact holder nor an exact roster agent.
|
||||
- `holder`: `owned`, `missing`, or `ownership-mismatch` after exact holder, private install identity, and complete global tmux environment validation.
|
||||
|
||||
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.
|
||||
`doctor` and `status` classify rather than adopt, destroy, or repair unmanaged state. `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.
|
||||
|
||||
@@ -195,17 +195,6 @@ pnpm format:check && pnpm typecheck && pnpm lint
|
||||
|
||||
A pre-push hook enforces this mechanically.
|
||||
|
||||
### CI Publish Channels
|
||||
|
||||
Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts separate:
|
||||
|
||||
| Source | npm packages | Gateway image |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `main` push/manual or release tag | committed package versions published to Gitea npm without changing the dist-tag workflow | `gateway:sha-<short>` plus `gateway:latest` on `main`, and the release tag on tag events |
|
||||
| `next` push/manual | CI-computed prereleases, `<target-stable>-next.<CI_PIPELINE_NUMBER>`, published with `npm publish --tag next` | `gateway:sha-<short>` only |
|
||||
|
||||
`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass.
|
||||
|
||||
---
|
||||
|
||||
## Adding New Agent Tools
|
||||
|
||||
@@ -175,18 +175,8 @@ Or use the direct URL:
|
||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||
```
|
||||
|
||||
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`.
|
||||
|
||||
Install lanes:
|
||||
|
||||
| Lane | Command | Source |
|
||||
| ------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| Stable | `bash tools/install.sh` | npm `@mosaicstack/mosaic@latest` + `main` |
|
||||
| Prerelease integration | `bash tools/install.sh --next` | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
|
||||
| Contributor/source build | `bash tools/install.sh --dev --ref X` | Build-from-source at the requested ref |
|
||||
|
||||
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` still wins and uses the source path.
|
||||
Flags for non-interactive use:
|
||||
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
|
||||
non-interactive use:
|
||||
|
||||
```bash
|
||||
--yes # Accept all defaults
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
| [`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 |
|
||||
|
||||
@@ -138,24 +138,16 @@ Before any preflight that can decide migration state, `mosaic-db-migrator` acqui
|
||||
|
||||
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).
|
||||
| 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. |
|
||||
|
||||
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.
|
||||
|
||||
@@ -181,36 +173,30 @@ Before relocation, the runner records a parameterized catalog inventory and depe
|
||||
|
||||
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 <fixed_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.
|
||||
External bootstrap executes `REVOKE CONNECT, TEMPORARY ON DATABASE <fixed_database> FROM PUBLIC`, then grants `CONNECT` only to `mosaic_runtime`, `mosaic_migrator`, and the time-bounded external bootstrap actor while it is required. Certification fails if an unrelated login retains `CONNECT` or either application login 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`. `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<version>` / `mosaic-db-importer-version-v<version>` | 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<version>` | 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. |
|
||||
| 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. |
|
||||
| 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<version>` / `mosaic-db-importer-version-v<version>` | 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<version>` | 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.
|
||||
|
||||
@@ -243,21 +229,20 @@ Stable sanitized codes are `DATABASE_RUNTIME_URL_REQUIRED`, `DATABASE_MIGRATION_
|
||||
|
||||
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. |
|
||||
| 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-03` sole runner, manifest, and schema foundation | 00,01 | **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 | **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 | **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 | **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 | **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.
|
||||
The manifests above are the complete ownership universe for KBN-101 implementation paths; 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.
|
||||
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
# 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 = <initial state 'backlog'>` 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(<active User deny row for (workspace_id, id, 'orchestrator')>)` (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 (<foreign W2>, …)` (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_ — `<status-pin> AND workspace_id = current_setting('mosaic.workspace_id', true)::uuid` and, for the Orchestrator rung, `AND NOT EXISTS(<active User-override deny row for (workspace_id, id, 'orchestrator')>)`; **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(<rung>, '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(<rung>, 'mosaic_schema_owner', 'USAGE')` / `NOT has_privs_of_role(<rung>, '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(<rung>, 'mosaic_schema_owner', 'MEMBER')` **AND** `NOT pg_has_role(<rung>, '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 = <foreign W2>` — 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:<column-set>`, `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 = <verified request workspace>` 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…, <mutable non-status cols…>) VALUES (…) ON CONFLICT (<natural key>) DO UPDATE SET <mutable non-status cols…>` — **`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 (<explicit non-status column list>) 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 `<status-pin> AND workspace_id = current_setting('mosaic.workspace_id', true)::uuid [AND NOT EXISTS(<active override deny row>)]`; **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=<foreign W2>`, 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(<override>)` 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(<active deny>)` 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=<foreign W2>`) 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(<rung>, '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(<rung>, 'mosaic_schema_owner', 'MEMBER')` **AND** `NOT pg_has_role(<rung>, '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(<rung>, 'mosaic_schema_owner', 'MEMBER')` AND `NOT pg_has_role(<rung>, '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._
|
||||
@@ -192,6 +192,4 @@ No role can transform a missing gate into a warning by changing status, editing
|
||||
|
||||
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.
|
||||
|
||||
@@ -8,14 +8,6 @@
|
||||
|
||||
## 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.
|
||||
@@ -87,14 +79,14 @@
|
||||
- **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).
|
||||
- **Non-effect:** role graph, 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**).
|
||||
- **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.
|
||||
- **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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user