Compare commits

..

1 Commits

Author SHA1 Message Date
191efaefeb feat(fleet): enforce generated environment boundary (#772)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-15 08:40:32 +00:00
39 changed files with 2792 additions and 1675 deletions

View File

@@ -26,10 +26,9 @@ pnpm test # Vitest (all packages)
pnpm build # Build all packages pnpm build # Build all packages
# Database # Database
pnpm --filter @mosaicstack/db db:generate # Offline migration artifact generation pnpm --filter @mosaicstack/db db:push # Push schema to PG (dev)
mosaic-db-migrator --run # Sole PostgreSQL DDL runner pnpm --filter @mosaicstack/db db:generate # Generate migrations
mosaic-db-migrator --verify # Verified readiness; no URL/SQL/schema/role argv pnpm --filter @mosaicstack/db db:migrate # Run migrations
# db:push is disposable-local-only; db:migrate/storage migrate --run must delegate only to the runner.
# Dev # Dev
docker compose up -d # Start PG, Valkey, OTEL, Jaeger docker compose up -d # Start PG, Valkey, OTEL, Jaeger

View File

@@ -157,10 +157,7 @@ mosaic storage status
mosaic storage tier mosaic storage tier
mosaic storage export mosaic storage export
mosaic storage import mosaic storage import
# `mosaic storage migrate --run` is a schema-wrapper compatibility command only; mosaic storage migrate
# it delegates solely to mosaic-db-migrator and is not tier data copy.
# Tier data copy is the KBN-101 secure migrate-tier route after activation, with
# --target-url-file and --target-attestation-file references only.
``` ```
### Telemetry ### Telemetry
@@ -201,9 +198,8 @@ docker compose up -d
# Install dependencies # Install dependencies
pnpm install pnpm install
# Run PostgreSQL migrations (sole DDL control plane) # Run migrations
mosaic-db-migrator --run pnpm --filter @mosaicstack/db run db:migrate
mosaic-db-migrator --verify
# Start all services in dev mode # Start all services in dev mode
pnpm dev pnpm dev

View File

@@ -152,10 +152,11 @@ for any `<Image>` components added in the future.
## How to Apply ## How to Apply
```bash ```bash
# PostgreSQL migration is a dedicated pre-runtime phase. # Run the DB migration (requires a live DB)
# Deployment injects migration-only credentials/TLS material; Gateway startup only verifies readiness. pnpm --filter @mosaicstack/db exec drizzle-kit migrate
mosaic-db-migrator --run
mosaic-db-migrator --verify # Or, in Docker/Swarm — migrations run automatically on gateway startup
# via runMigrations() in packages/db/src/migrate.ts
``` ```
--- ---

View File

@@ -125,35 +125,6 @@ are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
--- ---
## KBN-101 Database Runtime/Migration Role Split (#771)
### Problem and objective
PostgreSQL Gateway/storage currently uses one `DATABASE_URL` for runtime queries and migrations. That makes the deployed application identity an owner and prevents certification that KBN immutable event, artifact, checkpoint, and evidence relations reject runtime `UPDATE`/`DELETE`. KBN-101 freezes a least-privilege runtime/migration split before KBN-100 schema work.
### Normative requirements
1. `K101-REQ-01`: `DATABASE_URL` SHALL be the non-owner PostgreSQL runtime connection and `DATABASE_MIGRATION_URL` SHALL be the migration-only owner/migrator connection. They are required respectively for runtime and the dedicated `mosaic-db-migrator --run|--verify` phase in `standalone`/`federated`; local PGlite is the explicit exception. The published `@mosaicstack/db` bin maps exactly `mosaic-db-migrator` to `./dist/cli.js`, its image entrypoint is exactly `mosaic-db-migrator`, accepts no URL/SQL/schema/role argv, and returns stable sanitized exits. Every current/future PostgreSQL DDL entrypoint SHALL route to that runner or be denied, and SHALL reject `DATABASE_URL`-only execution before connection/DDL. Data migration may connect only after the runner prepares and verifies the PostgreSQL target, through dedicated non-DDL `mosaic_data_importer` and exactly `--target-url-file /run/secrets/mosaic-migrate-target-url`, its fixed paired authenticated provider-version file `/run/secrets/mosaic-migrate-target-version`, plus `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`. KBN-101-05 obtains URL key `url` and version only from the same successful Vault KV-v2 response at `secret-{env}/mosaic-stack/database/importer` (`data.metadata.version`), renders them as one immutable generation into separate consumer copies, and never infers a provider version from DSN bytes. The trusted runner verifies TLS/identity/manifest, reads its fixed importer URL/version copies only for binding through safe no-follow fd checks, and signs a credential-free JCS/Ed25519 attestation using its runner-only fixed root-owned private-key file; no signing key reaches importer/runtime. The artifact binds secret version and SHA-256 of exact high-entropy credential-file bytes, canonical TLS host/port/database, CA/SPKI, PostgreSQL system identifier/database OID, importer role, manifest/schema fingerprints, producer invocation/build/image digest, issued/expires/nonce, and correlation. Before target connection the importer validates URL/version/attestation/public-key files, signature/key/expiry/replay/authenticated provider version/digest/generation/bindings and the importer-only CA at exact `DATABASE_TLS_CA_CERT_PATH`; after verified TLS and before DML it validates server/database/role/CA/schema identity, with same-fd/in-memory-byte TOCTOU protection, rotation/revocation, a privileged producer-only-to-importer-only artifact handoff controller that verifies/copies/fsyncs/atomically renames/seals before importer start, consumer isolation/no logging-oracle, and sanitized errors. Raw `--target-url`, `DATABASE_URL` fallback, runtime-owner use, missing/unsafe/substituted files, stale/replayed/tampered/wrong-key attestation, wrong binding, and DDL attempt fail before target connection/DDL; post-connect mismatch closes with zero DML/DDL. A reviewed finite classifier inventories executable current source/scripts/package bins, operator docs, deploy manifests, and exact normative contracts by path; active secure records pin both options/files, producer/key/bindings/tests, while normative contracts cannot mask instructions. Unknown active commands, duplicate-owner, ownerless, missing-path, and historical/status-only masking hits fail. `db:push` is forbidden outside an explicitly disposable local developer database and cannot accept a production-like URL.
2. `K101-REQ-02`: Gateway runtime/replicas SHALL not execute migrations or DDL. The runner SHALL hold one `max:1` session and fixed two-int advisory namespace `1297044289` (`MOSA`), `1262636593` (`KBN1`) across preflight, reconciliation, migration, verification, and release. It SHALL compare the versioned canonical manifest v1 tuple (journal logical index/tag plus exact SQL-byte SHA-256) to the complete observed ledger mapping; count/set-only, timestamps, and physical insertion order are non-normative and insufficient.
3. `K101-REQ-03`: PostgreSQL SHALL separate non-login platform database owner, non-login schema owner, dedicated `NOLOGIN SUPERUSER` `mosaic_extension_owner`, login migrator, dedicated login non-DDL data importer, non-login runtime capability, and login runtime roles. For PostgreSQL 17 + pgvector 0.8.2, `vector` is untrusted (`trusted` is absent and `relocatable=true`): only an externally controlled audited platform-bootstrap superuser session may `SET ROLE mosaic_extension_owner` for CREATE/UPDATE/SET SCHEMA, then `RESET ROLE`; the role has `rolcanlogin=false`, `rolsuper=true`, zero members, no runtime credential/Vault secret, and is never provided to app containers. It owns `mosaic_extensions`, fresh `vector`, and owner-bearing extension members, while `mosaic_schema_owner` receives only `USAGE` for type resolution and never ownership/`CREATE`/`ALTER`/`DROP`/member-change/default-privilege authority there. Superuser cannot be constrained by `GRANT`/`REVOKE`; this is identity/non-login/no-membership/external-control/audit isolation, not a false least-privilege claim. Extension operations require control-plane change, independent review, backup/rollback, maintenance window, and audit evidence. Managed targets that cannot establish this exact role are ineligible until an independently approved versioned provider-owned extension-owner profile exists; app/migrator ownership is never silently retained. Existing approved-owner extension relocation validates exact `pg_namespace.nspowner`, `pg_extension.extowner`, member ownership/schema/version, while legacy runtime-owned extension fails closed to a controlled shadow-database migration—never unsupported ownership alteration, catalog mutation, ownership adoption, or `DROP CASCADE`. Runtime, migrator, schema owner, importer, and all service roles must fail `SET ROLE`, catalog/direct `ALTER`/`UPDATE`/`DROP`/membership-change denial, role ownership, superuser/role-creation/schema-creation/TEMPORARY, unsafe membership, untrusted search path, missing grants, unauthenticated TLS, and immutable privilege drift checks. Application schema is fixed `mosaic` with exact `pg_catalog,mosaic` session path; historical public migrations remain byte-immutable legacy bootstrap only, every future Drizzle application declaration targets `mosaic`, and `vector` is explicitly qualified from non-writable `mosaic_extensions`. No config-derived SQL identifier is permitted.
4. `K101-REQ-04`: `mosaicstack/stack` KBN-101-00 SHALL exclusively own `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, and bootstrap tests; KBN-101-05 SHALL exclusively own `tools/db/render-postgres-secrets.ts`, its tests, and current Compose/Portainer/two-gateway deployment declarations, consuming the versioned bootstrap interface without overlap. Environment IaC/Vault is named input and Mosaic deployment control plane/Jason is activation authority. Distinct runtime/migrator/importer URL, importer authenticated provider-version, DB-client CA, Gateway leaf, and PostgreSQL server key/certificate materials are provisioned before a production-like database starts. Importer and migrator have separate immutable URL/version copies at fixed `10002:10002`/`10003:10003` identities; runtime/unrelated containers receive neither importer material, attestation private key, or importer artifact. Runtime, migrator, and importer require their mounted CA plus `sslmode=verify-full`. Exact UID/GID/mode/rendering, service-DNS SANs, Vault/compose/Swarm consumer isolation, two-gateway pair ordering, server activation, pre-enforcement legacy-client drain and `hostssl` zero-plaintext-session proof, fresh/existing transition, CA-overlap rotation, TLS-only rollback, and standalone/federated/Swarm/two-gateway positive/negative TLS evidence are required. No application-generated production certificate or plaintext bootstrap exception is permitted.
5. `K101-REQ-05`: KBN immutable relations SHALL permit the real runtime role INSERT/SELECT only and deny UPDATE/DELETE; parent retention remains RESTRICT/no-cascade. Role/password/Vault creation is external platform control, never application migration/source.
6. `K101-REQ-06`: N-1 single-URL compatibility, rollout/rollback, Vault ownership/rotation/redaction, CI, installer, compose/Portainer, observability, and deployment handoffs SHALL be separately bounded one-card/one-PR work. Prepared slices remain inactive while current owner-runtime deployments stay N-1; Mosaic control plane/Jason alone authorizes one final atomic activation or rollback, with no force-on-red/bypass. KBN-101 planning itself SHALL not mutate production.
7. `K101-REQ-07`: KBN-100 SHALL begin only after the KBN-101 foundation role/schema-boundary certificate; it SHALL rebase on that main head, restore generated Drizzle declaration/snapshot/journal consistency, and bound procedural immutable-table grant/trigger/backfill additions to its schema slice. KBN-101 real deployed-role immutable-operation certification SHALL complete after KBN-100 creates those relations and before KBN-105.
### Acceptance criteria
1. `AC-K101-01`: DTO/command-matrix tests prove required modes, PGlite exception, `mosaic-db-migrator --help|--run|--verify`/stable exits/argv refusal, public-import negative, every finite classified DDL/static-bypass inventory path and both harness pairs reject `DATABASE_URL`-only before connection/DDL, no migration-to-runtime fallback, and `db:push` refusal outside an allowlisted disposable DB. The active `docs/guides/migrate-tier.md` route is inventoried to KBN-101-07 and proves runner-produced `--target-url-file /run/secrets/mosaic-migrate-target-url`, fixed paired provider-version file, and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`; runner-only signing/private-key isolation; Vault KV-v2 same-response version provenance, separate immutable generation mounts, importer CA, JCS/Ed25519 signature/key rotation/revocation, atomic artifact, expiry/replay, safe-fd secret-version/digest, canonical TLS/CA/server/database/role/manifest/schema bindings, dedicated non-DDL importer, consumer isolation/no log-oracle, and exact no-connection versus zero-DML rejection for missing/wrong/stale/replayed/tampered/wrong-key/substituted/generation-mismatched inputs. The full current non-normative docs inventory—including user guide, federation historical task/MILESTONES status, and non-operative SETUP—has an exact safe disposition. Scanner semantic checks reject automatic first-boot/startup extension/schema/migration wording, Compose-up-before-runner, and init-script authority regardless of named/normative/status classification; former SETUP wording and the exact former MILESTONES wording `pgvector extension installed + verified on startup` fail before inventory masking, while the sequence external bootstrap → TLS/roles → runner `--run``--verify` → Gateway readiness passes.
2. `AC-K101-02`: Fixed namespace lock contention/crash/readiness/non-interference and exact manifest-v1 reconciliation tests prove no replica race/runtime auto-migration and fail closed on every missing/unknown/duplicate/ambiguous/corrupt/stale ledger state.
3. `AC-K101-03`: Actual PostgreSQL 17 + pgvector 0.8.2 control-file, catalog, Drizzle-generation, vector-query/operator, fresh/approved-owner/legacy-shadow/partial/resume/rollback/N-1, and real deployed-role tests prove `trusted` absent/untrusted plus relocatability, external-superuser `SET ROLE` create/update/`RESET ROLE` audit, exact `rolcanlogin=false`/`rolsuper=true`/zero-membership/no-runtime-secret state, platform/schema/extension-owner/migrator/importer/runtime separation, `pg_extension.extowner` plus owner-bearing extension-member/schema/version assertions, and runtime/migrator/schema-owner/importer/all-service-role `SET ROLE`/ALTER/DROP/member-update denial. They also prove `pg_catalog,mosaic` per-session pool safety, `mosaic_extensions` qualification, identifier injection denial, ownership/membership/ledger-read/TEMP/default grants, and unsafe privilege denial.
4. `AC-K101-04`: Disposable standalone, federated/Swarm, and two-gateway verified-TLS positives plus for both pairs missing CA/wrong CA/wrong SAN/sslmode downgrade, server/Gateway key mode, UID/GID, secret-consumer isolation, and legacy-drain/`hostssl` negatives prove server bootstrap, ordering, and readiness; PGlite is expressly excluded from this PostgreSQL evidence.
5. `AC-K101-05`: Real runtime-role evidence proves INSERT/SELECT succeeds and UPDATE/DELETE fails for every frozen immutable KBN relation.
6. `AC-K101-06`: N-1/atomic activation/rollback, Vault/CA-overlap rotation/redaction, health/operator behavior, CI/deployment handoff, independent exact-head security review, and terminal-green CI evidence the foundation before KBN-100; after KBN-100, the real deployed-role immutable-operation certificate and Ultron approval release KBN-105.
**Normative implementation contract:** [`docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md`](./native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md). `ASSUMPTION:` existing `standalone` and `federated` are all PostgreSQL production-like modes; any new PostgreSQL tier inherits these requirements until an explicit versioned amendment.
---
## Tess Interaction Agent Workstream (TESS) ## Tess Interaction Agent Workstream (TESS)
### Problem and Objective ### Problem and Objective

View File

@@ -1,5 +1,12 @@
# Documentation Sitemap # Documentation Sitemap
## Fleet configuration management
- [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 ## Official channel plugins
- [Channel protocol architecture](architecture/channel-protocol.md) — shared lifecycle, message, stable-route, authorization, and response-target contracts. - [Channel protocol architecture](architecture/channel-protocol.md) — shared lifecycle, message, stable-route, authorization, and response-target contracts.
@@ -14,10 +21,7 @@
- [Workstream index](native-kanban-sot/INDEX.md) — artifact map, lane partition, and delivery order. - [Workstream index](native-kanban-sot/INDEX.md) — artifact map, lane partition, and delivery order.
- [Mission manifest](native-kanban-sot/MISSION-MANIFEST.md) — scope, authority, invariants, and gate model. - [Mission manifest](native-kanban-sot/MISSION-MANIFEST.md) — scope, authority, invariants, and gate model.
- [Task decomposition](native-kanban-sot/TASKS.md) — dependency-ordered implementation slices and ownership boundaries. - [Task decomposition](native-kanban-sot/TASKS.md) — dependency-ordered implementation slices and ownership boundaries.
- [KBN-101 database role split](native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md) — rc.13 Vault-authenticated importer URL/version generation with fixed isolated consumers/CA, runner-signed target-bound attestation, non-operative federation-SETUP/MILESTONES semantic closure, PostgreSQL-valid pgvector owner exception, disjoint card manifests, classifier, runner, verified TLS, activation, and certification prerequisite.
- [Federated tier data migration](guides/migrate-tier.md) — active KBN-101-07 operator route: runner-produced target attestation, dedicated non-DDL importer, and paired credential-/attestation-file references only.
- [Frozen shared contract](native-kanban-sot/SHARED-CONTRACT.md) — schema, API, Coordinator, health, recovery, and migration contracts. - [Frozen shared contract](native-kanban-sot/SHARED-CONTRACT.md) — schema, API, Coordinator, health, recovery, and migration contracts.
- [KBN-101 exact-head security review](reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) — retained prior REQUEST CHANGES evidence for `da742ca`; rc.13 awaits independent exact-head re-review after closing the federation-MILESTONES startup-extension HIGH finding.
- [Initial independent review](reports/native-kanban-sot/canon-initial-review-no-go.md) — KCR-001016 findings that blocked the first draft. - [Initial independent review](reports/native-kanban-sot/canon-initial-review-no-go.md) — KCR-001016 findings that blocked the first draft.
- [Final independent re-review](reports/native-kanban-sot/canon-final-rereview-go.md) — closure evidence and GO verdict. - [Final independent re-review](reports/native-kanban-sot/canon-final-rereview-go.md) — closure evidence and GO verdict.
- [Ultron final gate](reports/native-kanban-sot/ultron-final-go.md) — final requirements, authority, schema, migration, recovery, and evidence review. - [Ultron final gate](reports/native-kanban-sot/ultron-final-go.md) — final requirements, authority, schema, migration, recovery, and evidence review.

View File

@@ -70,10 +70,6 @@ export function createQueue(config?: QueueConfig): QueueHandle {
### `@mosaicstack/db` (packages/db/src/client.ts) ### `@mosaicstack/db` (packages/db/src/client.ts)
> **Historical design specimen — status-only, not an operator instruction.** KBN-101 supersedes
> this pre-split `DATABASE_URL` fallback shape; it cannot authorize runtime migration, DDL, or a
> connection-string fallback. See the KBN-101 runner/role contract for the produced interface.
```typescript ```typescript
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import postgres from 'postgres'; import postgres from 'postgres';

View File

@@ -54,7 +54,7 @@ Every milestone adds tests to these layers. A milestone cannot be claimed comple
- Add `"tier": "federated"` to `mosaic.config.json` schema and validators - Add `"tier": "federated"` to `mosaic.config.json` schema and validators
- Docker Compose `federated` profile (`docker-compose.federated.yml`) adds: Postgres+pgvector (5433), Valkey (6380), dedicated volumes - Docker Compose `federated` profile (`docker-compose.federated.yml`) adds: Postgres+pgvector (5433), Valkey (6380), dedicated volumes
- Tier detector in gateway bootstrap: reads config, asserts required services reachable, refuses to start otherwise - Tier detector in gateway bootstrap: reads config, asserts required services reachable, refuses to start otherwise
- **Historical/status only:** the prior startup-provisioning statement is superseded. Runtime/startup extension provisioning is forbidden. The only eligible sequence is external bootstrap → TLS/roles → `mosaic-db-migrator --run``mosaic-db-migrator --verify` → Gateway readiness; this record authorizes no current DDL, Compose/init, or startup path. - `pgvector` extension installed + verified on startup
- Migration logic: safe upgrade path from `local`/`standalone``federated` (data export/import script, one-way) - Migration logic: safe upgrade path from `local`/`standalone``federated` (data export/import script, one-way)
- `mosaic doctor` reports tier + service health - `mosaic doctor` reports tier + service health
- Gateway continues to serve as a normal standalone instance (no federation yet) - Gateway continues to serve as a normal standalone instance (no federation yet)

View File

@@ -1,71 +1,280 @@
# Federated Tier Setup Guide # Federated Tier Setup Guide
> **KBN-101 N-1 hold:** This page is **non-operative** until KBN-101-08 activates a ## What is the federated tier?
> reviewed release. It does not authorize a deployment operation, initialization artifacts,
> implicit extension/schema/migration creation, raw `CREATE`, direct database initialization, or
> a Gateway against an unverified database. The prior direct-start wording is retired; its
> regression fixture is owned by KBN-101-06.
## Activation-only sequence The federated tier is designed for multi-user and multi-host deployments. It consists of PostgreSQL 17 with pgvector extension (for embeddings and RAG), Valkey for distributed task queueing and caching, and a shared configuration across multiple Mosaic gateway instances. Use this tier when running Mosaic in production or when scaling beyond a single-host deployment.
The deployment control plane—not an operator shell or deployment lifecycle hook—performs this ## Prerequisites
exact sequence after activation authorization:
1. External privileged bootstrap provisions the approved database/extension prerequisites. - Docker and Docker Compose installed
2. The renderer installs the generation-pinned verified-TLS materials and roles. - Ports 5433 (PostgreSQL) and 6380 (Valkey) available on your host (or adjust environment variables)
3. The dedicated one-shot runner executes `mosaic-db-migrator --run`. - At least 2 GB free disk space for data volumes
4. The same runner executes `mosaic-db-migrator --verify`, including readiness and the
importer-target attestation where that route is enabled.
5. Only after successful verification may Gateway reach its independent verified-TLS Gateway
readiness gate.
No step may be reordered, skipped, replaced by a raw SQL command, or delegated to an initialization ## Start the federated stack
hook.
A missing extension, schema, migration, role, secret generation, or readiness proof is a failed
control-plane precondition; it is not an instruction to start Compose, retry startup, or create
anything directly.
## N-1 status and required disposition Run the federated overlay:
The current branch retains historical federation artifacts, but they are not a deployable ```bash
procedure. `docs/federation/TASKS.md` records their shipped status only. KBN-101-02 retires docker compose -f docker-compose.federated.yml --profile federated up -d
runtime/init DDL; KBN-101-05 owns the renderer/deployment handoff; KBN-101-06 verifies the ```
finite scanner and command matrix; and KBN-101-07 owns this operator route. A path named in an
inventory, a historical-status label, or a normative requirement cannot suppress the semantic
checks above.
Until the activation certificate names an exact release, use no database startup or recovery This starts PostgreSQL 17 with pgvector and Valkey 8. The pgvector extension is created automatically on first boot.
command from this document. For the produced importer interface, see
[the federated tier migration contract](../guides/migrate-tier.md); it is likewise non-operative
until activation.
## Federation and Step-CA reference Verify the services are running:
Federation uses PostgreSQL 17 with pgvector, Valkey, and a shared configuration across multiple ```bash
Gateway instances. Step-CA issues federation peer X.509 certificates whose custom OIDs carry a docker compose -f docker-compose.federated.yml ps
grant and subject identity. The following facts are reference material only; provisioning and ```
secret delivery remain deployment-control-plane work under the activation sequence.
| OID | Name | Description | Expected output shows `postgres-federated` and `valkey-federated` both healthy.
| ------------------- | ------------------------ | --------------------- |
| 1.3.6.1.4.1.99999.1 | `mosaic_grant_id` | Federation grant UUID |
| 1.3.6.1.4.1.99999.2 | `mosaic_subject_user_id` | Subject user UUID |
The internal arc `1.3.6.1.4.1.99999` is development-only. Before an externally reachable ## Configure mosaic for federated tier
production deployment, register an IANA Private Enterprise Number and version the assignments.
Each value is DER-encoded as an ASN.1 UTF8String containing the UUID.
The future activated Gateway requires `STEP_CA_URL`, `STEP_CA_PROVISIONER_PASSWORD`, Create or update your `mosaic.config.json`:
`STEP_CA_PROVISIONER_KEY_JSON`, `STEP_CA_ROOT_CERT_PATH`, and `BETTER_AUTH_SECRET` through the
reviewed secret mechanism. These names do not authorize shell exports, copied credential files,
or an ad hoc service start.
## Failure disposition ```json
{
"tier": "federated",
"database": "postgresql://mosaic:mosaic@localhost:5433/mosaic",
"queue": "redis://localhost:6380"
}
```
- A TLS, CA, SAN, role, runner, or readiness failure is a control-plane incident. Preserve only If you're using environment variables instead:
sanitized evidence and follow the approved rollback/repair record.
- A pgvector/extension failure is a failed external-bootstrap or runner precondition. Do not use ```bash
direct extension SQL, init artifacts, or a startup retry as remediation. export DATABASE_URL="postgresql://mosaic:mosaic@localhost:5433/mosaic"
- A port, container, or Valkey problem does not permit bypassing the activation sequence. export REDIS_URL="redis://localhost:6380"
- Federation peer-key rotation remains deferred until its separately approved migration plan; ```
do not rotate `BETTER_AUTH_SECRET` without that plan.
## Verify health
Run the health check:
```bash
mosaic gateway doctor
```
Expected output (green):
```
Tier: federated Config: mosaic.config.json
✓ postgres localhost:5433 (42ms)
✓ valkey localhost:6380 (8ms)
✓ pgvector (embedded) (15ms)
```
For JSON output (useful in CI/automation):
```bash
mosaic gateway doctor --json
```
## Step 2: Step-CA Bootstrap
Step-CA is a certificate authority that issues X.509 certificates for federation peers. In Mosaic federation, it signs peer certificates with custom OIDs that embed grant and user identities, enforcing authorization at the certificate level.
### Prerequisites for Step-CA
Before starting the CA, you must set up the dev password:
```bash
cp infra/step-ca/dev-password.example infra/step-ca/dev-password
# Edit dev-password and set your CA password (minimum 16 characters)
```
The password is required for the CA to boot and derive the provisioner key used by the gateway.
### Start the Step-CA service
Add the step-ca service to your federated stack:
```bash
docker compose -f docker-compose.federated.yml --profile federated up -d step-ca
```
On first boot, the init script (`infra/step-ca/init.sh`) runs automatically. It:
- Generates the CA root key and certificate in the Docker volume
- Creates the `mosaic-fed` JWK provisioner
- Applies the X.509 template from `infra/step-ca/templates/federation.tpl`
The volume is persistent, so subsequent boots reuse the existing CA keys.
Verify the CA is healthy:
```bash
curl https://localhost:9000/health --cacert /tmp/step-ca-root.crt
```
(If the root cert file doesn't exist yet, see the extraction steps below.)
### Extract credentials for the gateway
The gateway requires two credentials from the running CA:
**1. Provisioner key (for `STEP_CA_PROVISIONER_KEY_JSON`)**
```bash
docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json > /tmp/step-ca-provisioner.json
```
This JSON file contains the JWK public and private keys for the `mosaic-fed` provisioner. Store it securely and pass its contents to the gateway via the `STEP_CA_PROVISIONER_KEY_JSON` environment variable.
**2. Root certificate (for `STEP_CA_ROOT_CERT_PATH`)**
```bash
docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
```
This PEM file is the CA's root certificate, used to verify peer certificates issued by step-ca. Pass its path to the gateway via `STEP_CA_ROOT_CERT_PATH`.
### Custom OID Registry
Federation certificates include custom OIDs in the certificate extension. These encode authorization metadata:
| OID | Name | Description |
| ------------------- | ---------------------- | --------------------- |
| 1.3.6.1.4.1.99999.1 | mosaic_grant_id | Federation grant UUID |
| 1.3.6.1.4.1.99999.2 | mosaic_subject_user_id | Subject user UUID |
These OIDs are verified by the gateway after the CSR is signed, ensuring the certificate was issued with the correct grant and user context.
### Environment Variables
Configure the gateway with the following environment variables before startup:
| Variable | Required | Description |
| ------------------------------ | -------- | --------------------------------------------------------------------------------------------------------- |
| `STEP_CA_URL` | Yes | Base URL of the step-ca instance, e.g. `https://step-ca:9000` (use `https://localhost:9000` in local dev) |
| `STEP_CA_PROVISIONER_KEY_JSON` | Yes | JSON-encoded JWK from `/home/step/secrets/mosaic-fed.json` |
| `STEP_CA_ROOT_CERT_PATH` | Yes | Absolute path to the root CA certificate (e.g. `/tmp/step-ca-root.crt`) |
| `BETTER_AUTH_SECRET` | Yes | Secret used to seal peer private keys at rest; already required for M1 |
Example environment setup:
```bash
export STEP_CA_URL="https://localhost:9000"
export STEP_CA_PROVISIONER_KEY_JSON="$(cat /tmp/step-ca-provisioner.json)"
export STEP_CA_ROOT_CERT_PATH="/tmp/step-ca-root.crt"
export BETTER_AUTH_SECRET="<your-secret>"
```
## Troubleshooting
### Port conflicts
**Symptom:** `bind: address already in use`
**Fix:** Stop the base dev stack first:
```bash
docker compose down
docker compose -f docker-compose.federated.yml --profile federated up -d
```
Or change the host port with an environment variable:
```bash
PG_FEDERATED_HOST_PORT=5434 VALKEY_FEDERATED_HOST_PORT=6381 \
docker compose -f docker-compose.federated.yml --profile federated up -d
```
### pgvector extension error
**Symptom:** `ERROR: could not open extension control file`
**Fix:** pgvector is created at first boot. Check logs:
```bash
docker compose -f docker-compose.federated.yml logs postgres-federated | grep -i vector
```
If missing, exec into the container and create it manually:
```bash
docker exec <postgres-federated-id> psql -U mosaic -d mosaic -c "CREATE EXTENSION vector;"
```
### Valkey connection refused
**Symptom:** `Error: connect ECONNREFUSED 127.0.0.1:6380`
**Fix:** Check service health:
```bash
docker compose -f docker-compose.federated.yml logs valkey-federated
```
If Valkey is running, verify your firewall allows 6380. On macOS, Docker Desktop may require binding to `host.docker.internal` instead of `localhost`.
## Key rotation (deferred)
Federation peer private keys (`federation_peers.client_key_pem`) are sealed at rest using AES-256-GCM with a key derived from `BETTER_AUTH_SECRET` via SHA-256. If `BETTER_AUTH_SECRET` is rotated, all sealed `client_key_pem` values in the database become unreadable and must be re-sealed with the new key before rotation completes.
The full key rotation procedure (decrypt all rows with old key, re-encrypt with new key, atomically swap the secret) is out of scope for M2. Operators must not rotate `BETTER_AUTH_SECRET` without a migration plan for all sealed federation peer keys.
## OID Assignments — Mosaic Internal OID Arc
Mosaic uses the private enterprise arc `1.3.6.1.4.1.99999` for custom X.509
certificate extensions in federation grant certificates.
**IMPORTANT:** This is a development/internal OID arc. Before deploying to a
production environment accessible by external parties, register a proper IANA
Private Enterprise Number (PEN) at <https://pen.iana.org/pen/PenApplication.page>
and update these assignments accordingly.
### Assigned OIDs
| OID | Symbolic name | Description |
| --------------------- | --------------------------------- | --------------------------------------------------------- |
| `1.3.6.1.4.1.99999.1` | `mosaic.federation.grantId` | UUID of the `federation_grants` row authorising this cert |
| `1.3.6.1.4.1.99999.2` | `mosaic.federation.subjectUserId` | UUID of the local user on whose behalf the cert is issued |
### Encoding
Each extension value is DER-encoded as an ASN.1 **UTF8String**:
```
Tag 0x0C (UTF8String)
Length 0x24 (36 decimal — fixed length of a UUID string)
Value <36 ASCII bytes of the UUID>
```
The step-ca X.509 template at `infra/step-ca/templates/federation.tpl`
produces this encoding via the Go template expression:
```
{{ printf "\x0c\x24%s" .Token.mosaic_grant_id | b64enc }}
```
The resulting base64 value is passed as the `value` field of the extension
object in the template JSON.
### CA Environment Variables
The `CaService` (`apps/gateway/src/federation/ca.service.ts`) requires the
following environment variables at gateway startup:
| Variable | Required | Description |
| ------------------------------ | -------- | -------------------------------------------------------------------- |
| `STEP_CA_URL` | Yes | Base URL of the step-ca instance, e.g. `https://step-ca:9000` |
| `STEP_CA_PROVISIONER_PASSWORD` | Yes | JWK provisioner password for the `mosaic-fed` provisioner |
| `STEP_CA_PROVISIONER_KEY_JSON` | Yes | JSON-encoded JWK (public + private) for the `mosaic-fed` provisioner |
| `STEP_CA_ROOT_CERT_PATH` | Yes | Absolute path to the step-ca root CA certificate PEM file |
Set these variables in your environment or secret manager before starting
the gateway. In the federated Docker Compose stack they are expected to be
injected via Docker secrets and environment variable overrides.
### Fail-loud contract
The CA service (and the X.509 template) are designed to fail loudly if the
custom OIDs cannot be embedded:
- The template produces a malformed extension value (zero-length UTF8String
body) when the JWT claims `mosaic_grant_id` or `mosaic_subject_user_id` are
absent. step-ca rejects the CSR rather than issuing a cert without the OIDs.
- `CaService.issueCert()` throws a `CaServiceError` on every error path with
a human-readable `remediation` string. It never silently returns a cert that
may be missing the required extensions.

View File

@@ -15,20 +15,20 @@
Goal: Gateway runs in `federated` tier with containerized PG+pgvector+Valkey. No federation logic yet. Existing standalone behavior does not regress. Goal: Gateway runs in `federated` tier with containerized PG+pgvector+Valkey. No federation logic yet. Existing standalone behavior does not regress.
| id | status | description | issue | agent | branch | depends_on | estimate | notes | | id | status | description | issue | agent | branch | depends_on | estimate | notes |
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| FED-M1-01 | done | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | sonnet | feat/federation-m1-tier-config | — | 4K | Shipped in PR #470. Renamed `team``standalone`; added `team` deprecation alias; added `DEFAULT_FEDERATED_CONFIG`. | | FED-M1-01 | done | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | sonnet | feat/federation-m1-tier-config | — | 4K | Shipped in PR #470. Renamed `team``standalone`; added `team` deprecation alias; added `DEFAULT_FEDERATED_CONFIG`. |
| FED-M1-02 | done | Historical shipped-status record: authored a federated Compose overlay with PostgreSQL/pgvector, Valkey, volumes, and healthchecks. It is not a current startup, init, extension, schema, or migration procedure. | #460 | sonnet | feat/federation-m1-compose | FED-M1-01 | 5K | Shipped in PR #471 status only. KBN-101-02 retires its init authority; KBN-101-05 replaces deployment rendering; KBN-101-07 SETUP is non-operative until activation. | | FED-M1-02 | done | Author `docker-compose.federated.yml` as an overlay profile: Postgres 17 + pgvector extension (port 5433), Valkey (6380), named volumes, healthchecks. Compose-up should boot cleanly on a clean machine. | #460 | sonnet | feat/federation-m1-compose | FED-M1-01 | 5K | Shipped in PR #471. Overlay defines `postgres-federated`/`valkey-federated`, profile-gated, with pg-init for pgvector extension. |
| FED-M1-03 | done | Historical shipped-status record: add pgvector support to `packages/storage/src/adapters/postgres.ts`; no adapter changes for non-federated tiers. | #460 | sonnet | feat/federation-m1-pgvector | FED-M1-02 | 8K | Shipped in PR #472 status only. **KBN-101 supersedes this behavior:** it cannot authorize current runtime extension creation or any DDL; only the runner/external bootstrap contract may do so. | | FED-M1-03 | done | Add pgvector support to `packages/storage/src/adapters/postgres.ts`: create extension on init (idempotent), expose vector column type in schema helpers. No adapter changes for non-federated tiers. | #460 | sonnet | feat/federation-m1-pgvector | FED-M1-02 | 8K | Shipped in PR #472. `enableVector` flag on postgres StorageConfig; idempotent CREATE EXTENSION before migrations. |
| FED-M1-04 | done | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | sonnet | feat/federation-m1-detector | FED-M1-03 | 8K | Shipped in PR #473. 12 tests; 5s timeouts on probes; pgvector library/permission discrimination; rejects non-bullmq for federated. | | FED-M1-04 | done | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | sonnet | feat/federation-m1-detector | FED-M1-03 | 8K | Shipped in PR #473. 12 tests; 5s timeouts on probes; pgvector library/permission discrimination; rejects non-bullmq for federated. |
| FED-M1-05 | done | Historical shipped-status record: prior tier migration implementation. | #460 | sonnet | feat/federation-m1-migrate | FED-M1-04 | 10K | Shipped in PR #474 status only. **KBN-101 supersedes this route:** it cannot authorize current credentials, target connection, or DDL. The future active route requires runner verification plus target URL-file and signed attestation-file binding. | | FED-M1-05 | done | Write `scripts/migrate-to-federated.ts`: one-way migration from `local` (PGlite) / `standalone` (PG without pgvector) → `federated`. Dumps, transforms, loads; dry-run + confirm UX. Idempotent on re-run. | #460 | sonnet | feat/federation-m1-migrate | FED-M1-04 | 10K | Shipped in PR #474. `mosaic storage migrate-tier`; DrizzleMigrationSource (corrects P0 found in review); 32 tests; idempotent. |
| FED-M1-06 | done | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Shipped in PR #475 as `mosaic gateway doctor`. Probes lifted to @mosaicstack/storage; structural TierConfig breaks dep cycle. | | FED-M1-06 | done | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Shipped in PR #475 as `mosaic gateway doctor`. Probes lifted to @mosaicstack/storage; structural TierConfig breaks dep cycle. |
| FED-M1-07 | done | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Shipped in PR #476. 3 test files, 4 tests, gated by FEDERATED_INTEGRATION=1; reserved-port helper avoids host collisions. | | FED-M1-07 | done | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Shipped in PR #476. 3 test files, 4 tests, gated by FEDERATED_INTEGRATION=1; reserved-port helper avoids host collisions. |
| FED-M1-08 | done | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Shipped in PR #477. Caught P0 in M1-05 (camelCase→snake_case) missed by mocked unit tests; fix in same PR. | | FED-M1-08 | done | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Shipped in PR #477. Caught P0 in M1-05 (camelCase→snake_case) missed by mocked unit tests; fix in same PR. |
| FED-M1-09 | done | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | sonnet | feat/federation-m1-regression | FED-M1-07 | 4K | Clean canary. 351 gateway tests + 85 storage unit tests + full pnpm test all green; only FEDERATED_INTEGRATION-gated tests skip. | | FED-M1-09 | done | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | sonnet | feat/federation-m1-regression | FED-M1-07 | 4K | Clean canary. 351 gateway tests + 85 storage unit tests + full pnpm test all green; only FEDERATED_INTEGRATION-gated tests skip. |
| FED-M1-10 | done | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | feat/federation-m1-security-review | FED-M1-09 | 8K | 2 review rounds caught 7 issues: credential leak in pg/valkey/pgvector errors + redact-error util; missing advisory lock; SKIP_TABLES rationale. | | FED-M1-10 | done | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | feat/federation-m1-security-review | FED-M1-09 | 8K | 2 review rounds caught 7 issues: credential leak in pg/valkey/pgvector errors + redact-error util; missing advisory lock; SKIP_TABLES rationale. |
| FED-M1-11 | done | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Shipped: `docs/federation/SETUP.md` (119 lines), `docs/guides/migrate-tier.md` (147 lines), README Configuration blurb. | | FED-M1-11 | done | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Shipped: `docs/federation/SETUP.md` (119 lines), `docs/guides/migrate-tier.md` (147 lines), README Configuration blurb. |
| FED-M1-12 | done | PR, CI green, merge to main, close #460. | #460 | sonnet | feat/federation-m1-close | FED-M1-11 | 3K | M1 closed. PRs #470-#480 merged across 11 tasks. Issue #460 closed; release tag `fed-v0.1.0-m1` published. | | FED-M1-12 | done | PR, CI green, merge to main, close #460. | #460 | sonnet | feat/federation-m1-close | FED-M1-11 | 3K | M1 closed. PRs #470-#480 merged across 11 tasks. Issue #460 closed; release tag `fed-v0.1.0-m1` published. |
**M1 total estimate:** ~74K tokens (over-budget vs 20K PRD estimate — explanation below) **M1 total estimate:** ~74K tokens (over-budget vs 20K PRD estimate — explanation below)

View File

@@ -1,114 +1,77 @@
# Fleet Launch Runbook # Fleet Launch Runbook
How every Mosaic fleet agent — workers **and** the orchestrator — is launched, and how to The local fleet roster is the sole writable desired-state authority for membership and launch policy.
configure each one. The guiding principle: **one roster-driven launcher**. There is no bespoke Generated environment files are rebuildable projections, not an operator-editable command surface.
per-agent launch script; the roster plus per-agent `.env` files are the single source of launch
config.
## The launch chain ## Launch chain
| Layer | File | Responsibility | | Layer | Responsibility |
| ---------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| systemd unit | `mosaic-agent@<role>.service` | One templated unit per role; `ExecStart` runs the session launcher with the instance name `%i`. Defaults `MOSAIC_AGENT_RUNTIME=pi`, `MOSAIC_AGENT_NAME=%i`. | | Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
| session launcher | `tools/fleet/start-agent-session.sh <role>` | Builds the launch command, opens the tmux pane, wires the heartbeat. | | Projection writer | Renders deterministic `fleet/agents/<name>.env.generated` from the roster. |
| launch command | `mosaic yolo <runtime>` (or a per-agent override) | Replaces the pane's foreground process with the runtime, fully seeded. | | Optional local data | Reads a strict, data-only `fleet/agents/<name>.env.local`; it cannot shadow generated keys. |
| seeding | `mosaic`'s `composeContract()` | Injects the Constitution/USER/TOOLS/runtime contract, `*.local` overlays, **and** the Fleet-Comms cheat-sheet — all via `--append-system-prompt`. | | 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. |
Per-agent overrides live in `fleet/agents/<role>.env`, generated from `roster.yaml` by The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
`generateAgentEnv` (`packages/mosaic/src/commands/fleet.ts`) and consumed by the launcher. command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
secret-like key names, duplicate keys, comments, quoted/export syntax, and unsafe values are rejected.
## Worker launch path (default) ## Generated and local files
1. `roster.yaml` carries each agent's `runtime` and optional `model_hint`. `<name>.env.generated` is complete, deterministic, and written only by Mosaic. Its ordered keys are:
2. `generateAgentEnv` emits `fleet/agents/<role>.env` with `MOSAIC_AGENT_NAME`,
`MOSAIC_AGENT_RUNTIME`, and `MOSAIC_AGENT_MODEL`.
3. `start-agent-session.sh` has no `MOSAIC_AGENT_COMMAND` set, so it falls through to the default
(line ~44):
```sh
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}"
```
4. The launcher bakes `MOSAIC_AGENT_NAME` into the pane command (line ~118), so `composeContract`
can inject the Fleet-Comms cheat-sheet for that role.
That is the whole worker path: roster → `.env` → `mosaic yolo <runtime>` → seeded pane. ```dotenv
MOSAIC_AGENT_NAME=<roster name>
## Orchestrator fold (PATH A — ships today) MOSAIC_AGENT_CLASS=<roster class>
MOSAIC_AGENT_RUNTIME=<roster runtime>
The orchestrator is **just another roster agent** launched through the canonical path — not a MOSAIC_AGENT_MODEL=<roster model hint>
snowflake script. MOSAIC_AGENT_REASONING=<roster reasoning>
MOSAIC_AGENT_TOOL_POLICY=<roster tool policy>
| Piece | Value | MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
| ------------------ | ----------------------------------- | MOSAIC_TMUX_SOCKET=<roster socket or empty>
| host-side launcher | `orchestrator-launch.sh` |
| systemd unit | `mosaic-fleet-orchestrator.service` |
| tmux session | `orchestrator` (role-named) |
Set its launch command via `fleet/agents/orchestrator.env`:
```sh
MOSAIC_AGENT_COMMAND='mosaic yolo claude --channels plugin:discord@<channel>'
``` ```
When `MOSAIC_AGENT_COMMAND` is set, `start-agent-session.sh`'s `if [ -z "$MOSAIC_AGENT_COMMAND" ]` The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. `mosaic fleet add`
guard (line ~41) is false, so the line-44 default — **including its hardcoded `yolo`** — is skipped rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
entirely. The override fully controls the runtime and flags. Routing through `mosaic yolo claude` The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
(rather than a raw `claude` invocation) is what gives the orchestrator the same full it has no generated-launch adapter and cannot be added through this path.
`composeContract` seeding + Fleet-Comms cheat-sheet as every worker, with `--channels` and any
other flags passed straight through to the `claude` binary.
## Launch gotchas `<name>.env.local` is optional and may contain only non-secret machine data:
1. **Flag conflict.** `mosaic yolo claude` already injects `--dangerously-skip-permissions`. Do - `MOSAIC_RUNTIME_BIN`
**not** also pass `--permission-mode bypassPermissions` — the `claude` binary would receive both. - `MOSAIC_HEARTBEAT_RUN_DIR`
Use `mosaic yolo claude …` alone (yolo covers the unattended posture), **or** non-yolo - `MOSAIC_HEARTBEAT_INTERVAL`
`mosaic claude --permission-mode bypassPermissions …`. Never mix the two. - `MOSAIC_CLAUDE_JSON`
2. **`MOSAIC_AGENT_NAME` must reach the pane.** The launcher bakes it from the instance name, and - `CLAUDE_CONFIG_DIR`
`composeContract` gates the Fleet-Comms block on it (`launch.ts`, in `composeContract`) — **and**
the role must be a member of `roster.yaml`, or the block resolves empty.
3. **`launchRuntime` guards.** `mosaic yolo claude` runs `checkSoul` / `checkRuntime` /
`checkSequentialThinking`. The host needs `SOUL.md` and the sequential-thinking MCP, or the
launch aborts (a raw `claude` invocation skipped these checks). Dry-run the composed command in a
throwaway tmux session before swapping a live launcher.
## Why per-agent `.env` survives upgrades (#632) Paths must be safe absolute paths and the heartbeat interval must be a positive integer. Projection,
local, and quarantine files must be private regular files; the managed directories must be real,
private, non-symlink paths. Violations fail closed before tmux interaction.
`install.sh` `PRESERVE_PATHS` includes `fleet/*.yaml`, `fleet/agents`, and `fleet/run`, so ## Legacy input and diagnostics
`mosaic update`'s framework re-seed **preserves** your roster and per-agent `.env` overrides
(glob-aware `cp` fallback; matching TS parity in `file-adapter.ts`). Before #632, an auto re-seed
could wipe them — which is exactly why PATH A's `.env` override is safe to rely on now.
## Inspecting the comms wiring 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.
- `mosaic fleet comms-block <role>` prints the Fleet-Comms cheat-sheet a given role receives at Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
launch — its `[host:session]` identity, the exact `agent-send.sh` command for each peer, and the text, credentials, or other values.
FLIP / `--verify` conventions. `--host <h>` previews a cross-host view. An unknown role or missing
roster **fails loud** (stderr + non-zero exit), so a typo is never a silent no-op.
- Versus `mosaic compose-contract <runtime>`: that emits the **whole** system prompt and reads the
role from `MOSAIC_AGENT_NAME` (a full-prompt smoke test). `comms-block` is the targeted,
explicit-arg, comms-only view — e.g. `mosaic fleet comms-block coder0-0` to preview a peer.
## North Star / future direction ## Launch and stop behavior
**Vision:** a webUI lets the user edit each agent's launch config — switch **harness** The launcher obtains the agent's socket only from the validated generated projection. It creates or
(claude / pi / codex / opencode), toggle **yolo**, pick a **model**, set a **command/channels** checks the exact `=<agent-name>` tmux target; it never uses an ambient socket or fuzzy session match.
override — with no terminal. The same strict parser runs before exact-stop behavior. A fresh native Pi heartbeat remains authoritative;
the shell sidecar only provides fallback state when the native marker is stale or absent.
**Continuity — this is not a new launch path.** It is a data-model + UI-binding layer over the `mosaic fleet comms-block <role>` can inspect the role's resolved Fleet-Comms block. It is a read-only
existing roster-driven launcher. Field-by-field status today: inspection tool and fails loudly for an unknown role or missing roster.
| Launch-config field | Roster-native today? | Mechanism / gap | ## Current M2 boundary
| ------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **harness** (`runtime`) | ✅ end-to-end | `roster.runtime` → `generateAgentEnv` emits `MOSAIC_AGENT_RUNTIME` → launcher line 44. UI just writes the field. |
| **model** (`model_hint`) | ✅ end-to-end | `roster.model_hint` → `MOSAIC_AGENT_MODEL` → launcher line 44 `--model`. UI just writes the field. |
| **yolo** | ❌ new | Launcher line 44 **hardcodes** `mosaic yolo`. A non-yolo toggle needs a roster `yolo` field → emit `MOSAIC_AGENT_YOLO` → make line 44 conditional. |
| **command / channels** | ❌ new | `MOSAIC_AGENT_COMMAND` is **consumed** (launcher line ~12) but `generateAgentEnv` does not emit it. Needs a roster `command`/`channels` field → emitted. |
**The arc:** FCM-M2-001 supplies generated/local parsing, validation, projection, quarantine, and launch-boundary
evidence only. It does not authorize roster CRUD expansion, reconciliation, lifecycle changes, remote
- **A** — `.env` `MOSAIC_AGENT_COMMAND` hatch: manual, ships now, kept safe across upgrades by #632. or connector mutation, site canaries, or migration. M3 must establish the local reconcile/lifecycle
- **B** — roster-native launch-config: harness + model are already there; add the **yolo** toggle path; M4 separately provides migration preview, canary, and rollback gates.
(line-44 conditional) and **command/channels** emission to complete the data model.
- **webUI** — binds dropdowns/toggles directly to those four roster fields.
PATH A's `.env` override is the **manual form** of exactly what PATH B makes roster-native and the
webUI edits — one continuous arc, not three separate features. PATH B is tracked as #636.

View File

@@ -14,17 +14,18 @@ and surfaced as `mosaic fleet backlog <sub> --json`.
The backlog uses the existing Mosaic storage layer; there is **no** new database The backlog uses the existing Mosaic storage layer; there is **no** new database
engine (no sqlite, no raw client). engine (no sqlite, no raw client).
| Condition | Tier | Data location | | Condition | Tier | Data location |
| ---------------------------------- | -------------------- | ---------------------------------------------------------------- | | ------------------------------ | -------------------- | -------------------------------- |
| `DATABASE_URL` injected at runtime | Full server Postgres | the verified runtime database; it never authorizes migration/DDL | | `DATABASE_URL` set | Full server Postgres | the configured database |
| `PGLITE_DATA_DIR` set (no URL) | Embedded PGlite | that directory | | `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 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 claim relies on — so the **same code** runs on a laptop (embedded, single-host
default) and on a full Postgres deployment. Switching tiers is config-only. default) and on a full Postgres deployment. Switching tiers is config-only.
For embedded PGlite only, the local backlog routine may prepare its local schema on first use. PostgreSQL never migrates on CLI first use: it requires the dedicated `mosaic-db-migrator --run` phase and its read-only runtime readiness check before `mosaic fleet backlog` connects. The schema (`backlog` table) is created automatically on first CLI use:
`runMigrations()` for Postgres, `runPgliteMigrations()` for embedded PGlite.
### Update safety ### Update safety

View File

@@ -0,0 +1,96 @@
# Fleet Generated Environment Boundary
**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
a second roster or a command configuration surface.
## Paths and ownership
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. |
The systemd templates do not load either environment file. They invoke Bash with a fixed, cleared
bootstrap environment; the launcher reads and validates `.env.generated` and `.env.local` itself before
it queries, creates, or stops an exact tmux session. It does not `source`, `eval`, or execute an
environment-supplied command. Exact stop derives its socket from the same validated generated projection,
not from systemd or ambient environment data.
All projection, local, and quarantine files must be regular files with no group or world permissions.
The agent environment directory must also be a real, non-symlink private directory; it is validated
before either environment file is read or tmux is queried. Unsafe paths, symlinks, or permissions fail
closed. Diagnostics identify only a rule code, key name, and SHA-256 content hash; they never print
values, credential material, or command text.
## Allowed data
`.env.generated` is complete and ordered exactly as follows:
```dotenv
MOSAIC_AGENT_NAME=<roster name>
MOSAIC_AGENT_CLASS=<roster class>
MOSAIC_AGENT_RUNTIME=<roster runtime>
MOSAIC_AGENT_MODEL=<roster model hint>
MOSAIC_AGENT_REASONING=<roster reasoning>
MOSAIC_AGENT_TOOL_POLICY=<roster tool policy>
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`
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
this projection path.
`.env.local` may contain only these non-secret data keys:
- `MOSAIC_RUNTIME_BIN`
- `MOSAIC_HEARTBEAT_RUN_DIR`
- `MOSAIC_HEARTBEAT_INTERVAL`
- `MOSAIC_CLAUDE_JSON`
- `CLAUDE_CONFIG_DIR`
Local paths must be safe absolute paths and the interval must be a positive integer. Comments,
quoted/export syntax, duplicate keys, unknown keys, generated-key shadowing, sensitive key names,
and `MOSAIC_AGENT_COMMAND` are rejected. The launcher derives the only executable command from the
validated runtime, model, and reasoning data; no arbitrary command compatibility path exists. When a
Pi runtime writes a fresh `<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
During projection generation, legacy roster-derived keys are regenerated from the roster. A valid
allowed local value is relocated to `.env.local`; forbidden, malformed, duplicate, sensitive, and
unknown legacy entries cause the legacy file to be moved to `.env.quarantine` and are represented by
sanitized diagnostics. This is deterministic and idempotent after the legacy file has been consumed.
## USC interface packet
This card does not add a USC site file, write a USC roster, or run a site canary. The following is the
consolidated downstream interface packet. Status is deliberately separated from checkout presence: no
product release version has been evidenced for this interface set.
| Interface | Canonical public path and version | Tracker/release status | Downstream limit |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| M1 structural compiler | `parseRosterV2` in `packages/mosaic/src/fleet/roster-v2.ts`; schema `docs/fleet/reference/roster-v2.schema.json`; roster `version: 2` | FCM-M1-001 is recorded done, merged as #764 (`aa5b43b`); no released product version is asserted here. | Parse YAML/JSON and canonicalize a supplied v2 site roster without writes. |
| M1 semantic resolver | `validateRosterV2Semantics` in `packages/mosaic/src/fleet/roster-v2.ts`; baseline `framework/fleet/roles/` plus `roles.local/` | FCM-M1-002 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.
Generated environment data is a rebuildable projection, not an operator-editable source of membership,
runtime policy, or lifecycle state.
**Corrected downstream gates:** M2 supplies only parse/validation/projection evidence and does not
permit a USC site canary, reconciliation, or lifecycle mutation. M3 must first define and validate the
canonical local reconcile/lifecycle path. M4 then supplies preview/migration and its separate
canary/rollback gates; only after those M3 and M4 gates may a site migration or canary be considered.
This card authorizes none of those actions.

View File

@@ -223,10 +223,10 @@ external clients. Authentication requires a valid BetterAuth session (cookie or
### Required ### Required
| Variable | Description | | Variable | Description |
| -------------------- | ----------------------------------------------------------------------------------------------------------- | | -------------------- | ----------------------------------------------------------------------------------------- |
| `BETTER_AUTH_SECRET` | Secret key for BetterAuth session signing. Must be set or gateway will not start. | | `BETTER_AUTH_SECRET` | Secret key for BetterAuth session signing. Must be set or gateway will not start. |
| `DATABASE_URL` | Runtime-only PostgreSQL connection injected from the dedicated deployment secret; no default or inline DSN. | | `DATABASE_URL` | PostgreSQL connection string. Default: `postgresql://mosaic:mosaic@localhost:5433/mosaic` |
### Gateway ### Gateway

View File

@@ -61,13 +61,9 @@ pnpm install
### 4. Initialize the database ### 4. Initialize the database
```bash ```bash
mosaic-db-migrator --run pnpm --filter @mosaicstack/db db:migrate
mosaic-db-migrator --verify
``` ```
This is the sole PostgreSQL DDL/readiness control plane. It receives migration-only credentials
through deployment secret mounts and accepts no URL, SQL, schema, or role argv.
### 5. Build all packages ### 5. Build all packages
```bash ```bash
@@ -122,25 +118,34 @@ sudo apt-get install -y postgresql-17 postgresql-17-pgvector
# Follow https://valkey.io/download/ for your distribution # Follow https://valkey.io/download/ for your distribution
``` ```
### Step 2 — Provision the database ### Step 2 — Create the database
Do not run ad hoc SQL, create application roles, or create extensions from this guide. The ```sql
external reviewed bootstrap artifact provisions roles/pgvector, then the migration Job runs -- Run as the postgres superuser
`mosaic-db-migrator --run` and `mosaic-db-migrator --verify` over verified TLS. This guide does CREATE USER mosaic WITH PASSWORD 'change-me';
not authorize a plaintext, owner-runtime, or manual-extension bootstrap. CREATE DATABASE mosaic OWNER mosaic;
\c mosaic
CREATE EXTENSION IF NOT EXISTS vector;
```
### Step 3 — Clone and configure ### Step 3 — Clone and configure
```bash ```bash
git clone <repo-url> /opt/mosaic git clone <repo-url> /opt/mosaic
cd /opt/mosaic cd /opt/mosaic
cp .env.example .env
``` ```
Production deployment does **not** read a committed or local `.env` file. Use the reviewed Edit `/opt/mosaic/.env`. Required fields:
secret renderer/deployment control plane: it injects runtime secrets only at process exec,
migration secrets only to the one-shot runner, and non-secret endpoint configuration separately. ```dotenv
Validate required values at startup; never write a DSN or BetterAuth secret into this guide or a DATABASE_URL=postgresql://mosaic:<password>@localhost:5432/mosaic
production filesystem `.env`. VALKEY_URL=redis://localhost:6379
BETTER_AUTH_SECRET=<openssl rand -base64 32>
BETTER_AUTH_URL=https://your-domain.example.com
GATEWAY_CORS_ORIGIN=https://your-domain.example.com
NEXT_PUBLIC_GATEWAY_URL=https://your-domain.example.com
```
### Step 4 — Install dependencies and build ### Step 4 — Install dependencies and build
@@ -152,12 +157,9 @@ pnpm build
### Step 5 — Run database migrations ### Step 5 — Run database migrations
```bash ```bash
mosaic-db-migrator --run pnpm --filter @mosaicstack/db db:migrate
mosaic-db-migrator --verify
``` ```
The one-shot migrator alone receives the migration secret; Gateway/runtime never receives it.
### Step 6 — Start the gateway ### Step 6 — Start the gateway
```bash ```bash
@@ -338,12 +340,20 @@ MaxRetentionSec=30day
`BETTER_AUTH_SECRET` is missing or empty. Set it in `.env` and restart. `BETTER_AUTH_SECRET` is missing or empty. Set it in `.env` and restart.
### Runtime database connection refused ### `DATABASE_URL` connection refused
Verify PostgreSQL service/TLS readiness and the deployment-mounted runtime secret reference. Do Verify PostgreSQL is running and the port matches. The Docker Compose default is `5433`; bare-metal typically uses `5432`.
not print or test a DSN from a shell. Run the sanitized `mosaic-db-migrator --verify` readiness
procedure; if pgvector or schema verification fails, stop and remediate through the reviewed ```bash
bootstrap/runner control plane. Manual extension SQL is unavailable. psql "$DATABASE_URL" -c '\conninfo'
```
### pgvector extension missing
```sql
\c mosaic
CREATE EXTENSION IF NOT EXISTS vector;
```
### Valkey / Redis connection refused ### Valkey / Redis connection refused

View File

@@ -106,9 +106,7 @@ This starts:
Create a `.env` file in the monorepo root: Create a `.env` file in the monorepo root:
```env ```env
# Runtime database connection (local-development placeholder only; never a production secret) # Database (matches docker-compose defaults)
# KBN-101 production-like runtime injects DATABASE_URL from its dedicated secret mount.
# Importer URL/version are separate generation-pinned files and never env-exported.
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
# Auth (required — generate a random 32+ char string) # Auth (required — generate a random 32+ char string)
@@ -129,16 +127,14 @@ OLLAMA_MODELS=llama3.2
The gateway loads `.env` from the monorepo root via `dotenv` at startup The gateway loads `.env` from the monorepo root via `dotenv` at startup
(`apps/gateway/src/main.ts`). (`apps/gateway/src/main.ts`).
### 4. Prepare the PostgreSQL Schema ### 4. Push the Database Schema
```bash ```bash
mosaic-db-migrator --run pnpm --filter @mosaicstack/db db:push
mosaic-db-migrator --verify
``` ```
KBN-101 makes this the sole PostgreSQL DDL/readiness sequence. Direct schema push is not an This applies the Drizzle schema directly to the database (development only; use
operator workflow; a disposable local experiment is separately guarded and cannot use a migrations in production).
production-like tier or URL.
### 5. Start the Gateway ### 5. Start the Gateway
@@ -359,13 +355,12 @@ The schema lives in a single file:
The `insights` table uses a `vector(1536)` column (pgvector) for semantic search. The `insights` table uses a `vector(1536)` column (pgvector) for semantic search.
### Development: Prepare Schema ### Development: Push Schema
Prepare a PostgreSQL development target through the same sole runner: Apply schema changes directly to the dev database (no migration files created):
```bash ```bash
mosaic-db-migrator --run pnpm --filter @mosaicstack/db db:push
mosaic-db-migrator --verify
``` ```
### Generating Migrations ### Generating Migrations
@@ -381,8 +376,7 @@ This creates a new SQL migration file in `packages/db/drizzle/`.
### Running Migrations ### Running Migrations
```bash ```bash
mosaic-db-migrator --run pnpm --filter @mosaicstack/db db:migrate
mosaic-db-migrator --verify
``` ```
### Drizzle Config ### Drizzle Config
@@ -394,9 +388,9 @@ directory are defined there.
1. Add the table definition to `packages/db/src/schema.ts`. 1. Add the table definition to `packages/db/src/schema.ts`.
2. Export it from `packages/db/src/index.ts`. 2. Export it from `packages/db/src/index.ts`.
3. Generate the offline artifact with `pnpm --filter @mosaicstack/db db:generate`, then run 3. Run `pnpm --filter @mosaicstack/db db:push` (dev) or
`mosaic-db-migrator --run` and `mosaic-db-migrator --verify`. Direct schema push is not a `pnpm --filter @mosaicstack/db db:generate && pnpm --filter @mosaicstack/db db:migrate`
production-like workflow and the migration runner accepts no URL/SQL/schema/role argv. (production).
--- ---

View File

@@ -1,98 +1,147 @@
# Migrating to the Federated Tier # Migrating to the Federated Tier
> **KBN-101-07 ownership:** This active documentation is a **non-operative KBN-101 Step-by-step guide to migrate from `local` (PGlite) or `standalone` (PostgreSQL without pgvector) to `federated` (PostgreSQL 17 + pgvector + Valkey).
> contract** until KBN-101-02/-03/-05/-06 land and KBN-101-08 activates an exact reviewed
> release. The commands below describe the produced interface only. Do not run them on the
> current branch or replace them with direct PostgreSQL, raw SQL, legacy storage migration, or
> credential-on-argv procedures.
## Required activation sequence ## When to migrate
The deployment control plane executes, in order: external privileged bootstrap; verified TLS and Migrate to federated tier when:
roles; `mosaic-db-migrator --run`; `mosaic-db-migrator --verify`; then Gateway readiness. The
runner is the only attestation producer after its verified TLS, identity, manifest, and schema
checks. A data importer is never a schema bootstrap, extension installer, repair command, or DDL
consumer.
## Target material contract - Scaling from single-user to multi-user deployments
- Adding vector embeddings or RAG features
- Running Mosaic across multiple hosts
- Requires distributed task queueing and caching
- Moving to production with high availability
KBN-101-05 obtains the target URL from Vault KV-v2 ## Prerequisites
`secret-{env}/mosaic-stack/database/importer`, key `url`, and reads its authenticated version from
the same successful response `data.metadata.version`. A hash or DSN byte sequence is not a
provider version. The renderer treats URL bytes and provider version as one generation, writes a
temporary generation directory with fsync plus atomic rename, and creates separate immutable
consumer mounts. Swarm uses distinct versioned secret/config references. A deployment cannot mix
generations.
| Consumer | Permitted material | - Federated stack running and healthy (see [Federated Tier Setup](../federation/SETUP.md))
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | - Source database accessible and empty target database at the federated URL
| Migrator-attestation producer (`10003:10003`) | Its own migration URL/CA; read-only `/run/secrets/mosaic-migrate-target-url` and `/run/secrets/mosaic-migrate-target-version`, each `0400`, solely to bind; producer-only attestation output at `/run/mosaic-attestations-producer/migrate-target.v1.json`; root-wrapper-only signing key. It never connects with, uses, exports, logs, or forwards the importer URL/version. | - Backup of source database (recommended before any migration)
| Privileged deployment handoff controller | After runner success and before importer creation, it receives only root-owned non-secret expected provider-version/URL-SHA-256/generation descriptor and pinned public verifier key—not URL bytes or private key. It safe-opens/verifies descriptor and producer artifact, copies exact bytes to a new importer-only mount with fsync/atomic rename, sets `10002:10002` `0400`, seals it read-only, and refuses importer start on any partial/wrong-generation/wrong-owner/mode result. |
| Importer (`10002:10002`) | Its own immutable `0400` copies at the same URL/version paths; CA at exact `DATABASE_TLS_CA_CERT_PATH=/run/secrets/mosaic-db-ca.crt`; pinned Ed25519 public key; read-only `/run/mosaic-attestations/migrate-target.v1.json` supplied only by the sealed handoff. |
| Gateway/runtime/unrelated container | No importer URL/version, importer artifact, attestation private key, or unrelated CA mount. |
The migrator and importer safe-open URL, provider-version, attestation, and public-key files only ## Dry-run first
with `O_RDONLY|O_CLOEXEC|O_NOFOLLOW`; they validate from the opened fd that the file is regular,
has its expected owner/mode and link count one. The migrator digests only that URL fd for binding,
then zeroizes/closes it. The importer reads URL bytes once into protected memory, validates the
signed binding and exact CA before connecting from those same bytes, then zeroizes/closes every
fd. It neither logs nor exposes a URL/version/attestation/key oracle.
## Produced command interface Always run a dry-run to validate the migration:
After activation and only after approved target preparation, the future interface is:
```bash ```bash
mosaic-db-migrator --run
mosaic-db-migrator --verify
mosaic storage migrate-tier --to federated \ mosaic storage migrate-tier --to federated \
--target-url-file /run/secrets/mosaic-migrate-target-url \ --target-url postgresql://mosaic:mosaic@localhost:5433/mosaic \
--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json \
--dry-run --dry-run
``` ```
The provider-version file is fixed deployment material, not argv. This connecting dry-run consumes its nonce; before an actual copy, obtain fresh `mosaic-db-migrator --verify` and a new sealed handoff. The runner uses its migration Expected output (partial example):
identity; the importer connects only as non-DDL `mosaic_data_importer` and only after all
pre-connect validation. After verified TLS and before DML it compares PostgreSQL system ID,
database OID, `current_user`, CA/SPKI, and manifest/schema fingerprints to the artifact.
## Required refusals and evidence ```
[migrate-tier] Analyzing source tier: pglite
[migrate-tier] Analyzing target tier: federated
[migrate-tier] Precondition: target is empty ✓
users: 5 rows
teams: 2 rows
conversations: 12 rows
messages: 187 rows
... (all tables listed)
[migrate-tier] NOTE: Source tier has no pgvector support. insights.embedding will be NULL on all migrated rows.
[migrate-tier] DRY-RUN COMPLETE (no data written). 206 total rows would be migrated.
```
KBN-101-02/-03/-05/-06 must prove, with stable sanitized errors, that no target connection occurs Review the output. If it shows an error (e.g., target not empty), address it before proceeding.
for missing/unsafe URL/version/attestation/public-key files; symlink, hardlink, owner, mode, or
TOCTOU violations; mixed URL/version generations; missing/wrong CA mount; stale/replayed/tampered
or revoked-key artifacts; provider rotation/revocation; wrong TLS/server/database/role/manifest
binding; raw `--target-url`; `DATABASE_URL` fallback; runtime/owner identity; consumer leakage;
or any DDL attempt. Post-connect identity mismatch closes with zero DML/DDL. Tests also prove no
forwarding, child environment, logging, or error oracle leaks URL/version/key/artifact contents.
The attestation is credential-free JCS with detached Ed25519 signature and binds issued/expiry, ## Run the migration
nonce, authenticated provider version, exact URL-fd SHA-256, TLS host/port/database, CA/SPKI,
PostgreSQL system ID/database OID, importer role, manifest/schema, and producer identity. Provider
version rotation invalidates an old artifact and requires a fresh rendered generation plus runner
verification.
## Actual copy after dry-run When ready, run without `--dry-run`:
After reviewed dry-run, obtain the required fresh verification/attestation generation, then use:
```bash ```bash
mosaic-db-migrator --verify
mosaic storage migrate-tier --to federated \ mosaic storage migrate-tier --to federated \
--target-url-file /run/secrets/mosaic-migrate-target-url \ --target-url postgresql://mosaic:mosaic@localhost:5433/mosaic \
--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json \
--yes --yes
``` ```
The dry-run artifact is terminally replayed and must be rejected; `--yes` bypasses no file, The `--yes` flag skips the confirmation prompt (required in non-TTY environments like CI).
generation, signature, TLS, identity, or DDL control.
## Data boundary and recovery The command will:
The importer has only an allowlisted mutable-table DML registry. It has no grant for immutable KBN 1. Acquire an advisory lock (blocks concurrent invocations)
relations, schemas, roles, memberships, extensions, catalogs, or the Drizzle ledger. Source PGlite 2. Copy data from source to target in dependency order
uses its explicit local directory and does not make a PostgreSQL URL fallback valid. 3. Report rows migrated per table
4. Display any warnings (e.g., null vector embeddings)
A failed or ambiguous migration is a control-plane incident: preserve sanitized evidence, retain ## What gets migrated
the approved backup/rollback state, and retry only after independent review. Never inspect,
unlock, repair, or initialize the target with ad hoc SQL or copied credentials. All persistent, user-bound data is migrated in dependency order:
- **users, teams, team_members** — user and team ownership
- **accounts** — OAuth provider tokens (durable credentials)
- **projects, agents, missions, tasks** — all project and agent definitions
- **conversations, messages** — all chat history
- **preferences, insights, agent_logs** — preferences and observability
- **provider_credentials** — stored API keys and secrets
- **tickets, events, skills, routing_rules, appreciations** — auxiliary records
Full order is defined in code (`MIGRATION_ORDER` in `packages/storage/src/migrate-tier.ts`).
## What gets skipped and why
Three tables are intentionally not migrated:
| Table | Reason |
| ----------------- | ----------------------------------------------------------------------------------------------- |
| **sessions** | TTL'd auth sessions from the old environment; they will fail JWT verification on the new target |
| **verifications** | One-time tokens (email verify, password reset) that have either expired or been consumed |
| **admin_tokens** | Hashed tokens bound to the old environment's secret keys; must be re-issued |
**Note on accounts and provider_credentials:** These durable credentials ARE migrated because they are user-bound and required for resuming agent work on the target environment. After migration to a multi-tenant federated deployment, operators may want to audit or wipe these if users are untrusted or credentials should not be shared.
## Idempotency and concurrency
The migration is **idempotent**:
- Re-running is safe (uses `ON CONFLICT DO UPDATE` internally)
- Ideal for retries on transient failures
- Concurrent invocations are blocked by a Postgres advisory lock; the second caller will wait
If a previous run is stuck, check for advisory locks:
```sql
SELECT * FROM pg_locks WHERE locktype='advisory';
```
If you need to force-unlock (dangerous):
```sql
SELECT pg_advisory_unlock(<lock_id>);
```
## Verify the migration
After migration completes, spot-check the target:
```bash
# Count rows on a few critical tables
psql postgresql://mosaic:mosaic@localhost:5433/mosaic -c \
"SELECT 'users' as table, COUNT(*) FROM users UNION ALL
SELECT 'conversations' as table, COUNT(*) FROM conversations UNION ALL
SELECT 'messages' as table, COUNT(*) FROM messages;"
```
Verify a known user or project exists by ID:
```bash
psql postgresql://mosaic:mosaic@localhost:5433/mosaic -c \
"SELECT id, email FROM users WHERE email='<your-email>';"
```
Ensure vector embeddings are NULL (if source was PGlite) or populated (if source was postgres + pgvector):
```bash
psql postgresql://mosaic:mosaic@localhost:5433/mosaic -c \
"SELECT embedding IS NOT NULL as has_vector FROM insights LIMIT 5;"
```
## Rollback
There is no in-place rollback. If the migration fails:
1. Restore the target database from a pre-migration backup
2. Investigate the failure logs
3. Rerun the migration
Always test migrations in a staging environment first.

View File

@@ -522,14 +522,8 @@ mosaic storage export --bucket agent-artifacts --output ./artifacts.tar.gz
# Import data into storage # Import data into storage
mosaic storage import --bucket agent-artifacts --input ./artifacts.tar.gz mosaic storage import --bucket agent-artifacts --input ./artifacts.tar.gz
# Schema migration (compatibility wrapper only; not a tier data copy) # Migrate data between tiers
mosaic storage migrate --run # delegates only to mosaic-db-migrator mosaic storage migrate --from hot --to cold --older-than 30d
# Tier data copy is unavailable until KBN-101 activation. Its only produced interface is:
# mosaic storage migrate-tier --target-url-file /run/secrets/mosaic-migrate-target-url \
# --target-attestation-file /run/mosaic-attestations/migrate-target.v1.json
# (provider version is the fixed paired deployment file, never argv)
# Never use a legacy --from/--to storage-migrate command or pass a credential on argv.
``` ```
--- ---

View File

@@ -1,28 +1,26 @@
# Native Kanban/SOT Canon # Native Kanban/SOT Canon
**Status:** KCR-001016 independently cleared; KBN-101 rc.13 MILESTONES semantic-scan remediation awaits independent exact-head re-review under issue [#771](https://git.mosaicstack.dev/mosaicstack/stack/issues/771) **Status:** KCR-001016 independently cleared; canonical publication is in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
**Date:** 2026-07-14 **Date:** 2026-07-14
**Implementation hold:** no feature implementation starts until this canon is squash-merged to `main` with terminal-green CI; after merge, every slice remains held until its KBN prerequisite graph is satisfied. **Implementation hold:** no feature implementation starts until this canon is squash-merged to `main` with terminal-green CI; after merge, every slice remains held until its KBN prerequisite graph is satisfied.
## Artifacts ## Artifacts
| Artifact | Purpose | | Artifact | Purpose |
| -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Canonical requirements](../requirements/native-kanban-sot.md) | Canonical P0P3 requirements, all seven ratified decisions, fixed invariants, thin MVP, recovery tiers, non-goals, and per-requirement acceptance criteria | | [Canonical requirements](../requirements/native-kanban-sot.md) | Canonical P0P3 requirements, all seven ratified decisions, fixed invariants, thin MVP, recovery tiers, non-goals, and per-requirement acceptance criteria |
| [`MISSION-MANIFEST.md`](./MISSION-MANIFEST.md) | Mission/authority boundaries, exact role chain, gate model, mandatory SecReview triggers, Certifier final/no-merge rule, and collision-free slice ownership | | [`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 P0P3 slices with IN/OUT scope, dependencies, shared contracts, file ownership, evidence, and USC coder2/3/4/5 parallelization | | [`TASKS.md`](./TASKS.md) | Dependency-ordered, bounded P0P3 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.13 PostgreSQL-valid pgvector owner exception; Vault-authenticated importer URL/version generation with fixed isolated consumers/CA and sealed producer-to-importer attestation handoff, runner-signed target-bound attestation, and non-operative SETUP/MILESTONES semantic closure; exhaustive operator-route inventory; exact disjoint card manifests, mechanical DDL classifier, executable `mosaic-db-migrator`, two-gateway TLS/bootstrap, role/search-path, atomic activation, and certification contract; foundation prerequisite of KBN-100 and real-role gate before KBN-105 | | [`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 |
| [`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/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit | | [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures | | [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings | | [`contracts/recovery-posture.v1.ts`](./contracts/recovery-posture.v1.ts) | Provider-neutral shape schema plus normative runtime refinement, cross-field constraints, and Lite/Standard/High-assurance defaults |
| [`contracts/recovery-posture.v1.ts`](./contracts/recovery-posture.v1.ts) | Provider-neutral shape schema plus normative runtime refinement, cross-field constraints, and Lite/Standard/High-assurance defaults | | [`tsconfig.json`](./tsconfig.json) | Strict no-emit project scope for linting and compiling the four frozen TypeScript contracts against the current Stack Drizzle declarations |
| [`tsconfig.json`](./tsconfig.json) | Strict no-emit project scope for linting and compiling the four frozen TypeScript contracts against the current Stack Drizzle declarations | | [`DOCUMENTATION-CHECKLIST.md`](./DOCUMENTATION-CHECKLIST.md) | Publication documentation gate and implementation-slice deferrals |
| [`DOCUMENTATION-CHECKLIST.md`](./DOCUMENTATION-CHECKLIST.md) | Publication documentation gate and implementation-slice deferrals | | [Initial independent review](../reports/native-kanban-sot/canon-initial-review-no-go.md) | KCR-001016 findings that blocked the first draft |
| [KBN-101 exact-head security review](../reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) | Historical `da742ca` REQUEST CHANGES report retained as prior closure evidence; rc.13 awaits independent exact-head re-review after closing the federation-MILESTONES startup-extension HIGH finding | | [Final independent re-review](../reports/native-kanban-sot/canon-final-rereview-go.md) | Closure matrix, reproducible validation evidence, and GO verdict |
| [Initial independent review](../reports/native-kanban-sot/canon-initial-review-no-go.md) | KCR-001016 findings that blocked the first draft | | [Ultron final gate](../reports/native-kanban-sot/ultron-final-go.md) | Final requirements, authority, schema, migration, recovery, decomposition, and evidence review GO |
| [Final independent re-review](../reports/native-kanban-sot/canon-final-rereview-go.md) | Closure matrix, reproducible validation evidence, and GO verdict |
| [Ultron final gate](../reports/native-kanban-sot/ultron-final-go.md) | Final requirements, authority, schema, migration, recovery, decomposition, and evidence review GO |
## Recommended USC lane partition ## Recommended USC lane partition
@@ -34,7 +32,7 @@
| **coder5** | Web | Tasks/Projects Kanban/List/detail and later Coordinator/migration-review UI | | **coder5** | Web | Tasks/Projects Kanban/List/detail and later Coordinator/migration-review UI |
| **Mos** | Serialized integration | Canon publication, frozen-contract changes, shared-root/exports, integration gates, merge authority | | **Mos** | Serialized integration | Canon publication, frozen-contract changes, shared-root/exports, integration gates, merge authority |
The safe order is KBN-010 → KBN-101 foundation → KBN-100 → KBN-101 deployed-role immutable-operation certificate → KBN-105, then coder3 Gateway/MCP server, coder4 CLI/projection, coder5 web, and coder2 recovery can proceed on disjoint files. KBN-100 is blocked on the KBN-101 foundation; real deployed-role certification—not synthetic test roles—is required before KBN-105. coder4 then runs pure Coordinator → importer → cutover tooling serially. No two active slices edit the same files. The safe order is KBN-010 → KBN-100 → KBN-105, then coder3 Gateway/MCP server, coder4 CLI/projection, coder5 web, and coder2 recovery can proceed on disjoint files. coder4 then runs pure Coordinator → importer → cutover tooling serially. No two active slices edit the same files.
## Recovery defaults ## Recovery defaults

View File

@@ -1,254 +0,0 @@
# KBN-101 — Database Runtime/Migration Role Split
**Status:** frozen implementation contract; rc.13 closes the federation-MILESTONES indirect startup-extension HIGH finding, awaiting independent exact-head re-review for [#771](https://git.mosaicstack.dev/mosaicstack/stack/issues/771)
**Version:** 1.0.0-rc.13
**Dependency:** KBN-010 → **KBN-101 foundation** → KBN-100 → **KBN-101 deployed-role certification** → KBN-105
**Scope:** PostgreSQL standalone/federated runtime identity, the sole application DDL runner, TLS bootstrap, readiness, deployment handoff, and evidence. This documentation card changes no database, secret, deployment, CI, runtime, migration, or compose artifact.
## 1. Decision, modes, and non-negotiable boundaries
Current `main` has one `DATABASE_URL` path in `packages/db/src/client.ts`, `migrate.ts`, `drizzle.config.ts`, storage adapters/CLI, Gateway startup, fleet-backlog, CI, installer output, compose, and Portainer. It also has PostgreSQL DDL outside a controlled migration phase: direct Drizzle scripts, `CREATE EXTENSION` probes, a direct-DDL federated integration test, and platform init SQL. None of those current paths is certified by this contract; every implementation card below must close its listed path.
| Item | Exact name / shape | Rule |
| ------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Runtime URL | `DATABASE_URL` | PostgreSQL **non-owner runtime** connection. Required only by runtime services in `standalone`/`federated`; those services never receive `DATABASE_MIGRATION_URL`. |
| Migration URL | `DATABASE_MIGRATION_URL` | Required only by `mosaic-db-migrator`, the dedicated one-shot runner/Job. It is forbidden in Gateway, storage runtime, fleet, and ordinary CLI environments. |
| Runtime DTO | `DatabaseRuntimeConnectionConfigDto` | `tier`, `databaseUrl`, `runtimeMode`; maps only `DATABASE_URL`. It is not constructible from migration configuration. |
| Migration DTO | `DatabaseMigrationConnectionConfigDto` | `tier`, `migrationDatabaseUrl`, `migrationMode`; maps only `DATABASE_MIGRATION_URL`, is accepted only by `mosaic-db-migrator`, and has no runtime-bootstrap import path. |
| TLS DTO | `DatabaseTlsConfigDto` | `caCertificatePath`, `rejectUnauthorized: true`, `serverName`; validates the mounted CA without serializing its bytes. `serverName` is derived only from the validated connection host. |
| Readiness DTO | `DatabaseSchemaReadinessDto` | `state`, `expectedSchemaVersion`, `observedSchemaVersion`, `migrationRequired`, `roleCheck`, `checkedAt`; no URL, user, host, database name, or secret. |
| Modes | `local`, `standalone`, `federated` | `local` is the explicit PGlite-only exception. `standalone` and `federated` are production-like PostgreSQL modes. |
**No fallback or plaintext:** in production-like modes, missing `DATABASE_URL`, `DATABASE_MIGRATION_URL`, or `DATABASE_TLS_CA_CERT_PATH` is a phase-specific error. No command may substitute a runtime URL, config-file URL, default URL, inferred URL, or hard-coded URL. PostgreSQL URLs must use `sslmode=verify-full` with `rejectUnauthorized: true`; `disable`, `allow`, `prefer`, `require`, `no-verify`, an absent CA, a wrong CA, an unverified certificate, or host/SAN mismatch fails before readiness. PGlite uses only its configured local data directory and explicit PGlite routine; it neither reads nor interprets PostgreSQL URL/TLS variables.
**ASSUMPTION K101-A1:** `standalone` and `federated` are the complete current PostgreSQL production-like modes. A future PostgreSQL tier inherits this contract until a versioned amendment names its DNS, secrets, bootstrap, and evidence.
## 2. The sole PostgreSQL DDL control plane
`mosaic-db-migrator` is the **only application/CI/test command that may connect with DDL authority to PostgreSQL**. It owns, in one `max: 1` PostgreSQL session: migration-DTO parse, TLS/identity/search-path preflight, advisory-lock acquisition, manifest/ledger reconciliation, migration execution, postflight/readiness verification, lock release, and session close. It rejects `DATABASE_URL`-only invocation **before opening a connection or emitting DDL**. The only exception is the external privileged platform/IaC bootstrap actor (§4/§7), which is not an application command, never receives either application URL, and executes only the fixed bootstrap artifact.
**Executable contract (KBN-101-03 exclusive):** `packages/db/package.json` publishes the exact mapping `"mosaic-db-migrator": "./dist/cli.js"`; `packages/db/src/cli.ts` compiles to that target. `docker/db-migrator.Dockerfile` builds that package and has the exact image entrypoint `ENTRYPOINT ["mosaic-db-migrator"]`. KBN-101-03 alone owns package/bin/build/pack/discovery tests that execute this compiled bin and image as `mosaic-db-migrator --help|--run|--verify`. The CLI imports only private implementation modules in `packages/db/src/migrator/`; package root exports do **not** expose `runMigrations`, and no other programmatic migration API is public. The sole typed programmatic runner is private to the package/CLI boundary and accepts the typed migration DTO, never a URL/string/SQL/schema/role argument.
The exact user-facing interface is `mosaic-db-migrator --run` and `mosaic-db-migrator --verify`. `--help` is required and is the only discovery mode. Both commands use the fixed packaged migration folder and accept only `DATABASE_MIGRATION_URL`, `DATABASE_TLS_CA_CERT_PATH`, and non-secret `MOSAIC_DATABASE_TIER`/correlation input from the environment. They reject `DATABASE_URL` as fallback and reject URL, SQL, schema, role, database, migration-folder, and identifier arguments in argv. Stable sanitized process exits are: `0` success; `64` configuration; `65` TLS; `66` unsafe identity; `67` ledger/schema; `68` lock contention; `69` migration failure. CI, Compose, Swarm, and each two-gateway migration Job invoke the immutable db-migrator image with exactly `mosaic-db-migrator --run`; their readiness verification invokes exactly `mosaic-db-migrator --verify`. Required tests cover `--help`, argv rejection, runtime-only refusal before connect, migration-only success, each sanitized error/lock exit, and ordered `postgres-a → mosaic-db-migrator-a → gateway-a` plus `postgres-b → mosaic-db-migrator-b → gateway-b` execution.
The implementation must inventory and close every present and future entrypoint as follows:
| Current entrypoint | Required rc.13 disposition |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `packages/db/src/migrate.ts:runMigrations()` | Remove its optional URL, `DATABASE_URL`, and default fallback API from public/runtime exports. Its PostgreSQL behavior moves behind `mosaic-db-migrator`; callers cannot invoke it with an arbitrary URL. |
| `packages/db/src/index.ts` | `KBN-101-03` removes the public `runMigrations` re-export; only the explicit PGlite-local API remains as separately typed local behavior. A compile/import-negative proves `@mosaicstack/db` cannot directly import `runMigrations`; a `DATABASE_URL`-only direct-library attempt fails before connect. |
| `packages/db/drizzle.config.ts` and direct `drizzle-kit migrate` | A database-connecting Drizzle configuration reads only `DATABASE_MIGRATION_URL` through the migration DTO and rejects its absence before connection. Replace direct `drizzle-kit migrate` exposure with `mosaic-db-migrator`. `db:generate` is an offline schema artifact command and must neither resolve nor connect to a URL. |
| `pnpm --filter @mosaicstack/db db:migrate` and CI invocation | Make it a thin `mosaic-db-migrator` wrapper; no direct Drizzle migrator invocation remains. CI supplies an isolated disposable migration URL only to that job. |
| `db:push` / direct `drizzle-kit push` | Forbidden for standalone, federated, CI production-like, Portainer, and any URL outside a disposable developer database. If retained for local experimentation, a wrapper requires `MOSAIC_DISPOSABLE_DEVELOPER_DB=1`, a locally allowlisted disposable target, `DATABASE_MIGRATION_URL`, verified TLS when PostgreSQL is used, and rejects `DATABASE_URL`, any production-like tier, and every non-allowlisted host/database before connection. It is never a release, repair, or migration procedure. |
| `mosaic storage migrate --run` | Delegates only to `mosaic-db-migrator`; it rejects `DATABASE_URL`-only execution before spawning or connecting. Its PGlite form remains explicitly local-only. |
| `PostgresAdapter.migrate()`, Gateway `DatabaseModule`/startup, and PostgreSQL adapter factories | Runtime PostgreSQL migration is removed: no `runMigrations`, DDL, `CREATE EXTENSION`, or migration-compatible handle is reachable from startup. Gateway performs read-only identity, TLS, `search_path`, and manifest-ledger readiness checks only. |
| `packages/mosaic/src/commands/fleet-backlog.ts` | PostgreSQL fleet backlog never migrates or creates tables. It consumes a ready runtime connection; PGlite may use only its explicit local migration routine. |
| `packages/storage/src/{adapters/postgres,tier-detection}.ts` extension work and `infra/pg-init/01-extensions.sql` | Runtime probes become read-only catalog/extension-presence checks. Extension provisioning is a fixed external bootstrap prerequisite or a reviewed runner migration where the migrator has the required scoped authority; it is never a probe side effect or a reason to grant runtime database CREATE. |
| `packages/db/src/federation.integration.test.ts` direct type/table/index DDL | `KBN-101-02` replaces it with a pre-migrated disposable database created by `mosaic-db-migrator`, or makes the test invoke that runner. The test itself has runtime credentials and no direct DDL. |
| `apps/gateway/src/__tests__/integration/federated-pgvector.integration.test.ts` `CREATE TEMP TABLE` | `KBN-101-02` replaces the temporary runtime DDL with a runner-prepared disposable **persistent** `mosaic.federated_pgvector_fixture` database/table. The test receives only its runtime URL/CA and performs read/query-only qualified-vector assertions (including the selected vector operator); it has no setup hook, temporary privilege, or DDL. Runtime `TEMPORARY` remains denied. |
| `docker/init-db.sql` and `infra/pg-init/01-extensions.sql` | `KBN-101-02` retires these duplicate tracked init artifacts; neither may remain as a hidden extension authority. The sole extension action is the fixed external bootstrap artifact described in §4/§5, or the runner only when its reviewed implementation card explicitly grants that authority. |
| `packages/storage/src/{cli,migrate-tier}.ts` operator/runtime command closure | `KBN-101-02` alone replaces raw SQL/DDL behavior with delegation to `mosaic-db-migrator --run` (or the explicit PGlite-local path). Data copy requires the exact paired non-secret argv references `--target-url-file /run/secrets/mosaic-migrate-target-url` and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`, with the paired non-argv authenticated provider-version file `/run/secrets/mosaic-migrate-target-version`. The trusted `mosaic-db-migrator --verify` producer reads those exact importer materials only to bind and attest them; it never uses importer credentials for DDL/DML. Before target connection the importer verifies strict files, signed target attestation, credential-file digest/version, canonical TLS/database binding, role, manifest, expiry, and replay; after verified TLS but before transaction/DML it verifies server/database identity and schema. It opens only a dedicated non-DDL `mosaic_data_importer` target connection. Its DML registry is limited to declared mutable application tables and excludes schemas, roles, memberships, extensions, extension/catalog objects, Drizzle ledger, and immutable KBN relations. Raw `--target-url`, `DATABASE_URL` fallback, runtime-owner/importer role confusion, missing/unsafe/substituted files, stale/replayed/tampered/wrong-key attestation, wrong binding, and any DDL attempt fail before target connection/DDL or copy. KBN-101-07 documents that produced interface but owns neither source file. |
| `tools/federation-harness/docker-compose.two-gateways.yml` | Active topology; `KBN-101-05` migrates it rather than retires it. It must contain `postgres-a`, `postgres-b`, `mosaic-db-migrator-a`, `mosaic-db-migrator-b`, `gateway-a`, and `gateway-b`; each database has separate runtime/migrator URL and CA consumers, TLS server material, verified-TLS readiness, and runner-before-Gateway ordering. Its existing plaintext URLs/init mount are forbidden. |
| `docs/fleet/backlog-conventions.md` | `KBN-101-07` removes the current automatic first-use PostgreSQL `runMigrations()` claim. It points PostgreSQL operators to the sole runner/readiness sequence and labels only the PGlite routine as local-only. A documentation-route `DATABASE_URL`-only test asserts no first-use/migration instruction is executable before connect. |
| `docs/PERFORMANCE.md` | `KBN-101-07` removes direct `drizzle-kit migrate` and Gateway-startup `runMigrations()` instructions. It points to the sole runner/readiness sequence. A documentation-route `DATABASE_URL`-only test asserts neither direct command nor Gateway fallback remains. |
| `README.md`, `CLAUDE.md`, `docs/guides/{admin-guide,dev-guide,deployment,migrate-tier,user-guide}.md`, `docs/federation/{MILESTONES,SETUP,TASKS}.md`, `docs/fleet/backlog-conventions.md`, `docs/PERFORMANCE.md`, `docs/design/storage-abstraction-middleware.md`, and `docs/plans/2026-03-15-agent-platform-architecture.md` documentation hits | `KBN-101-07` owns the non-normative disposition inventory in §2.2. It replaces, retires, or labels status-only every direct `db:migrate`, `db:push`, `CREATE EXTENSION`, first-use migration, Gateway-startup migration, and generic `mosaic storage migrate` instruction with the runner/bootstrap or secure `migrate-tier` route. The architecture plan explicitly marks direct `db:migrate` superseded. Historical shipped-status language is adjacent to an exact non-authorizing KBN-101 note; `docs/federation/MILESTONES.md` is status-only, its former startup-extension wording is superseded and forbidden, and it authorizes no current DDL, Compose/init, or startup path. Documentation is a DDL entrypoint and cannot advertise a bypass. |
| New package scripts, test helpers, setup hooks, CLI commands, installers, CI steps, adapters, or operator docs | A repository check rejects any new PostgreSQL DDL-capable entrypoint or instruction unless it is the dedicated runner or the named external bootstrap artifact. No future script may accept a URL parameter or `DATABASE_URL` as a DDL escape hatch. |
The runner must run the original migration bytes, including shipped `0009`; no migration command may repair a ledger by manual insertion, adoption, `db:push`, or schema diff. Tests requiring PostgreSQL schema consume a pre-migrated disposable database or invoke this exact runner.
`KBN-101-06` alone owns `.woodpecker/ci.yml`, `tools/ci/kbn101-ddl-inventory.ts`, `tools/ci/kbn101-ddl-inventory.spec.ts`, `tools/ci/fixtures/kbn101-ddl-inventory.json`, `tools/ci/kbn101-entrypoint-matrix.ts`, and `tools/ci/kbn101-entrypoint-matrix.spec.ts`. The scanner inventory has exactly the common fields `path`, `class`, `ownerCard`, `disposition`, `allowedTokens`, `rationale`, `expiry`, and `reviewRevision`; each disposition then has its separately required fields. `class` is one of `executable-source`, `script-or-package-bin`, `operator-document`, `deploy-manifest`, or `normative-contract`; `ownerCard` is one KBN-101 card; `disposition` is one of `runner`, `delegate`, `deny`, `retire`, `superseded-document`, `active-secure-data-migration`, `status-only`, or `allowlisted`; and `allowedTokens` is a nonempty subset of the fixed rules below only for `allowlisted` or `superseded-document`. `normative-contract` is excluded from **operator execution**, never from scanning: it is valid only for the exact KBN PRD/contract/shared/task canonical paths, must carry declarative requirements rather than an executable shell instruction, and a fenced executable instruction or an imperative bypass in that class fails rather than being masked. An `active-secure-data-migration` record has empty `allowedTokens` and additionally requires `route`, `targetCredentialOption`, `targetCredentialFile`, `targetCredentialVersionFile`, `targetAttestationOption`, `targetAttestationFile`, `attestationProducer`, `attestationKeyIds`, `attestationBindings`, `targetSchemaPrerequisite`, `targetConnectionRole`, `forbiddenInputs`, and `routeTest`; its parser rejects a missing common or disposition-specific field, a route that accepts a credential value on argv, an attestation missing any canonical binding, or a target that is not runner-prepared/verified. The latter `superseded-document` disposition is allowed only for the exact architecture-plan path when adjacent text says the direct command is superseded/MUST NOT run and names `mosaic-db-migrator --run`. The scanner rejects duplicate path owners, ownerless non-allowlisted rows, missing paths, malformed fields, an inventory path outside its declared class, and every unrecognized active command. The classifier's exact case-insensitive token/rule set is: `runMigrations\\s*\\(`; `drizzle-kit\\s+(?:migrate|push)`; `\\bdb:(?:migrate|push)\\b`; `mosaic\\s+storage\\s+migrate`; `CREATE\\s+(?:TEMP(?:ORARY)?\\s+)?(?:EXTENSION|TABLE|TYPE|INDEX|SCHEMA)`; `ALTER\\s+(?:EXTENSION|TABLE|TYPE|SCHEMA)`; `DROP\\s+(?:EXTENSION|TABLE|TYPE|INDEX|SCHEMA)`; and `DATABASE_URL` when it occurs in the same file as any preceding rule. Independently of those lexical rules, every operator document and deploy manifest receives a path-level semantic assertion that rejects an operative `extension`, `schema`, or `migration` described as `created`, `installed`, or `applied automatically` on `first boot` or `startup`; a `docker compose ... up`/Compose-up/start-first sequence before `mosaic-db-migrator --run` and `--verify`; and any init-script/init-SQL authority. The semantic assertion runs before inventory disposition: a named path, `status-only`, `normative-contract`, or any other record can never suppress it. The fixture includes the former `docs/federation/SETUP.md` wording and the exact former `docs/federation/MILESTONES.md` wording `pgvector extension installed + verified on startup` as failing regressions before inventory disposition can mask either path; the rc.13 non-operative sequence is a passing fixture. It scans exact-path current executable source/scripts/package bins, operator documents, deploy manifests, and named normative contracts; any lexical or semantic hit not represented by a permitted record fails. The matrix harness invokes the compiled runner/package/image and produced deployment artifacts; it edits no producer file.
The canonical inventory includes this active—not historical—operator route record: `path=docs/guides/migrate-tier.md`, `class=operator-document`, `ownerCard=KBN-101-07`, `disposition=active-secure-data-migration`, `allowedTokens=[]`, `route=mosaic storage migrate-tier`, `targetCredentialOption=--target-url-file`, `targetCredentialFile=/run/secrets/mosaic-migrate-target-url`, `targetCredentialVersionFile=/run/secrets/mosaic-migrate-target-version`, `targetAttestationOption=--target-attestation-file`, `targetAttestationFile=/run/mosaic-attestations/migrate-target.v1.json`, `attestationProducer=mosaic-db-migrator --verify`, `attestationKeyIds=[migrate-target-v1-active,migrate-target-v1-overlap]`, `attestationBindings=[credentialSecretVersion,credentialFileSha256,tlsHostPortDatabase,caSpkiFingerprint,postgresSystemIdentifier,databaseOid,expectedImporterRole,manifestFingerprint,schemaFingerprint]`, `targetSchemaPrerequisite=mosaic-db-migrator --verify`, `targetConnectionRole=mosaic_data_importer`, `forbiddenInputs=[--target-url,DATABASE_URL,runtime-owner,signing-key]`, and `routeTest=packages/storage/src/migrate-tier.spec.ts::secureTargetRouteAndAttestation`. The -06 scanner/matrix must reject a changed/missing field, any `--target-url` or credential-bearing argv, a `DATABASE_URL` target fallback, a missing/unsafe/substituted credential or authenticated-version file, mixed URL/version generation, missing/wrong importer CA mount, missing/stale/replayed/tampered/wrong-key attestation, wrong binding, wrong mode/owner/link count, runtime-owner/importer confusion, target connection before attestation verification, or any DDL attempt before target connection/DDL.
### 2.1 Target-bound importer attestation v1
`mosaic-db-migrator --verify` is the trusted producer only after its verified-TLS identity, manifest/ledger, and schema checks pass. Its root-owned launch wrapper is the sole reader of the fixed `MOSAIC_DB_ATTESTATION_SIGNING_KEY_FILE=/run/secrets/mosaic-db-migrate-target-ed25519` reference: that regular, non-symlink `root:root` `0400` Ed25519 private-key file is mounted only to the one-shot runner. It opens the key once, passes signing capability only in memory to `mosaic-db-migrator`, and never mounts, forwards, logs, prints, or otherwise exposes the private key to the importer, runtime, or an application container. KBN-101-05 renders both that runner-only secret and the importer-only pinned public-key/key-ring mount; an alternative env path, unsafe key file, or key material in importer/runtime is a hard failure.
After verification the `10003:10003` migrator atomically emits the credential-free, non-secret producer artifact to its **producer-only** handoff mount `/run/mosaic-attestations-producer/migrate-target.v1.json`: write a same-directory `0600` temporary file, write canonical bytes, `fsync` file and directory, set final `10003:10003` `0400` owner/mode, and rename atomically. A privileged deployment handoff controller runs only after runner success and before importer creation. It receives neither importer URL bytes nor private key: only a root-owned non-secret `0400` renderer generation descriptor (expected provider version, URL SHA-256, generation ID) and pinned public verifier key. It safe-opens/verifies that descriptor and the signed artifact against expected bindings, copies exact bytes into a new importer-only mount, fsyncs file/directory, sets `10002:10002` `0400`, and atomically renames `/run/mosaic-attestations/migrate-target.v1.json`. The controller then seals that importer mount read-only and starts the importer. It never alters payload bytes, forwards URL/version/key material, or exposes either mount to Gateway/runtime/unrelated containers; a controller failure leaves no importer process. The producer and importer never share a writable file or mount. The single JSON envelope carries an RFC 8785 JCS canonical UTF-8 `attestation` payload plus a detached Ed25519 `signature` envelope (`algorithm`, `keyId`, base64 signature); the signature covers only the canonical payload bytes, never a reformatted document. The v1 payload fields are exactly `format`, `version`, `keyId`, `issuedAt`, `expiresAt`, `nonce`, `targetCredentialSecretVersion`, `targetCredentialFileSha256`, `tlsHost`, `tlsPort`, `database`, `caSpkiFingerprint`, `postgresSystemIdentifier`, `databaseOid`, `expectedImporterRole`, `manifestFingerprint`, `schemaFingerprint`, `producerInvocation`, `producerBuildDigest`, `producerImageDigest`, and `correlationId`. It contains no URL/DSN, username, password, private key, or credential bytes. The target credential must be a high-entropy DSN secret because its SHA-256 is non-secret binding evidence, never a substitute secret.
For binding, the renderer obtains the importer URL `url` and its authenticated provider version from the **same successful Vault KV-v2 response** at canonical path `secret-{env}/mosaic-stack/database/importer`: `data.data.url` and `data.metadata.version`. The version is never inferred from DSN bytes, a hash, a filename, or an unverified side channel. The renderer treats exact URL bytes plus this metadata version as one generation and produces separately mounted immutable copies for the migrator-attestation producer and importer consumer. The migrator opens each copy only with `O_RDONLY|O_CLOEXEC|O_NOFOLLOW`, validates from the opened fd with `fstat` that it is a regular file with its exact fixed owner, `0400` mode, and link count `1`, then digests the URL from that fd and reads the authenticated version from its paired fd. It reads these importer-only materials solely to bind/sign; it never connects, performs DDL/DML, forwards, exports, logs, or passes either URL/version to a child environment. It zeroizes protected URL memory and closes both fds after signing. The runner uses `DATABASE_MIGRATION_URL` for its verification connection; it does not use importer credentials for DDL or DML.
`mosaic storage migrate-tier` accepts exactly `--target-url-file /run/secrets/mosaic-migrate-target-url` and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`; the paired authenticated provider-version file is fixed, non-argv `/run/secrets/mosaic-migrate-target-version`. Before **any** target connection it safe-opens the URL, version, attestation, and pinned public-key files with `O_RDONLY|O_CLOEXEC|O_NOFOLLOW`, verifies post-open `fstat` regular-file/exact-owner/`0400`/link-count-one invariants, and validates public key/key ID, JCS signature/canonicality, expiry, nonce replay cache, authenticated provider version, URL-fd digest, canonical host/port/database, the CA file at exact `DATABASE_TLS_CA_CERT_PATH`, expected importer role, and manifest/schema fields. It reads URL bytes once into protected memory, digests/parses/connects from those exact bytes with no reread, then zeroizes memory and closes fds. Missing, substituted, symlinked, hard-linked, wrong-owner, wrong-mode, changed-after-open, mixed-generation, stale, replayed, tampered, wrong-key, revoked, missing-CA, wrong-mount, or wrong-binding inputs return stable sanitized errors with **zero target connection and zero DDL**. The nonce replay cache is durable through the artifact expiry window and atomically claims `(keyId, nonce, credentialFileSha256)` before connecting; a replay is terminal. A connecting dry-run consumes this nonce and MUST obtain fresh `mosaic-db-migrator --verify`, a new artifact handoff, and a new nonce before any `--yes` copy; tests prove the old artifact is rejected. New active and overlap public keys are pinned by key ID; rotation accepts only the bounded overlap, revocation removes a key immediately, and a provider version rotation invalidates the old attestation and requires a freshly rendered generation and runner verification/signature.
Only after verified TLS connection and before transaction/DML, the importer compares server system identifier, database OID, `current_user`, CA/SPKI, and manifest/schema fingerprint with the attestation. A mismatch closes the connection with **zero DML/DDL**. Its grants and statement classifier make DDL impossible. KBN-101-02 tests consumer isolation, absent/wrong CA mount, missing/wrong/stale/replayed/tampered attestation, wrong/revoked key, substituted URL/version file, symlink/hardlink/mode/owner/TOCTOU rejection, mixed URL/version generation, provider rotation/revocation, wrong host/database/CA/role/manifest/system ID, digest/version mismatch, no URL/version/attestation/key logging or error oracle, and both `zero target connection` versus `connection then zero DML` outcomes. KBN-101-03 tests JCS/signing/atomic producer and fd-zeroization/close semantics; KBN-101-05 tests generation-pinned private/public key, URL/version, CA, and artifact mounts/rendering; KBN-101-06 invokes the complete matrix; KBN-101-07 owns the operator guide only.
### 2.2 Complete current documentation disposition inventory
The KBN-101-06 fixture records these exact non-normative paths: `README.md`, `CLAUDE.md`, `docs/federation/MILESTONES.md`, `docs/federation/SETUP.md`, `docs/guides/admin-guide.md`, `docs/guides/dev-guide.md`, `docs/guides/deployment.md`, `docs/guides/migrate-tier.md`, `docs/guides/user-guide.md`, `docs/fleet/backlog-conventions.md`, `docs/PERFORMANCE.md`, `docs/plans/2026-03-15-agent-platform-architecture.md`, and `docs/design/storage-abstraction-middleware.md` as `operator-document`; each is runner/delegate/deny/superseded/active-secure as adjacent text specifies. `docs/federation/MILESTONES.md` and `docs/federation/TASKS.md` are exact `operator-document` `status-only` historical records: their shipped M1 language is superseded and cannot authorize current DDL, extension creation, importer execution, Compose/init, or startup authority. The fixture uses the exact former MILESTONES wording `pgvector extension installed + verified on startup` as a semantic negative and proves it fails before an inventory status-only record can mask it. `docs/PRD.md`, this contract, `SHARED-CONTRACT.md`, and `native-kanban-sot/TASKS.md` are exact `normative-contract` records; their declarative KBN requirements are scan-visible but excluded from operator execution and cannot mask an instruction. The full-doc scanner must fail an unknown active command, a missing record, or a status-only/normative path containing executable bypass guidance.
The only allowlist categories are `historical-sql` (`packages/db/drizzle/**`, byte-immutable runner input only), `pglite-local` (an explicitly PGlite-only source/test path), `negative-test-literal` (a focused negative test), `vendored-generated` (a generated or vendored artifact), and `historical-review-report` (`docs/reports/**` only). Every allowed record names its exact path, token(s), rationale, expiry, and review revision. No allowlist category is valid for an executable current source/script/package bin, operator document, deploy manifest, `README.md`, `CLAUDE.md`, `docs/plans/**`, or `docs/guides/**`; `historical-review-report` cannot contain an operative command. Scanner self-tests place each token in an unowned current path, prove duplicate-owner/ownerless/path-existence failure, and prove an operative `db:migrate` instruction in a path labeled historical fails rather than being masked. The scanner is a classifier plus path inventory and review—not a naive token scan alone—and never by itself proves DDL authority. For every non-allowlisted inventory row and `gateway-a`/`gateway-b`, the matrix proves `DATABASE_URL`-only, missing migration URL, direct library import, direct Drizzle, `db:push`, init artifact, operator route, runtime-only fixture/test, and each harness migration Job/Gateway fail before connection/DDL as applicable. The `db:push` negatives also cover production-like tier and production-like URL rejection.
## 3. Exact migration manifest, ledger, and lock
### 3.1 Manifest v1
The runner generates and verifies a source-controlled **migration manifest v1** from the shipped Drizzle journal and SQL files. A record has exactly:
```text
logicalIndex: non-negative integer from journal array position
journalTag: exact journal `tag` string
migrationSha256: lowercase SHA-256 of the exact migration `.sql` file bytes
```
The canonical SQL bytes are the raw Git blob bytes at the signed source-release commit, not workstation checkout bytes. KBN-101-03 adds/validates an LF-pinning `.gitattributes` rule for `packages/db/drizzle/**/*.sql`, generates the source-controlled manifest from those canonical blobs in CI, and makes the runner verify deployed file bytes against the manifest before DDL. No newline, Unicode, whitespace, SQL, or line-ending normalization is applied. Manifest canonical serialization is UTF-8 bytes of:
```text
mosaic-drizzle-manifest-v1\n
<logicalIndex>\t<JSON.stringify(journalTag)>\t<migrationSha256>\n
... in ascending logicalIndex with no omitted index
```
`manifestSha256` is SHA-256 of those canonical bytes. The manifest has no timestamp-derived ordering; `folderMillis`, legacy ledger `id`, and `created_at` are diagnostic only. KBN-101-03 corrects journal **logical** order metadata (including the current `0008`/`0009` anomaly) without changing shipped `0009` bytes.
The runner stores certification in `drizzle.__mosaic_migration_manifest` with exactly one active v1 row (`manifest_version=1`, `manifest_sha256`, `certified_at`). It is owned by `mosaic_schema_owner`; only the migrator after `SET ROLE mosaic_schema_owner` may insert/update it; runtime gets `SELECT` only, and `PUBLIC` gets no schema/table privilege. Existing databases receive this table through the runner after backup/preflight; it is not created by Gateway, a test, or manual SQL.
### 3.2 Reconciliation and 0009 transition
The expected ledger is the ordered list of v1 manifest tuples. The runner reads every observed `drizzle.__drizzle_migrations.hash`, maps **each observed hash to exactly one** manifest tuple, and rejects a missing, unknown, duplicate, ambiguous, corrupt, or stale-replica mapping. Equality is tuple-complete: every expected tuple occurs once, no additional tuple occurs, and the manifest digest matches. A count comparison or hash-set comparison is forbidden. Physical legacy insertion `id`, insertion timestamp, and order are expressly non-normative.
For an existing database:
1. take the KBN-101-07 approved backup and capture a read-only inventory before changing anything;
2. if `0009` is missing **and** its effects are absent, run the original shipped `0009` through `mosaic-db-migrator` and record it normally;
3. if the `0009` hash is present but physically late, accept it when its one-to-one tuple mapping is exact;
4. if an expected hash is missing while its effects are partial or complete, or catalog/ledger evidence conflicts, fail closed as `DATABASE_MIGRATION_RECONCILIATION_AMBIGUOUS`. Recovery is backup restoration or an explicit separately reviewed repair artifact with its own owner, tests, backup, rollback, and approval—not manual ledger insertion/adoption; and
5. write/update the v1 certification row only after exact reconciliation and final catalog/readiness verification.
Required runner tests cover clean, pre-0009, skipped-0009/effects-absent, applied-late, duplicate, unknown, missing, corrupt tuple-pair, partial/full-effect ambiguity, stale replica, backup/restore, and rerun idempotence. The tests prove the `0009` SQL bytes are unchanged and logical journal ordering—not physical ledger order—controls reconciliation.
### 3.3 Fixed advisory-lock namespace
Before any preflight that can decide migration state, `mosaic-db-migrator` acquires `pg_try_advisory_lock(1297044289, 1262636593)`. The constants are signed-int4-safe fixed namespace values: class `1297044289` (`MOSA`) and object `1262636593` (`KBN1`). The same `max: 1` session retains it for preflight, reconcile, migrate, verify, release, and close. Failure returns `DATABASE_MIGRATION_LOCKED` immediately; a process crash releases it when the PostgreSQL connection closes. Runtime readiness remains unready while a holder is active and never waits by running migrations. Tests prove concurrent contention, crash/connection-loss release, readiness while held, and non-interference from an unrelated advisory key.
## 4. PostgreSQL roles, identifiers, and trusted sessions
Role creation, passwords, membership, database ownership, certificates, and Vault values are platform/IaC/operator work—not Drizzle/application migrations. Application SQL must not issue credential/role management statements or embed credentials.
| Role | Attributes and ownership | Membership / session use |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `mosaic_platform_database_owner` | `NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`; platform-only database owner after bootstrap. | Never granted to application roles. |
| external platform bootstrap actor | Provider/operator/IaC-controlled, externally audited **superuser** identity outside the Mosaic role graph and Vault/application configuration. | Creates/transitions the database and roles, then retires from application use. It alone `SET ROLE`s the extension owner for `CREATE EXTENSION`, `ALTER EXTENSION ... UPDATE`, or `ALTER EXTENSION ... SET SCHEMA`, records the action, and `RESET ROLE`s. |
| `mosaic_schema_owner` | `NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`; owns only `mosaic`/`drizzle` schemas and application/ledger objects. It has only `USAGE` on `mosaic_extensions` for fixed legacy type resolution. | Never an application login; no ownership, `CREATE`, `ALTER`, `DROP`, extension/member-change, or default-privilege authority in `mosaic_extensions`; its migrator subphase never receives temporary `CREATE` there. |
| `mosaic_extension_owner` | Dedicated `NOLOGIN SUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS` extension owner, distinct from platform/schema/migrator/runtime. It is used solely to own/maintain untrusted `vector`, `mosaic_extensions`, and every owner-bearing extension member there. | `rolcanlogin=false`, `rolsuper=true`, and **zero members** are catalog-proven. No application role has membership, `SET ROLE`, credential, or inheritable grant. An externally controlled, audited platform-bootstrap **superuser** session alone executes `SET ROLE mosaic_extension_owner` for fresh creation, approved-owner update/relocation, or shadow bootstrap, then `RESET ROLE`; no persistent membership is ever granted. |
| `mosaic_migrator` | `LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`. | Only `mosaic_schema_owner`; runner verifies `session_user=mosaic_migrator`, `SET ROLE mosaic_schema_owner`, then `current_user=mosaic_schema_owner`. |
| `mosaic_data_importer` | `LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`; dedicated data-copy identity, not a DDL or schema owner. | It is used only after runner preparation/verification through the KBN-101-02 file-reference interface. It has no owner/migrator/extension membership and cannot `SET ROLE mosaic_extension_owner`, or `ALTER`/catalog-or-extension-member `UPDATE`/`DROP`/change extension membership. Its bounded data-copy DML is not extension authority. |
| `mosaic_runtime_capability` | `NOLOGIN` and no ownership/administrative attributes. | Holds only named runtime grants. |
| `mosaic_runtime` | `LOGIN INHERIT` with no ownership/administrative attributes. | Only `mosaic_runtime_capability WITH INHERIT TRUE, SET FALSE, ADMIN FALSE`; never owner/migrator member. |
The fixed application/runtime schema is **`mosaic`**. Every application/runtime pooled connection executes and verifies exactly `SET search_path TO pg_catalog, mosaic` before its first application query; connection checkout repeats this after reset/reconnect. Transactional application operations use `SET LOCAL search_path TO pg_catalog, mosaic` and verify it before query execution. `public` and `$user` are forbidden in all runtime paths.
The sole runner has one narrowly bounded **legacy-history bootstrap subphase** for the immutable current journal: before it runs, external bootstrap revokes `CREATE` on `public` from `PUBLIC`, temporarily assigns its ownership to `mosaic_schema_owner`, and denies all runtime connections. The externally controlled audited bootstrap-superuser session first executes `SET ROLE mosaic_extension_owner` to create/own `mosaic_extensions` and fresh `vector`, or to perform approved-owner relocation/update, records that control-plane action, and executes `RESET ROLE`; it receives no membership because a superuser can assume the role without one. In its locked `max:1` migration session only, after `SET ROLE mosaic_schema_owner`, the runner has only `USAGE` (not ownership or `CREATE`) on `mosaic_extensions` and uses the fixed legacy-only `SET LOCAL search_path TO pg_catalog, public, mosaic_extensions` solely so immutable `0001` resolves its unqualified existing `vector` type. It cannot create, alter, drop, reassign, or change a member there. The preflight catalogs `pg_namespace.nspowner`, schema ACL/default ACLs, `pg_extension.extowner`, and member owners, then directly proves `CREATE`/`ALTER`/`DROP`/member-change denial for runtime, migrator, and schema owner. After relocation/catalog verification and before manifest certification or any runtime readiness, the runner transfers `public` ownership to `mosaic_platform_database_owner`, revokes application `USAGE`/`CREATE`, and restores `pg_catalog,mosaic`. This compatibility subphase is not a runtime/operator option, accepts no configuration identifier, has no fallback, and is removed/disabled before KBN-101-08.
`KBN-101-03` exclusively owns `packages/db/src/schema.ts`, `packages/db/drizzle/meta/*`, `packages/db/drizzle/meta/_journal.json`, the generated relocation migration, and exact database/Drizzle tests. It freezes one exported `export const mosaic = pgSchema('mosaic')`; every application `pgTable` and every application `pgEnum` must be declared through that export. The generated snapshot/journal and future `db:generate` output must target `mosaic` only; a static declaration/SQL test rejects a default-schema application `pgTable`/`pgEnum`, `public` application declaration, or future generated application DDL outside `mosaic`. Historical `0000` through current SQL and the shipped journal provenance are byte-immutable and execute only in the trusted legacy-`public` subphase; they are never rewritten to claim they originally targeted `mosaic`.
### 4.1 pgvector ownership and supported transition
The selected extension policy is fixed for PostgreSQL 17 + pgvector 0.8.2. Its actual target-image `vector.control` evidence must show `relocatable = true` and no `trusted = true` or `superuser = false` setting: `vector` is therefore untrusted and creation requires superuser authority. Fresh databases: the external audited bootstrap-superuser session executes `SET ROLE mosaic_extension_owner`, creates and owns `mosaic_extensions`, then creates `vector` as `CREATE EXTENSION vector WITH SCHEMA mosaic_extensions`; it records the control-plane action and executes `RESET ROLE`. Under that role it revokes default `TABLES`, `SEQUENCES`, `FUNCTIONS`, `TYPES`, and `SCHEMAS` privileges from `PUBLIC`, `mosaic_schema_owner`, `mosaic_migrator`, `mosaic_data_importer`, and `mosaic_runtime`, then grants only the explicitly required read/type/function privileges. It validates `rolcanlogin=false`, `rolsuper=true`, zero role members, `pg_namespace.nspowner`, `pg_extension.extowner`, and owner-bearing extension-member ownership/schema/version (ownerless PostgreSQL catalog member classes are verified as ownerless, never falsely assigned), schema/default privileges, `RESET ROLE`, and the external audit record. Approved-owner update/relocation is likewise performed only by that external session while set to `mosaic_extension_owner`; the existing path is eligible only when `extowner` is already exact. `mosaic_extensions` remains non-writable by runtime, migrator, schema owner, importer, and every service role. They have no membership in `mosaic_extension_owner` and must fail catalog assertions plus `SET ROLE`, `CREATE`/`ALTER`/`DROP EXTENSION`, extension-member `UPDATE`/DDL, and role-membership change denials. Shadow migration and rollback repeat these owner/default-privilege/preflight assertions before copying, before atomic switch, after resume, and before read-only rollback.
PostgreSQL has **no supported** `ALTER EXTENSION ... OWNER TO`. No card may invent it, mutate system catalogs, use `DROP ... CASCADE`, or adopt legacy extension ownership. An existing `vector` is eligible for the clean in-place path only when `pg_extension.extowner` already resolves to the approved `mosaic_extension_owner`; after exact supported-version, `extrelocatable=true`, dependency, and complete expected member-set checks, the bootstrap actor may perform the tested `ALTER EXTENSION vector SET SCHEMA mosaic_extensions`. The same postflight verifies `extowner`, each member owner/schema, version, dependency inventory, and denial for runtime/migrator/schema owner.
A `vector` extension owned by a legacy runtime/single login is **not activated in place**. It fails closed before activation and requires this controlled shadow-database migration: (1) approved backup and read-only inventory; (2) create new database/roles; (3) the audited external bootstrap-superuser session `SET ROLE mosaic_extension_owner`, creates `mosaic_extensions`/`vector`, records the audit event, and `RESET ROLE`; (4) sole runner applies migrations and relocation; (5) copy data with vector dimensions, row counts, checksums, FK, and sequence evidence; (6) quiesce/drain writers; (7) apply and verify final delta; (8) prove role/TLS/readiness; (9) atomically switch connections; and (10) retain the old database read-only for the approved rollback window. Partial/cancel/resume and rollback preserve the old read-only source until the switch; no target unable to shadow-migrate is eligible until separately reviewed. Required tests cover fresh, approved-owner clean existing relocation, legacy-owner shadow path, partial/resume/rollback, N-1, exact `extowner`/member/schema/version assertions, and `SET ROLE`/ALTER/DROP/member-update denials for runtime, migrator, schema owner, importer, and all service roles.
**Superuser exception and threat boundary:** PostgreSQL superuser authority cannot be privilege-limited by `GRANT`/`REVOKE`; this is deliberately **not** a least-privilege claim for `mosaic_extension_owner`. The containment is its dedicated identity, `NOLOGIN`, zero membership, absence from runtime credentials/Vault/application containers, external control-plane-only audited use, and independent review. Any extension create/update/schema operation is a control-plane change requiring independent review, approved backup/rollback, maintenance window, and audit evidence. A managed target that cannot establish this exact `NOLOGIN SUPERUSER`, zero-member, externally controlled role is ineligible until an independently approved, versioned provider-owned extension-owner profile exists; it must not silently retain app/migrator ownership.
`schema.ts` must emit `mosaic_extensions.vector(...)`, and all vector casts/operators/functions in runner, application queries, fixtures, and generated SQL must explicitly qualify `mosaic_extensions` (for example `OPERATOR(mosaic_extensions.<->)`); `mosaic_extensions` is deliberately **not** added to runtime `search_path`.
Before relocation, the runner records a parameterized catalog inventory and dependency graph. It must classify, in dependency order, application enum/domain/base types; owned and identity sequences; application tables; table columns/defaults; functions/procedures; views/materialized views; extension-owned objects; then triggers, constraints/FKs, indexes, and dependent rules/policies. It moves base types, sequences, tables, and functions as required; OID-bound constraints/indexes follow their owning objects and are catalog-verified rather than recreated blindly. Every object must be expected exactly once and be in either the immutable legacy/bootstrap allowlist, the `mosaic` application allowlist, or the selected `mosaic_extensions` extension membership; unknown, extra, duplicate, cross-schema, dependency-cycle, or partial-resume state fails closed. No raw client-side identifier interpolation is allowed.
`KBN-101-00` owns bootstrap-role/schema/extension/default-privilege and direct-denial tests. `KBN-101-03` owns the runner integration tests that consume those bootstrap fixtures: approved-owner existing relocation, legacy-owner shadow migration, interrupted/partial relocation resume, partial/cancel shadow resume, and pre-activation reverse rollback-before-activation. Reverse rollback is allowed only before KBN-101-08 and restores the approved backup or the reviewed inverse relocation, never a runtime `search_path` bypass. Its N-1 order is: current legacy release → inactive bootstrap/runner and `mosaic` declarations → catalog relocation/generated-artifact verification or approved shadow migration → verified non-owner runtime activation. The combined evidence includes byte-immutable historical execution, no public application objects/declarations/future SQL, vector type/cast/operator query success under fixed `pg_catalog,mosaic`, exact extension-owner/member/schema/version assertions, catalog and direct ALTER/DROP/member-change denials for runtime/migrator/schema owner, and all extension eligibility negatives.
No SQL identifier may come from URL/config/environment/operator input. Catalog comparisons use parameter values. The fixed identifiers above are constants; the external bootstrap artifact alone may use server-side `format('%I', fixed_allowlisted_identifier)` after allowlist validation. Raw client-side interpolation for identifiers, `SET search_path`, database, schema, role, table, or extension names is forbidden. Tests include injection-shaped values, a poisoned pooled-session reset, and transaction `SET LOCAL` restoration negatives.
External bootstrap executes `REVOKE CONNECT, TEMPORARY ON DATABASE <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.
## 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. |
| 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.
The server leaf SANs are frozen to actual connection DNS names, not a configurable alias:
| topology | PostgreSQL service DNS names that must be SANs | Gateway leaf SANs / consumers |
| ------------------------------------ | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| standalone compose | `DNS:postgres`, `DNS:localhost` (only for the documented host-port disposable test path) | `DNS:gateway`; its Gateway process consumes runtime URL + CA + Gateway leaf/key only |
| federated compose | `DNS:postgres-federated`, `DNS:localhost` (only for the documented host-port disposable test path) | `DNS:gateway-federated`; runtime/migrator consume only their database-specific material |
| Portainer/Swarm federated test stack | `DNS:postgres` (the in-stack service endpoint used by Gateway and migrator) | `DNS:gateway`; same consumer isolation |
| two-gateway harness | `DNS:postgres-a`, `DNS:postgres-b` | `DNS:gateway-a`, `DNS:gateway-b`; each Gateway gets only its own runtime URL, CA, and Gateway leaf/key; each `mosaic-db-migrator-{a,b}` gets only the matching migration URL and CA |
A new topology requires a versioned amendment before issuance. The PostgreSQL container activation artifact sets `ssl=on`, `ssl_cert_file`, and `ssl_key_file` to those paths, uses a locked-down `postgresql.conf` include, and verifies file ownership/mode before start. The server health/readiness gate makes a `verify-full` CA/SAN-validated connection as the approved runtime/migrator identity; `pg_isready` alone is insufficient. Migration Job starts only after server TLS readiness. Gateway replicas start only after a successful runner result and independently pass verified-TLS, identity, search-path, and ledger readiness. In the two-gateway harness this ordering occurs independently as `postgres-a → mosaic-db-migrator-a → gateway-a` and `postgres-b → mosaic-db-migrator-b → gateway-b`; no Gateway starts against its database before its own runner certificate succeeds.
**Fresh DB:** provision CA/leaf/secrets and server TLS configuration before initial database bootstrap; bootstrap/extension prerequisites run, then the runner migrates over verified TLS, then runtime deploys. **Existing DB:** take the approved backup, provision/mount TLS material, and **drain/scale to zero every N-1 runtime, worker, CLI maintenance process, and replica before TLS enforcement**. Enable server TLS, terminate any residual non-TLS PostgreSQL backend sessions, set `pg_hba.conf` to `hostssl` for all application/migrator CIDRs with no matching `host` rule, reload/restart as required, and prove the non-TLS session count is zero. Only then prove `verify-full` through the existing endpoint, run reconciliation/migration, and roll the non-owner TLS runtime. There is no plaintext transition interval.
**Rotation/rollback:** stage a CA bundle containing old+new trust roots to runtime/migrator, validate a new server leaf with exact SANs, restart PostgreSQL and validate it, roll consumers, then remove the old root only after evidence. Credential rotation remains independent and never mounts migration material into Gateway. Before expiry, rollback restores the prior known-valid leaf/key and overlapping CA bundle, restarts PostgreSQL, enforces `hostssl`, terminates residual non-TLS sessions, and verifies `verify-full`; it never downgrades `sslmode`, restores a plaintext-only N-1 runtime after enforcement, or accepts plaintext. A pre-enforcement abort may restore the backed-up N-1 state only before `hostssl` is enabled and is recorded as an aborted—not activated—release. The runbook records expiry windows, secret versions, backup ID, drained-service/session evidence, activation actor, and validation result—not secret values.
Required disposable tests cover standalone compose, federated/Swarm, and the two-gateway harness positives using verified TLS, plus for **both** gateway/database pairs: missing CA, wrong CA, wrong PostgreSQL SAN, wrong Gateway SAN, `sslmode` downgrade, missing/mispermissioned server or Gateway key, runtime-with-migrator-secret, cross-pair secret leakage, rendered secret-consumer/UID/GID/mode isolation, legacy plaintext drain/termination/`hostssl` enforcement, and readiness-before-migration negatives. PGlite local tests are explicitly classified as non-PostgreSQL and do not satisfy a PostgreSQL TLS test.
## 6. Runtime verification and sanitized failures
Before accepting traffic, runtime queries only parameterized/sanitized identity and privilege metadata on its already-open verified-TLS connection. It fails closed for owner/migrator identity or assumability; superuser/CREATEROLE/CREATEDB/BYPASSRLS; object/schema/database ownership; TEMPORARY; unexpected function execute; missing inherited capability/sequence/ledger grants; any non-exact search path; wrong schema/database target; bad TLS; or manifest/ledger mismatch. After KBN-100 it also checks every immutable grant/denial and relation presence.
The runner performs reciprocal preflight under the same session/lock: dedicated migration DTO only, verified TLS, exact allowlisted target, migrator `session_user`, schema-owner `current_user`, exact trusted search path, and no unsafe attributes. It fails before DDL otherwise.
Stable sanitized codes are `DATABASE_RUNTIME_URL_REQUIRED`, `DATABASE_MIGRATION_URL_REQUIRED`, `DATABASE_TLS_REQUIRED`, `DATABASE_TLS_VERIFICATION_FAILED`, `DATABASE_ROLE_UNSAFE`, `DATABASE_ROLE_GRANT_MISMATCH`, `DATABASE_SEARCH_PATH_UNSAFE`, `DATABASE_SCHEMA_MISMATCH`, `DATABASE_MIGRATION_RECONCILIATION_AMBIGUOUS`, `DATABASE_MIGRATION_LOCKED`, `DATABASE_MIGRATION_IDENTITY_UNSAFE`, `MIGRATE_TARGET_ATTESTATION_REQUIRED`, `MIGRATE_TARGET_ATTESTATION_INVALID`, `MIGRATE_TARGET_ATTESTATION_EXPIRED`, `MIGRATE_TARGET_ATTESTATION_REPLAYED`, `MIGRATE_TARGET_BINDING_MISMATCH`, `MIGRATE_TARGET_FILE_UNSAFE`, `MIGRATE_TARGET_GENERATION_MISMATCH`, `MIGRATE_TARGET_CA_REQUIRED`, and `MIGRATE_TARGET_CONSUMER_ISOLATION`. Logs/metrics may contain code, tier, manifest fingerprint, role class, and correlation ID only; never DSN, username, host, database name, SQL parameter, secret, or raw catalog result. External health exposes only unavailable/not-ready.
## 7. Safe DAG, activation, and rollback authority
Every KBN-101 card remains one PR with exclusive ownership. Cards `00``07` may merge only as **prepared, inactive capability**: no current owner-runtime deployment consumes their image/config, and no compatibility switch is exposed to a runtime operator. They must not retain `ALLOW_LEGACY_*`, runtime DDL, `DATABASE_URL` migration fallback, plaintext TLS, direct Drizzle, or test-only bypass flags. Current owner-runtime deployments remain on their known N-1 release until final activation.
| Card | Depends on | Complete, disjoint file/glob manifest and required test/evidence paths |
| ----------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `KBN-101-00` platform bootstrap / IaC | contract | **Only:** `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, `infra/pg-bootstrap/tests/**`. It creates the extension-owner role/schema/extension interface and proves fresh, approved-owner, legacy-shadow, catalog/default-privilege, and direct-denial bootstrap cases. No renderer, runner, Compose, CI, or deployment path. |
| `KBN-101-01` typed runtime config and verifier | 00 | **Only:** `packages/config/src/index.ts`, `packages/config/src/mosaic-config.ts`, `packages/config/src/mosaic-config.spec.ts`; `packages/db/src/client.ts`, `packages/db/src/defaults.ts`, `packages/db/src/connection-identity.ts`, `packages/db/src/client.spec.ts`, `packages/db/src/defaults.spec.ts`, `packages/db/src/connection-identity.spec.ts`; `apps/gateway/src/database/database.module.ts`, `apps/gateway/src/database/database.module.spec.ts`. It supplies runtime/migration/TLS DTO parsing plus runtime identity/search-path/readiness verification. No migrator, storage, installer, deploy, or CI path. |
| `KBN-101-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 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/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 and documents the interfaces produced by -02/-03/-04/-05, including both file references, signing-key isolation, attestation bindings, rotation/replay, and no-connection/no-DML errors; 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. Its semantic suite fails former `SETUP.md` automatic-first-boot/startup and the exact former `MILESTONES.md` wording `pgvector extension installed + verified on startup` before an inventory record can mask either path; it also fails Compose-up-before-runner, extension/schema/migration automatic wording, and init-script authority. It passes the rc.13 external-bootstrap → TLS/roles → `mosaic-db-migrator --run``--verify` → Gateway-readiness sequence. 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; the KBN-101-06 inventory test fails on overlap, an ownerless in-scope path, or a nonexistent declared path. Cards `00``07` are prepared artifacts, not independently deployed releases: the immutable N-1 owner-runtime image stays live until KBN-101-08 control-plane atomic activation. No activation card edits a source-changing path, and no runtime bypass or broken deployed intermediate exists.
**Authority:** Mosaic control plane/Jason is the sole activation and rollback authority. CI, Gateway, migrator, Coordinator, and Certifier cannot activate, waive a red result, or force release. Before an incompatible KBN-100 switch, the authority stops/scales runtime, uses the approved backup/restore or separately reviewed runner artifact, restores only a known TLS-compatible runtime with its runtime secret after `hostssl` enforcement, and verifies no plaintext sessions plus TLS/readiness. Migration URL is never injected into Gateway to enable rollback. KBN-100 starts only after KBN-101-08; KBN-105 starts only after KBN-101-09.
**ASSUMPTION K101-A2:** every eligible production-like deployment can schedule a dedicated migration Job/one-shot command and an operator/IaC-controlled TLS bootstrap. A target that cannot do both is ineligible for KBN certification.
## 8. Acceptance traceability
| Requirement / acceptance criterion | Required implementation evidence |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| K101-REQ-01 / AC-K101-01 | DTO/command matrix covers local/PGlite, standalone, federated, and both harness pairs; every finite classified §2 path rejects `DATABASE_URL`-only before connection/DDL; no runtime fallback/default; `--help`, argv, import-compile, and live-operator-route negatives pass. The active migrate-tier route proves runner-produced JCS/Ed25519 attestation, root-owned signing/private-key and importer-public-key isolation, Vault KV-v2 `secret-{env}/mosaic-stack/database/importer` URL plus same-response authenticated `data.metadata.version`, separate immutable renderer generations, importer CA, exact `--target-url-file /run/secrets/mosaic-migrate-target-url` plus version file and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`, safe-fd/digest/TLS/server/database/role/manifest/schema bindings, expiry/replay/provider-rotation/revocation/TOCTOU checks, bounded non-DDL importer DML, and consumer-isolation/no-log-oracle plus no-connection versus zero-DML refusal. |
| K101-REQ-02 / AC-K101-02 | KBN-101-03 one-session fixed two-int lock, `--run`/`--verify` exit-code, contention/crash/readiness/unrelated-key tests; manifest-v1 canonical bytes/digest and all reconciliation states; Gateway/replica DDL impossibility. |
| K101-REQ-03 / AC-K101-03 | KBN-101-00 bootstrap schema/extension-owner/default-privilege and direct-denial proof against actual PostgreSQL 17 + pgvector 0.8.2 control metadata (`trusted` absent/untrusted, `relocatable=true`); external-superuser `SET ROLE` create/update/`RESET ROLE` audit proof; `rolcanlogin=false`, `rolsuper=true`, zero members/no runtime credential proof; KBN-101-03 catalog relocation and future-Drizzle-only-`mosaic` proof plus approved-owner/shadow runner integration; `pg_namespace.nspowner`, `pg_extension.extowner`, owner-bearing member/schema/version, and runtime/migrator/schema-owner/importer/all-service-role catalog plus `SET ROLE`/ALTER/DROP/member-update denials; KBN-101-01/03 role, path, TEMP, ledger/default-grant, and pool-reset tests. |
| K101-REQ-04 / AC-K101-04 | KBN-101-00 bootstrap-interface and KBN-101-05 renderer/deployment tests; immutable-image exact Job commands and runner-before-readiness order; fresh/existing verified-TLS Compose/Swarm/two-gateway positives; both-pair CA/SAN/downgrade/key-permission negatives; exact UID/GID/mode and runtime/migrator secret-consumer rendering/CI negatives. |
| K101-REQ-05 / AC-K101-05 | KBN-101-09 after KBN-100: real deployed runtime INSERT/SELECT success and UPDATE/DELETE denial for each frozen relation, with RESTRICT retention evidence. |
| K101-REQ-06 / AC-K101-06 | KBN-101-00…08 prepared-card/no-intermediate-deploy evidence; one final activation authority record; N-1 drain/zero-plaintext-session/`hostssl`, backup/restore, CA overlap rotation, TLS-only rollback, Vault/redaction, and no-force-on-red evidence. |
| K101-REQ-07 / KBN sequence | KBN-101-08 foundation certificate before KBN-100, KBN-101-09 real immutable-role certificate plus Ultron approval before KBN-105; KBN-100 rebases/restores Drizzle consistency and never bypasses the serial gates. |
| Delivery integrity | One-card/one-PR DAG, exact file ownership, docs/link/contract checks, independent author≠reviewer re-review on the pushed exact head, and terminal-green CI for implementation cards. |
## 9. Non-goals and residual authority
KBN-101 planning does not create roles, certificates, Vault paths, migrations, deployment artifacts, or a deployed certificate. It does not replace KBN-100s data migration, immutable retention, tenant constraints, or KBN-105 endpoint freeze. A PostgreSQL superuser/break-glass operator—including the deliberately isolated extension owner—remains outside application containment: `GRANT`/`REVOKE` cannot constrain that authority. Separate identity/non-login/zero-membership/external-control/audit controls, independent review, maintenance windows, backup/rollback evidence, and drills are mandatory.

View File

@@ -1,76 +1,14 @@
# Native Kanban/SOT — Remediated Shared Contract v1 # Native Kanban/SOT — Remediated Shared Contract v1
**Status:** CONTROL-PLANE rc.13 KBN-101 federation-MILESTONES semantic-scan remediation complete; awaiting independent exact-head re-review. Prior KCR-001016 and rc.4 SI-001 decisions retained; KBN-101 foundation certification precedes KBN-100 and real immutable-operation certification precedes KBN-105 **Status:** CONTROL-PLANE SI-001 AMENDMENT AUTHORIZED; prior KCR-001016 independent-review GO retained; rc.4 requires independent schema/SecReview before KBN-100
**Version:** 1.0.0-rc.13 **Version:** 1.0.0-rc.4
**Date:** 2026-07-15 **Date:** 2026-07-14
**Change authority:** Mosaic control plane/Jason only **Change authority:** Mosaic control plane/Jason only
**SI-001 amendment authority:** `web1:mosaic-100` control-plane decision under issue #753 **SI-001 amendment authority:** `web1:mosaic-100` control-plane decision under issue #753
## Amendment record ## Amendment record
### 1.0.0-rc.13Federation-MILESTONES indirect-startup closure ### 1.0.0-rc.4KBN010-SI-001
- **Complete operator inventory:** `docs/federation/MILESTONES.md` is exclusively KBN-101-07 and an exact KBN-101-06 `operator-document` `status-only` record. Its former `pgvector extension installed + verified on startup` wording is superseded and forbidden; it authorizes no current DDL, Compose/init, or runtime/startup path.
- **Unmaskable semantic negative:** before inventory disposition, the scanner fixture proves that exact former wording fails. The only passing status-only sequence is external bootstrap → TLS/roles → `mosaic-db-migrator --run``mosaic-db-migrator --verify` → Gateway readiness.
### 1.0.0-rc.12 — Deployable importer generation and indirect-DDL-route closure
- **Authenticated generation:** KBN-101-05 owns one canonical Vault KV-v2 importer record, `secret-{env}/mosaic-stack/database/importer` key `url`, with its version taken only from the same successful `data.metadata.version` response. Value plus provider version are one generation, never inferred from DSN bytes. The renderer creates separate immutable `0400` URL/version copies for migrator `10003:10003` binding-only access and importer `10002:10002` access; it uses fsync/atomic generation replacement for Compose and distinct versioned secret/config references for Swarm, so deployment cannot mix generations.
- **Bounded consumers:** importer alone receives its URL/version, CA at `DATABASE_TLS_CA_CERT_PATH`, pinned public key, and read-only attestation; migrator receives its own migration URL/CA, the URL/version only for no-connect/no-export binding, attestation output, and the root-wrapper-only private key. Safe fd open/fstat/digest/zeroize/close semantics, a privileged producer-only-to-importer-only attestation handoff controller (verify, exact-byte copy, fsync/atomic rename, `10002:10002` `0400` seal, then importer start), no shared writable file, no logging/oracle, provider rotation/revocation, CA/mount, consumer-isolation, and symlink/hardlink/owner/mode/TOCTOU negatives are mandatory.
- **Indirect-DDL closure:** `docs/federation/SETUP.md` is non-operative until KBN-101 activation and documents only external bootstrap → TLS/roles → runner `--run``--verify` → Gateway readiness. The -06 scanner performs unsuppressible semantic checks for automatic first-boot/startup extension/schema/migration language, Compose-up-before-runner, and init-script authority; the former SETUP wording fails and the remediated sequence passes.
### 1.0.0-rc.11 — Target-bound importer attestation and exhaustive operator-route closure
- **Target-bound proof:** trusted `mosaic-db-migrator --verify` now produces the atomic, credential-free `migrate-target.v1.json` JCS/Ed25519 artifact from a runner-only root-owned signing-key reference; the importer receives only pinned public verification keys and the artifact. Its signed v1 fields bind issued/expiry/nonce, exact secret version and SHA-256 of high-entropy target-file bytes, canonical TLS host/port/database, CA/SPKI, PostgreSQL system identifier/database OID, expected importer role, manifest/schema fingerprints, and producer invocation/build/image/correlation. No DSN, username, password, credential bytes, or signing key enters the artifact, importer, runtime, logs, or output.
- **Fail-closed importer:** `mosaic storage migrate-tier` requires both `--target-url-file /run/secrets/mosaic-migrate-target-url` and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`. Before target connection it validates files, signature/key/expiry/replay, secret version/digest, TLS/CA/role/manifest bindings and opens/digests/connects from the same in-memory URL bytes. After verified TLS but before transaction/DML it matches server ID, database OID, `current_user`, CA/SPKI, and manifest/schema; failure distinguishes zero connection from connection/zero-DML and DDL remains impossible. Rotation overlap/revocation, atomic rename, replay cache, secret rotation invalidation, and wrong/substituted/stale/tampered/file-change tests are mandatory.
- **Closed documentation surface:** KBN-101-06 inventories every current non-normative scanner hit, including `docs/guides/user-guide.md` and status-only `docs/federation/TASKS.md`; the latter is historical and cannot authorize DDL. The legacy `storage migrate` tier-copy syntax is unavailable. `storage migrate` is schema-wrapper delegation only; secure tier data copy is `migrate-tier`. Exact KBN PRD/contract/shared/task paths may be `normative-contract` scan class but are still scanned and cannot mask executable instructions. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
- **Non-effect:** pgvector closure, manifest, lock, role graph, TLS, activation, and KBN-100/KBN-105 serial gates are unchanged.
### 1.0.0-rc.10 — PostgreSQL-valid untrusted pgvector owner and active migrate-tier closure
- **Valid extension authority:** PostgreSQL 17 + pgvector 0.8.2 `vector` is untrusted (`trusted` absent; `relocatable=true`), so `mosaic_extension_owner` is exactly `NOLOGIN SUPERUSER`, not `NOSUPERUSER`. It is dedicated solely to `mosaic_extensions`, `vector`, and owner-bearing extension members; `rolcanlogin=false`, `rolsuper=true`, zero members, no runtime credential/Vault secret, and no app-container delivery are catalog and deployment proof. An externally controlled audited bootstrap-superuser session alone `SET ROLE`s for extension CREATE/UPDATE/SET SCHEMA, then `RESET ROLE`; fresh and shadow paths do so, while in-place existing work requires exact pre-existing `extowner`.
- **Explicit superuser exception:** `GRANT`/`REVOKE` cannot privilege-limit a superuser. The containment is dedicated identity, no login, no membership, external control plane, audit, independent review, backup/rollback, and maintenance window—not a false least-privilege claim. Runtime, migrator, schema owner, importer, and every service role cannot assume the role or alter/update/drop/change extension membership. Managed targets without this exact role are ineligible unless a versioned provider-owned extension-owner profile is independently approved.
- **Active secure data-migration route:** `docs/guides/migrate-tier.md` is exclusively KBN-101-07, is active rather than historical, and specifies runner-prepared/verified PostgreSQL destination plus a dedicated non-DDL importer. KBN-101-02 freezes `--target-url-file /run/secrets/mosaic-migrate-target-url`, never credential argv; raw `--target-url`, `DATABASE_URL` fallback, runtime owner, missing/unsafe file, wrong mode, and DDL all fail before target connection/DDL. KBN-101-06 inventory/matrix records the route and exact secure fields, then tests its finite operator-document closure.
- **Non-effect:** manifest, lock, `mosaic` application schema, TLS, activation, and KBN-100/KBN-105 serial gates remain unchanged. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
### 1.0.0-rc.9 — KBN-101 extension-schema boundary, disjoint manifests, and scanner mechanics
- **Extension schema owner:** `mosaic_extension_owner`, not `mosaic_schema_owner`, creates and owns `mosaic_extensions`, `vector`, and extension-member objects. The external bootstrap actor `SET ROLE`s for fresh creation or approved-owner relocation, then `RESET ROLE`s; rc.10 replaces the earlier membership wording with the PostgreSQL-valid zero-member superuser exception. Schema owner has only `USAGE` for legacy type resolution—never ownership, `CREATE`, `ALTER`, `DROP`, member change, or default-privilege authority. Runtime, migrator, and schema owner must fail catalog and direct DDL denials; shadow/resume/rollback repeat the owner/default-privilege proof.
- **Exclusive delivery DAG:** KBN-101-00…09 now has a complete, nonoverlapping exact file/glob manifest with named tests/evidence. The runner mapping is exactly `"mosaic-db-migrator": "./dist/cli.js"` and image `ENTRYPOINT ["mosaic-db-migrator"]`; `packages/storage/src/{cli,migrate-tier}.ts` belongs only to -02, and -07 is documentation only. -08/-09 own evidence paths only. -00…07 are prepared artifacts; the immutable N-1 image remains live until -08 atomic activation, so no independently deployed intermediate can bypass runtime controls.
- **Mechanical classifier:** -06 owns the exact scanner, inventory fixture, command-matrix harness, and CI wiring. Inventory records pin path/class/owner/disposition/allowed tokens/rationale/expiry/review revision; unknown, duplicate-owner, ownerless, missing-path, invalid allowlist, and historical-category masking fail. The architecture plan's operative direct `db:migrate` is replaced by sole-runner guidance rather than hidden under a historical category.
- **Non-effect:** manifest v1, lock, `mosaic` application-schema ownership, TLS, activation, KBN-100/KBN-105 serial gates, and all earlier canon decisions remain unchanged. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
### 1.0.0-rc.8 — KBN-101 finite authority, executable runner, and pgvector-owner remediation
- **Finite authority closure:** KBN-101-06 classifies every current executable source/script/package bin, operator document, and deploy manifest by exact path; unclassified current hits fail. Byte-immutable historical SQL, PGlite-only routines, negative-test literals, vendored/generated artifacts, and clearly labeled historical reports are exact-path/category reviewed allowlists only. `packages/db/src/index.ts` loses its public `runMigrations` export with a direct-import/compile negative; `docs/fleet/backlog-conventions.md` and `docs/PERFORMANCE.md` lose first-use/direct-Drizzle/Gateway-startup migration instructions and carry runner/readiness route negatives. A token scan is only input to the classifier, never proof of authority.
- **Executable exclusive cards:** KBN-101-03 alone publishes `mosaic-db-migrator` from `packages/db/package.json`/`src/cli.ts`, owns `docker/db-migrator.Dockerfile`, and keeps `{runner,config.dto,manifest,identity,tls}` private, with exact `--run|--verify|--help`, env-only input, stable exits, and command tests. KBN-101-00 alone owns `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, plus bootstrap tests. KBN-101-05 alone owns `tools/db/render-postgres-secrets.ts`, renderer tests, and Compose/Portainer/Swarm/two-gateway declarations, consuming the versioned bootstrap interface. No card overlaps renderer/bootstrap/deployment ownership.
- **Extension-owner transition:** `mosaic_extension_owner` is a dedicated NOLOGIN role whose membership/credentials never reach services; the external bootstrap actor alone may `SET ROLE` during bootstrap. Fresh vector and member objects retain that owner. PostgreSQL has no supported extension-owner alteration: approved-owner existing extension relocation validates `pg_extension.extowner`, members/schema/version and uses tested `ALTER EXTENSION ... SET SCHEMA`; legacy runtime-owned extension fails closed to a controlled shadow database migration with backup, evidence, quiesce/final delta, atomic switch, and read-only rollback window. No catalog mutation, ownership adoption, or `DROP CASCADE` is permitted. Runtime/migrator/schema-owner extension ALTER/DROP/member-update denial is mandatory.
- **Non-effect:** manifest v1, lock namespace, role/search-path, relocation/TLS/activation, KBN-100/KBN-105 serial gates, and all retained canon decisions are strengthened, not weakened. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
### 1.0.0-rc.7 — KBN-101 complete current-path, relocation, and two-gateway remediation
- **Finite current-path closure:** static inventory and the `DATABASE_URL`-only-before-connect/DDL denial matrix now explicitly include Gateway's former temporary-table pgvector test (runner-prepared persistent read/query-only fixture), `docker/init-db.sql` retirement, `migrate-tier.ts` runner/bootstrap-only guidance, and the active two-gateway harness. The harness is migrated, not retired: `postgres-a/b → mosaic-db-migrator-a/b → gateway-a/b`, each with isolated URL/CA material, verified readiness, SANs, and positive/negative TLS evidence.
- **Executable relocation:** KBN-101-03 exclusively owns `schema.ts`, Drizzle snapshots/journal/generated relocation and exact tests. All future application declarations use exported `pgSchema('mosaic')`; immutable historical SQL runs only in trusted legacy `public`. `vector` is fixed in non-writable `mosaic_extensions`, with exact catalog relocatability/version eligibility, explicit type/operator qualification, catalog-class ordering, unknown-object fail-closed behavior, clean/current-public/partial/reverse rollback tests, and an N-1 release order.
- **Bound deployment ownership:** `mosaicstack/stack` KBN-101-00/05 owns current Compose, Portainer, two-gateway, bootstrap renderer/templates, UID/GID declarations, and rendered validation. Gateway is fixed to `10001:10001`; PostgreSQL UID/GID is image-inspected and frozen only after digest pinning. Exact secret paths, atomic renderer behavior, Compose/Swarm targets/modes, Gateway/PostgreSQL leaf separation, and two-pair TLS failure evidence are required. Mosaic deployment control plane/Jason is the named activation authority; environment IaC/Vault supplies versioned input only.
- **Correct traceability:** REQ-03 maps to role/schema/search-path, REQ-04 to TLS, REQ-05 to post-KBN-100 immutability, REQ-06 to rollout/rollback, and REQ-07 to the KBN-101 → KBN-100 → KBN-101 → KBN-105 sequence. No prior manifest/lock/role/DAG/activation decision is weakened.
### 1.0.0-rc.6 — KBN-101 closed DDL/TLS/ledger activation remediation
- **Choice:** `mosaic-db-migrator` is the sole application/CI/test PostgreSQL DDL control plane. Every legacy entrypoint is routed or denied, rejects `DATABASE_URL`-only before connection/DDL, and `db:push` is unavailable outside an allowlisted disposable developer target. The runner holds one `max:1` session with fixed `pg_try_advisory_lock(1297044289,1262636593)` across preflight through release.
- **Exact ledger:** manifest v1 canonically serializes journal logical index/tag and SHA-256 of exact shipped migration bytes. It maps each observed ledger hash to one tuple; physical insertion order is non-normative, while missing/unknown/duplicate/ambiguous/corrupt/stale states fail closed. Shipped `0009` bytes remain unchanged; a missing/effects-absent `0009` runs normally, an applied-late hash maps normally, and partial/full effects with missing hash require backup restoration or separately reviewed repair—not manual adoption.
- **TLS/search path:** operator/IaC owns CA and server leaf lifecycle, exact compose/Swarm secret mounts, server TLS activation, service-DNS SANs, verified-TLS readiness, transition, CA overlap rotation, and rollback. Runtime/migrator use `verify-full`; PGlite is not PostgreSQL TLS evidence. Application sessions use only `pg_catalog,mosaic`; no URL/config-derived identifier reaches SQL.
- **Safe release:** cards 0007 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, immutable certification after KBN-100, KBN-105 gate, rc.5s preserved rc.4 SI-001 invariants, and all KCR-001016 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-001016 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-101s role/schema-boundary foundation certificate, Vault/redaction/rotation, N-1/rollback, and independent security GO are mandatory before KBN-100. After KBN-100 creates the immutable relations, KBN-101 real deployed-role immutable-operation certification plus Ultron GO is mandatory before KBN-105; synthetic test-role success alone is insufficient. Exact implementation detail is normative in [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
### 1.0.0-rc.4 — KBN010-SI-001 (preserved)
- **Choice:** add the explicitly named, non-partial unique candidate key `missions_workspace_id_uidx` on `missions(workspace_id, id)` and retain `missions_workspace_project_id_uidx` on `(workspace_id, project_id, id)`. - **Choice:** add the explicitly named, non-partial unique candidate key `missions_workspace_id_uidx` on `missions(workspace_id, id)` and retain `missions_workspace_project_id_uidx` on `(workspace_id, project_id, id)`.
- **Rationale:** mission `id` remains globally unique, while the composite candidate key makes the frozen tenant-safe generic mission relations valid. `artifacts` and `approval_decisions` are polymorphic exactly-one-target records and do not consistently carry `project_id`; widening both children would unnecessarily broaden v1 and its target semantics. - **Rationale:** mission `id` remains globally unique, while the composite candidate key makes the frozen tenant-safe generic mission relations valid. `artifacts` and `approval_decisions` are polymorphic exactly-one-target records and do not consistently carry `project_id`; widening both children would unnecessarily broaden v1 and its target semantics.
@@ -81,7 +19,7 @@
## 1. Authority ## 1. Authority
Concrete contracts are the four `contracts/*.v1.ts` files. PostgreSQL/current-main Drizzle is the sole writable SOT. In PostgreSQL standalone/federated deployments, KBN-101 rc.13 DDL/ledger/TLS/role/attestation/generation separation is a precondition to schema implementation and certification. Public health, Valkey, files, exports, providers, browser state, and outage notes cannot authorize/reconstruct writes. Mechanical Coordinator is non-LLM with no scope/gate/certification/merge authority. Certifier is final independent gate with no merge authority. No feature lane starts until this canon merges and the KBN-010/KBN-105 prerequisites are satisfied. Concrete contracts are the four `contracts/*.v1.ts` files. PostgreSQL/current-main Drizzle is the sole writable SOT. Public health, Valkey, files, exports, providers, browser state, and outage notes cannot authorize/reconstruct writes. Mechanical Coordinator is non-LLM with no scope/gate/certification/merge authority. Certifier is final independent gate with no merge authority. No feature lane starts until this canon merges and the KBN-010/KBN-105 prerequisites are satisfied.
## 2. Health proof and exact failures ## 2. Health proof and exact failures
@@ -166,7 +104,7 @@ The candidate key is intentionally redundant with globally unique `missions.id`,
### 5.3 New audit/proposal DDL order ### 5.3 New audit/proposal DDL order
KBN-100 migration DDL may begin only after KBN-101 foundation role/schema-boundary certification. It runs in the explicit migrator/owner phase—not Gateway startup—and its generated Drizzle declaration/snapshot/journal must be mutually consistent. It must execute in this order: KBN-100 migration DDL must execute in this order:
1. create `task_events` and its unique `(workspace_id, id)` key; 1. create `task_events` and its unique `(workspace_id, id)` key;
2. create `change_proposals` with nullable acceptance-event ID and required submission-event ID; 2. create `change_proposals` with nullable acceptance-event ID and required submission-event ID;
@@ -305,7 +243,7 @@ KBN-115/coder2 owns `packages/config/src/recovery-posture.ts`, tests, and recove
## 9. Integration, security, and hold ## 9. Integration, security, and hold
Required release evidence includes KBN-101 foundation role/schema-boundary and post-KBN-100 real immutable-operation deployed-role certificates (not synthetic roles), empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority. Required release evidence includes empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority.
### 9.1 SI-001 amendment gate and #757 boundary ### 9.1 SI-001 amendment gate and #757 boundary

View File

@@ -43,14 +43,12 @@ Shared roots, package exports/manifests, lockfiles, and generated artifacts are
```text ```text
KBN-000 canon remediation KBN-000 canon remediation
-> KBN-010 threat/auth/constraint-impact gate (MUST COMPLETE) -> KBN-010 threat/auth/constraint-impact gate (MUST COMPLETE)
-> KBN-101 foundation role/schema-boundary certificate (SERIAL) -> KBN-100 schema + concrete N-1 migration implementation
-> KBN-100 schema + concrete N-1 migration implementation ├─ KBN-105 exact endpoint/DTO/error/registry freeze (SERIAL)
├─ KBN-101 post-KBN-100 deployed-role immutable-operation certificate (SERIAL) ├─ KBN-110 domain + Gateway + MCP server implementation
-> KBN-105 exact endpoint/DTO/error/registry freeze (SERIAL) ├─ KBN-120 CLI/projection implementation [coder4 first]
│ ├─ KBN-110 domain + Gateway + MCP server implementation │ └─ KBN-130 web MVP implementation
│ ├─ KBN-120 CLI/projection implementation [coder4 first] └─ KBN-115 recovery parser/mechanism slice [coder2 lane-serial]
│ └─ KBN-130 web MVP implementation
└─ KBN-115 recovery parser/mechanism slice [coder2 lane-serial]
KBN-110 + KBN-120 + KBN-130 + KBN-115 KBN-110 + KBN-120 + KBN-130 + KBN-115
-> KBN-140 P1 integration/SIT -> KBN-140 P1 integration/SIT
-> KBN-200 pure decision engine [coder4 after KBN-120] -> KBN-200 pure decision engine [coder4 after KBN-120]
@@ -66,7 +64,7 @@ KBN-310 + KBN-320
-> KBN-340 owner-gated cutover/stabilization -> KBN-340 owner-gated cutover/stabilization
``` ```
No consumer implementation begins before KBN-105. No schema work begins before KBN-010 completes and the KBN-101 foundation role/schema-boundary certificate passes; the real immutable-operation certificate follows KBN-100 and blocks KBN-105. The coder4 order is always KBN-120 → KBN-200 → KBN-300 → KBN-320. No consumer implementation begins before KBN-105. No schema work begins before KBN-010 completes. The coder4 order is always KBN-120 → KBN-200 → KBN-300 → KBN-320.
## 4. P0 — Canon, threat gate, schema, and exact API freeze ## 4. P0 — Canon, threat gate, schema, and exact API freeze
@@ -93,17 +91,6 @@ No consumer implementation begins before KBN-105. No schema work begins before K
- **Contract surfaces:** schema constraints, health proof, exact errors, command-family authorization. - **Contract surfaces:** schema constraints, health proof, exact errors, command-family authorization.
- **Evidence:** signed constraint-impact matrix; no unresolved schema-impact finding; SecReview pass. - **Evidence:** signed constraint-impact matrix; no unresolved schema-impact finding; SecReview pass.
### KBN-101 — PostgreSQL runtime/migration role split and deployed-role certification
- **Status:** IN PROGRESS — issue [#771](https://git.mosaicstack.dev/mosaicstack/stack/issues/771); rc.13 closes the exact-head MILESTONES HIGH finding: `docs/federation/MILESTONES.md` is exclusively KBN-101-07, status-only, and has an unmaskable KBN-101-06 semantic negative for its former startup-extension wording. It awaits independent exact-head re-review; implementation remains held.
- **Owner:** Mos integration control plane; independently reviewed by security/Ultron.
- **Mode:** SERIAL foundation certificate blocks KBN-100; its post-KBN-100 real immutable-operation certificate blocks KBN-105.
- **IN:** Exact `DATABASE_URL` non-owner runtime versus `DATABASE_MIGRATION_URL` owner/migrator connection contract; sole published `mosaic-db-migrator --run|--verify` PostgreSQL DDL path and all legacy/future entrypoint closure; active migrate-tier destination only after runner prepare/verify through exact `--target-url-file /run/secrets/mosaic-migrate-target-url`, paired authenticated provider-version file, and signed `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`; runner-only signing key/public-key isolation; canonical Vault KV-v2 target URL/version, generation-pinned renderer, importer CA/public-key/attestation plus privileged sealed producer-to-importer handoff, safe-fd/consumer-isolation/no-log-oracle, TLS/server/database/role/manifest/schema binding, expiry/replay/provider-rotation/TOCTOU/no-DML controls, and dedicated non-DDL importer; finite exact-path scanner/allowlist/active-route review plus unsuppressible automatic-startup/init/Compose-before-runner semantic negatives and every-path before-connect denial matrix; `DATABASE_TLS_CA_CERT_PATH` plus operator/IaC CA/server-key/cert lifecycle, exact service-DNS SANs, Vault/compose/Swarm mount modes, TLS server/bootstrap/rotation/rollback; PGlite exception; fixed two-int advisory lock; manifest-v1 logical-index/tag/exact-byte-SHA-256 ledger reconciliation including safe `0009`; fixed `mosaic` schema and exact `pg_catalog,mosaic` pooled session path; platform/schema/`NOLOGIN SUPERUSER` extension-owner/migrator/importer/runtime roles; approved-owner versus legacy-owner shadow pgvector transition; ownership, zero membership/no runtime secret, TEMP/ledger-read/default privilege and immutable grant proof; N-1 inactive prepared cards then atomic activation/rollback authority; Vault/redaction/observability/operator runbooks; one-card/one-PR implementation DAG.
- **OUT:** Production mutation in this planning card; KBN-100 tables/data backfill; application API behavior; KBN-105 route/DTO freeze.
- **Depends on:** KBN-010 completed.
- **Contract surfaces:** [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md); `SHARED-CONTRACT.md` rc.13 amendment.
- **Evidence:** foundation: exact `--help|--run|--verify`/exit/argv/import-negative plus DTO entrypoint negatives for every finite classified current DDL/static-bypass path (including `DATABASE_URL`-only, runner fixture, retired init, sanitized current operator guidance, both harness pairs, and `db:push` refusal); active migrate-tier paired URL/version/attestation files, signing/public-key isolation, canonical Vault KV-v2 authenticated version, generation-pinned renderer, importer CA, safe fd/TOCTOU/consumer-isolation/no-log-oracle, atomic JCS/Ed25519, digest/TLS/server/database/role/manifest/schema binding, expiry/replay/provider rotation/revocation, zero-connection versus zero-DML, prepared-target/importer/no-DDL negatives; clean/pre-0009/skipped/applied-late/duplicate/unknown/missing/corrupt/stale/backup plus public-to-`mosaic`/partial/reverse runner proof; fixed-lock contention/crash/readiness/unrelated-key tests; runtime cannot invoke migrations/DDL/TEMP; actual pgvector 0.8.2 control metadata, fresh/approved-owner existing/legacy-owner shadow/partial-resume-rollback/N-1 pgvector evidence with `rolcanlogin=false`, `rolsuper=true`, zero members, external-superuser `SET ROLE`/`RESET ROLE` audit, `pg_extension.extowner`, owner-bearing member/schema/version and runtime/migrator/schema-owner/importer/all-service-role `SET ROLE`/ALTER/DROP/member-update denial; disposable standalone, federated/Swarm, and two-gateway verified-TLS positives plus both-pair CA/SAN/downgrade/key mode/UID-GID/URL-secret consumer-isolation and legacy-drain/`hostssl` zero-plaintext negatives; exclusive bootstrap/renderer/manifest ownership test; catalog relocation/vector-query/operator/Drizzle-only-`mosaic`, role/grant/search-path/pool-reset/identifier checks; N-1/atomic TLS-only rollback/no-force-on-red rehearsal; named Vault/bootstrap-control-plane/CA-overlap/redaction/operator evidence; independent author≠reviewer security GO. Post-KBN-100: real deployed non-owner INSERT/SELECT and UPDATE/DELETE denial for immutable event/artifact/evidence relations plus Ultron GO.
### KBN-100 — Unified Drizzle schema and concrete N-1 migration ### KBN-100 — Unified Drizzle schema and concrete N-1 migration
- **Owner:** **coder2**. - **Owner:** **coder2**.
@@ -111,7 +98,7 @@ No consumer implementation begins before KBN-105. No schema work begins before K
- **Exclusive files:** `packages/db/src/schema.ts`, `packages/db/drizzle/**`, DB tests. - **Exclusive files:** `packages/db/src/schema.ts`, `packages/db/drizzle/**`, DB tests.
- **IN:** All frozen tables/joins/enums; workspace/project-congruent constraints; owners/principals; tags/archive; change proposals with both workspace-aware task-event composite FKs and frozen event-before-proposal DDL order; assignment approvals; durable execution/quarantine; monotonic bigint fence; exact checkpoint/evidence joins; RESTRICT/immutability; concrete current-main expand/backfill/switch/contract map. - **IN:** All frozen tables/joins/enums; workspace/project-congruent constraints; owners/principals; tags/archive; change proposals with both workspace-aware task-event composite FKs and frozen event-before-proposal DDL order; assignment approvals; durable execution/quarantine; monotonic bigint fence; exact checkpoint/evidence joins; RESTRICT/immutability; concrete current-main expand/backfill/switch/contract map.
- **OUT:** Repositories, Gateway, Coordinator behavior, UI, importer. - **OUT:** Repositories, Gateway, Coordinator behavior, UI, importer.
- **Depends on:** **KBN-010 completed and KBN-101 foundation role/schema-boundary certificate PASS**. KBN-100 is blocked until both are terminal; it rebases on KBN-101 main, restores generated Drizzle declaration/snapshot/journal consistency, and confines procedural immutable-table grant/trigger/backfill work to its schema ownership. Its new relations are then subject to KBN-101 post-KBN-100 deployed-role certification. - **Depends on:** **KBN-010 completed**.
- **Contract surfaces:** `kanban-schema.v1.ts`; SHARED-CONTRACT current-main delta map. - **Contract surfaces:** `kanban-schema.v1.ts`; SHARED-CONTRACT current-main delta map.
- **Evidence:** reviewed SQL; empty/prod-shape/partial-resume/rollback tests; N-1 app safety; legacy columns remain declared; workspace/project mismatch negatives; proposal event-FK missing/foreign-workspace tests; one active lease; monotonic fence; parent-delete RESTRICT; immutability privileges; SecReview. - **Evidence:** reviewed SQL; empty/prod-shape/partial-resume/rollback tests; N-1 app safety; legacy columns remain declared; workspace/project mismatch negatives; proposal event-FK missing/foreign-workspace tests; one active lease; monotonic fence; parent-delete RESTRICT; immutability privileges; SecReview.
@@ -122,7 +109,7 @@ No consumer implementation begins before KBN-105. No schema work begins before K
- **Exclusive files:** canonical endpoint-registry/DTO contract docs; no implementation. - **Exclusive files:** canonical endpoint-registry/DTO contract docs; no implementation.
- **IN:** Exact routes and methods from SHARED-CONTRACT §8; request/success/error fields; status codes; pagination/filter/revision envelopes; idempotency/expected-version headers/fields; proposal commands; health proof exclusion from public DTOs; MCP tool-to-route map. - **IN:** Exact routes and methods from SHARED-CONTRACT §8; request/success/error fields; status codes; pagination/filter/revision envelopes; idempotency/expected-version headers/fields; proposal commands; health proof exclusion from public DTOs; MCP tool-to-route map.
- **OUT:** Controller/service/client implementation. - **OUT:** Controller/service/client implementation.
- **Depends on:** KBN-100 and KBN-101 post-KBN-100 deployed-role immutable-operation certification PASS. - **Depends on:** KBN-100.
- **Contract surfaces:** health/error unions; schema IDs/statuses; Gateway DTO freeze. - **Contract surfaces:** health/error unions; schema IDs/statuses; Gateway DTO freeze.
- **Evidence:** every FE/CLI/MCP call maps 1:1 to a route; 503/502-504/409 non-cross-map fixtures; contract digest published. - **Evidence:** every FE/CLI/MCP call maps 1:1 to a route; 503/502-504/409 non-cross-map fixtures; contract digest published.
@@ -264,15 +251,13 @@ No consumer implementation begins before KBN-105. No schema work begins before K
## 8. Consistent USC wave schedule ## 8. Consistent USC wave schedule
| Wave | coder2 | coder3 | coder4 | coder5 | | Wave | coder2 | coder3 | coder4 | coder5 |
| ---- | ----------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------ | ------------------------------ | | ---- | ------------------------- | -------------------------------------- | ------------------------------ | ------------------------------ |
| 0 | Wait | **KBN-010** | Wait | Wait | | 0 | Wait | **KBN-010** | Wait | Wait |
| 0.5 | Wait | **KBN-101 foundation** Mos-controlled role/connection contract and certificate | Wait | Wait | | 1 | **KBN-100** | Review constraint implementation | Wait | Wait |
| 1 | **KBN-100** after KBN-101 foundation PASS | Review bounded schema/grant implementation | Wait | Wait | | 2 | **KBN-115** after KBN-100 | **KBN-105** exact freeze, then KBN-110 | **KBN-120** only after KBN-105 | **KBN-130** only after KBN-105 |
| 1.5 | Certification support | **KBN-101 post-KBN-100 deployed-role immutable-operation certificate**, then KBN-105 | Wait | Wait | | 3 | Review support | Finish KBN-110 | **KBN-200 after KBN-120** | Finish KBN-130 |
| 2 | **KBN-115** after KBN-100 | **KBN-105** exact freeze, then KBN-110 | **KBN-120** only after KBN-105 | **KBN-130** only after KBN-105 | | 4 | | **KBN-210 after KBN-200** | Review/support | **KBN-220 after KBN-210 DTOs** |
| 3 | Review support | Finish KBN-110 | **KBN-200 after KBN-120** | Finish KBN-130 | | 5 | | P2 remediation | **KBN-300 then KBN-320** | **KBN-310** |
| 4 | — | **KBN-210 after KBN-200** | Review/support | **KBN-220 after KBN-210 DTOs** |
| 5 | — | P2 remediation | **KBN-300 then KBN-320** | **KBN-310** |
Mos alone releases slices and lifts the build hold after independent re-review GO. Mos alone releases slices and lifts the build hold after independent re-review GO.

View File

@@ -1464,11 +1464,9 @@ Generate and apply:
```bash ```bash
pnpm --filter @mosaicstack/db db:generate # generates migration SQL pnpm --filter @mosaicstack/db db:generate # generates migration SQL
mosaic-db-migrator --run # sole PostgreSQL migration runner pnpm --filter @mosaicstack/db db:migrate # applies to PG
``` ```
> **KBN-101 supersession:** `pnpm --filter @mosaicstack/db db:migrate` is superseded and MUST NOT be used. The runner receives only deployment-injected migration credentials; it accepts no URL, SQL, schema, or role argv.
Platform enforcement keys (seeded with `mutable = false` by gateway `PreferencesService.onModuleInit()`): Platform enforcement keys (seeded with `mutable = false` by gateway `PreferencesService.onModuleInit()`):
| Key | Category | Reason | | Key | Category | Reason |

View File

@@ -1,109 +0,0 @@
# KBN-101 contract independent security/architecture review
**Verdict: REQUEST CHANGES**
## Review identity and scope
- **Exact reviewed head:** `da742ca2da4a2ff466916c818fe275c4f7ffd384` (`docs(#771): record role-split review evidence`)
- **Required comparison:** `origin/main...da742ca2da4a2ff466916c818fe275c4f7ffd384`
- **Range:** `82ce3252df38a687c50485f8d048b53ca8db5989` is an ancestor of the reviewed head; the final head adds the scratchpad evidence commit and was reviewed.
- **Changed docs:** `docs/PRD.md`, `docs/SITEMAP.md`, `docs/native-kanban-sot/{INDEX.md,KBN-101-DB-ROLE-SPLIT.md,SHARED-CONTRACT.md,TASKS.md}`, and `docs/scratchpads/771-kbn101-db-role-split.md` (300 additions / 25 deletions).
- **Reviewed inputs:** issue #771; current DB/Gateway/storage/config/wizard/installer/compose/Portainer/CI sources; all current migration/DDL references; KBN-010, rc.4/rc.5 shared contract, requirements/canon, KBN-100 #769 branch context, and the final scratchpad.
- **Repository/provider state:** not modified. The pre-existing `.mosaic/orchestrator/*` dirt was not touched.
The role graph itself is sound in principle: a NOLOGIN platform database owner, separate NOLOGIN schema owner, NOINHERIT migrator which explicitly `SET ROLE`s, and runtime membership only in a capability role with `SET FALSE` does not create circular privilege or application-created login roles. The split of foundation certification before KBN-100 and immutable-operation certification after KBN-100 is also correctly ordered.
## Findings
### HIGH — DDL/migration control plane is not closed at every current entrypoint
The contract requires an explicit, locked migration phase and forbids Gateway/runtime DDL (`KBN-101-DB-ROLE-SPLIT.md:34-39`), but its KBN-101-02 result merely says migration-capable commands use the migration DTO (`:109`). It does not prohibit or route every existing bypass through that one command.
Current bypasses include:
- `runMigrations()` falls back from an argument to `DATABASE_URL` and a hard-coded URL (`packages/db/src/migrate.ts:24-35`), while `drizzle.config.ts` likewise uses `DATABASE_URL` plus a default (`packages/db/drizzle.config.ts:3-9`).
- Package scripts expose direct `drizzle-kit migrate` **and** `drizzle-kit push` (`packages/db/package.json:23-26`); `db:push` bypasses the planned journal/fingerprint/lock entirely.
- `mosaic storage migrate --run` shells out to the direct `db:migrate` script (`packages/storage/src/cli.ts:413-452`).
- The federated integration test can create types, tables, and indexes directly against `DATABASE_URL` and intentionally operates without a Drizzle ledger (`packages/db/src/federation.integration.test.ts:28-30,46-134`).
**Failure mode:** a runtime or CI environment with only `DATABASE_URL`, or an operator invoking an existing command, can apply unverified DDL outside the lock, `SET ROLE` preflight, exact-ledger gate, and deployment sequencing. This breaks the requested fail-closed split even if Gateway startup is repaired.
**Required remediation:** amend KBN-101-02/03/06 to enumerate these entrypoints and make the dedicated migrator runner the only PostgreSQL DDL path. Production-like `db:push` must be removed/blocked; `db:migrate`, `storage migrate --run`, and migration tests must invoke the same migration runner with `DATABASE_MIGRATION_URL`, lock, identity preflight, and ledger verification. Tests needing schema must consume a pre-migrated disposable database, or be explicitly run only by that migration phase. Add negative tests showing each command refuses `DATABASE_URL`-only execution and cannot reach DDL.
### HIGH — TLS requirement has no deployable server/bootstrap contract
The contract correctly requires a mounted CA and hostname-verified TLS (`KBN-101-DB-ROLE-SPLIT.md:25,28,93-95`). However KBN-101-05 promises only a “migration phase and secret binding boundary” (`:112`), not PostgreSQL server TLS, certificate issuance/SANs, CA distribution, startup ordering, or the fresh/existing-database bootstrap trust path.
Current standalone and federated compose expose plain PostgreSQL with no server TLS configuration or CA mount (`docker-compose.yml:2-14`; `docker-compose.federated.yml:27-44`). The Portainer test stack passes a single plaintext in-network URL and uses the same database login for Gateway and database bootstrap (`deploy/portainer/federated-test.stack.yml:51-60,110-117`).
**Failure mode:** enforcing the mandatory CA makes current local standalone/federated topologies unable to start; relaxing it to make bootstrap work silently violates K101-REQ-03. A first database cannot be safely migrated until the server certificate, its SAN for the actual service/DNS name, and trusted CA are provisioned, but this lifecycle is not owned or tested.
**Required remediation:** add a concrete KBN-101-00/05 TLS bootstrap sub-contract: issuer/CA owner; server key/cert and SAN inputs; secure storage/mount permissions; `postgresql.conf`/container TLS enablement; migration and runtime CA mounts; hostname used by each compose/Swarm service; readiness only after TLS authentication; CA overlap rotation; and an existing-database transition. Require a disposable standalone and federated/Swarm test to prove verified TLS succeeds and missing CA, wrong CA, wrong SAN, and `sslmode` downgrade fail before readiness. Do not merge KBN-101-05 with an implicit plaintext exception.
### HIGH — exact ledger fingerprint and historical 0009 repair are underspecified for existing databases
The contract requires an “ordered complete set” and rejection of out-of-order rows (`KBN-101-DB-ROLE-SPLIT.md:36-38`), but does not define the canonical serialized tuple, ledger ordering source, or safe upgrade rule for a historical ledger. The current ledger stores only `id`, `hash`, and `created_at` (`packages/db/src/migrate.ts:70-82,105-107`). Its journal is demonstrably non-monotonic: `0008` has `when=1776822435828`, followed by `0009` at `1745280000000` (`packages/db/drizzle/meta/_journal.json:62-79`); the existing PostgreSQL runner documents that this causes skipping (`packages/db/src/migrate.ts:29-35`).
**Failure mode:** an implementation can either reject a legitimate historical database after correcting 0009, or accept a reordered/duplicated ledger because no precise comparison rule exists. A count/hash-set implementation would fail to detect the condition that this contract explicitly calls unsafe; physical `id` order is not an adequate substitute after historical repair.
**Required remediation:** freeze a versioned manifest algorithm before implementation: canonical record fields (at least journal index/tag, corrected logical order, migration content hash, and an explicit migration-manifest version), canonical byte serialization, SHA-256 input, and exact observed-ledger mapping. State whether physical ledger insertion order is normative; if not, compare hash-to-manifest tuples rather than timestamps. Add an idempotent migrator-only 0009 existing-database remediation/reconciliation procedure with backup/rollback evidence. Require clean, pre-0009, 0009-skipped, 0009-applied-late, duplicate, unknown, missing, corrupt-pair, and stale-replica cases. No manual ledger insertion is an acceptable production recovery path.
### MEDIUM — advisory-lock namespace is collision-prone and lacks a fixed identifier contract
The specified lock is `pg_try_advisory_lock(hashtext('mosaic-schema-migration-v1'))` (`KBN-101-DB-ROLE-SPLIT.md:34`). `hashtext` produces a 32-bit key. Session ownership/crash behavior is otherwise correctly stated (one session, same-session release, connection-close release), but an unrelated database user can accidentally collide or deliberately hold the key and force `DATABASE_MIGRATION_LOCKED`.
**Failure mode:** avoidable migration denial of service in a shared PostgreSQL database. The current repository already uses separate `hashtext` advisory-lock names for migrate-tier, demonstrating the need for a documented namespace rather than a collision-prone implicit one.
**Required remediation:** freeze a two-int advisory-lock namespace (fixed documented class/object values) or a documented 64-bit `hashtextextended` key with fixed seed; keep acquisition, migration, verification, and release on the single `max:1` migrator session. Add tests for concurrent migration, connection loss/crash release, readiness while the lock holder is active, and an unrelated lock-key non-interference case.
### MEDIUM — identifier safety and `search_path` verification need executable constraints
The contract rightly requires `pg_catalog, <mosaic_application_schema>` and rejects writable paths (`KBN-101-DB-ROLE-SPLIT.md:54,70-77`), but uses dynamic placeholders for database/schema and does not state how migration/bootstrap SQL will avoid identifier interpolation. Existing code has raw-SQL facilities (`packages/storage/src/migrate-tier.ts` uses `.unsafe`), so this is not merely theoretical.
**Failure mode:** a future operator-configured database/schema value that reaches bootstrap or `SET search_path` through raw string construction can inject DDL, or a pooled connection can retain a mutable search path.
**Required remediation:** require fixed allowlisted identifiers or server-side identifier quoting (`format('%I', ...)`) only; never interpolate URL/config values into SQL. Set and verify the trusted path per connection/session before any query (`SET LOCAL` inside transactions where applicable), forbid `public`/`$user` additions, and add injection-shaped identifier and pooled-connection reset negatives. Include this in KBN-101-00/01 tests.
## Acceptance and threat traceability
| Requirement / threat | Review result | Evidence or blocking finding |
| --- | --- | --- |
| K101-REQ-01 / AC-K101-01 split runtime/migration URLs | Partial | Role/DTO boundary is coherent; HIGH DDL-path finding requires all current commands to be closed. |
| K101-REQ-02 / AC-K101-02 explicit migration/readiness | Blocked | HIGH ledger definition and HIGH DDL-bypass findings. |
| K101-REQ-03 / AC-K101-03 least privilege, TLS, grants | Partial | Role model, default privileges, ledger read-only, TEMP/function checks are well specified (`KBN-101...:47-56,70-79`); HIGH TLS bootstrap and MEDIUM identifier constraints remain. |
| K101-REQ-04 / AC-K101-04 immutable relations | Correctly deferred | KBN-101-09 after KBN-100 is the correct serial gate (`KBN-101...:58-66,115-118`); no synthetic-only certification claim found. |
| K101-REQ-05 / AC-K101-05 N-1, secrets, rollback | Partial | No owner-runtime exception and rollback keeps migration URL out of Gateway (`:83-95`); deployable TLS and full command inventory are missing. |
| K101-REQ-06 / AC-K101-07 KBN gates and DAG | Structurally sound | DAG is acyclic: 00→01/{03}; 02→06; 00/01/03→05; 00/04/05/06→07→08→KBN-100→09→KBN-105. KBN-100s current branch contains docs-only baseline tracking, not schema implementation. |
| T: runtime DDL / migration fallback | Blocked | HIGH finding 1. Current Gateway/storage, CLI, direct Drizzle scripts, and integration DDL require explicit closure. |
| T: race/crash/readiness | Partial | Same-session nonblocking lock and replica-unready rules are present (`:34-38`); lock namespace remediation required. |
| T: immutable evidence rewrite | Correctly staged | Explicit INSERT/SELECT-only matrix and RESTRICT retention are retained; proof is properly after table creation. |
| T: secret leakage / TLS downgrade | Partial | Redaction and distinct Vault paths are specified (`:93-97`), but no server TLS/bootstrap implementation contract exists. |
## Unresolved assumptions
1. `standalone` and `federated` are the complete PostgreSQL production-like set (K101-A1).
2. Each eligible deployment can execute a dedicated migration Job/one-shot phase (K101-A2).
3. Vault path names are targets, not verified existing paths; deployment ownership remains to be established.
4. PostgreSQL 17 is available for the selected membership and advisory-lock implementation.
5. The required server-side TLS issuer/certificate lifecycle and Swarm/compose secret transport have not been decided; this is blocking, not a permissible implicit plaintext bootstrap.
6. Historical databases containing the 0009 journal/ledger anomaly have no frozen reconciliation procedure.
## Independent test and consistency evidence
Read-only checks run in this review:
| Check | Result |
| --- | --- |
| `git diff --check origin/main...da742ca2...` | PASS |
| `pnpm exec prettier --check` on all seven changed docs | PASS |
| `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` | PASS |
| `docker compose -f docker-compose.yml config --quiet` (isolated test ports) | PASS |
| `docker compose -f docker-compose.federated.yml --profile federated config --quiet` (isolated test ports) | PASS |
| Static journal inspection | FAILS the required monotonic ordering premise: 0008 → 0009 `when` decreases; current runner documents skipping behavior. |
| Static DDL-entrypoint inventory | Found direct Drizzle scripts, storage CLI shell-out, runtime extension/migration calls, fleet backlog migration, tier probe extension creation, and a direct-DLL federated integration test. |
No live database, Vault, CI, deployment, issue, PR, or repository mutation was performed. The pass results validate documentation syntax/contract compilation and compose syntax only; they do **not** certify the proposed security behavior.
## Conclusion
Do not merge this frozen contract as implementation-ready until the HIGH findings are corrected and independently re-reviewed. The central role ownership/default-privilege design, immutable-table staging, and KBN-100/KBN-105 serial gating should be retained; they are not the reason for this REQUEST CHANGES verdict.

View File

@@ -1,153 +0,0 @@
# Scratchpad — KBN-101 DB runtime/migration role split (#771)
- **Branch:** `docs/771-kbn101-db-role-split`
- **Base:** `main` `e9c4aa3`
- **Scope:** planning/documentation only; authorized files are PRD, Native Kanban task/shared/index docs, sitemap, this scratchpad, and the new KBN-101 contract.
- **Explicit exclusions:** source/runtime/config/deployment/secret/migration/compose/CI/lock/package/KBN-100 branch edits; no production mutation.
## Objective
Freeze an implementation-ready PostgreSQL role/connection split so the Gateway uses a non-owner runtime identity and only a dedicated migration phase uses an owner/migrator identity. Make real deployed-role certification—not synthetic role tests—a serial prerequisite of KBN-100 and KBN-105.
## Intake and current-state evidence
- Mission MVP is active; W3 Native Kanban/SOT is planning-complete. The task state shows KBN-010 as the predecessor and KBN-100 as the current schema slice.
- Current branch started at `e9c4aa3`; `.mosaic/orchestrator/{mission.json,session.lock}` were already runtime-modified and remain untouched.
- `packages/db/src/client.ts`, `migrate.ts`, and `drizzle.config.ts` resolve one `DATABASE_URL` (with default fallback). `packages/storage/src/adapters/postgres.ts` calls `runMigrations(this.url)`.
- `apps/gateway/src/database/database.module.ts` calls `storageAdapter.migrate()` at startup for PostgreSQL; this is the owner-runtime defect to remove in KBN-101 implementation.
- `packages/config/src/mosaic-config.ts`, installer wizard, local/federated compose, Portainer test stack, and `.woodpecker/ci.yml` currently expose one URL. PGlite has an existing explicit local migration path.
- Current KBN contract requires immutable events/checkpoints/artifacts/evidence, `RESTRICT`, and KBN-100 generated Drizzle consistency. It did not establish a deployable runtime identity split.
## Frozen decisions
1. `DATABASE_URL` is the non-owner runtime URL; `DATABASE_MIGRATION_URL` is migration-only. Both are required in their respective PostgreSQL phases; PGlite is the explicit local exception; migration never falls back to runtime/default/config URL.
2. PostgreSQL Gateway runtime never auto-runs migration/DDL. Dedicated migrator uses `pg_try_advisory_lock(hashtext('mosaic-schema-migration-v1'))`; replicas only check exact ordered Drizzle-ledger readiness and fail closed.
3. Roles are non-login `mosaic_platform_database_owner`, non-login `mosaic_schema_owner`, login/noinherit `mosaic_migrator`, non-login `mosaic_runtime_capability`, and login/inherit `mosaic_runtime`. Runtime inherits only its capability role with SET/ADMIN denied, has no owner/migrator membership, no unsafe attributes/ownership/DDL authority, and an explicit trusted search path.
4. Runtime gets mutable DML only as needed, but INSERT/SELECT only on `task_events`, `artifacts`, `task_checkpoints`, `task_checkpoint_artifacts`, and `approval_decision_artifacts`. KBN-100 still enforces RESTRICT/no-cascade.
5. Startup verifies effective role/ownership/attributes/inherited capability/TEMP/function-execute/ledger grants/search path/immutable denials/schema fingerprint without DSN exposure. It also requires authenticated CA/hostname-verified TLS. Stable sanitized errors and redaction rules are required.
6. N-1 retains single runtime URL only as a non-certified compatibility release; staged role provisioning/migration/runtime deployment then enforces the split. Rollback never injects migration URL into Gateway.
7. Vault target paths, rotation, deployment injection, CI, installer, compose, Portainer, and observability are separate one-card/one-PR handoffs. The migration-only file manifest includes `packages/db/drizzle.config.ts`; KBN-101 repairs the known PostgreSQL runner/journal ordering defect and proves a clean database applies every hash once before its foundation certificate. No application migration creates roles/passwords or hardcodes credentials.
8. KBN-101 foundation merges/certifies first. KBN-100 then rebases, restores Drizzle declaration/snapshot/journal consistency, and bounds procedural immutable-table grant/trigger/backfill work to its own slice. Because those immutable relations do not exist until KBN-100, KBN-101s real deployed-role immutable-operation certificate follows KBN-100 and is the serial gate before KBN-105.
## Assumptions
- `standalone` and `federated` are all current PostgreSQL production-like modes; a future PostgreSQL tier inherits this contract unless versioned otherwise.
- Deployment will support a dedicated migration Job/one-shot command. A target that cannot run it cannot receive production/federated KBN certification.
- Canonical Vault target paths require deployment-owner verification before provisioning; the planning document does not claim they already exist.
## Documentation produced
- `docs/PRD.md`: bounded KBN-101 requirements and acceptance criteria.
- `docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md`: normative rc.5 implementation, threat, migration/rollback, evidence, and exact file DAG contract.
- `docs/native-kanban-sot/SHARED-CONTRACT.md`: rc.5 amendment preserving rc.4.
- `docs/native-kanban-sot/TASKS.md`: KBN-101 inserted before and blocks KBN-100; KBN-105 held.
- Native Kanban index and root sitemap links.
## Validation plan
1. Prettier only for changed Markdown.
2. Markdown link target/check checks scoped to modified docs.
3. Strict contract check with `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` (the frozen TypeScript contracts remain unchanged).
4. Diff allowlist proves only authorized documentation files changed, apart from pre-existing Mosaic runtime state.
5. Independent documentation/security self-review: role escalation, fallback, startup DDL, schema readiness, grants/default privileges, immutable tables, secret leakage, deployment and KBN-100 boundaries.
## Review corrections
Independent Codex review found two blockers and security review found two medium defects; all were remediated in the frozen contract:
1. Split the KBN-101 certificate into a foundation role/schema-boundary certificate (before KBN-100) and real immutable-operation certificate (after KBN-100, before KBN-105). This preserves the requested KBN-100 block without requiring evidence for tables not yet created.
2. Removed the legacy owner-runtime exception. N-1 compatibility preserves variable/config shape only; the KBN-101 runtime refuses owner/migrator identity and current single-URL installs remain on their previous release until role cutover.
3. Introduced `mosaic_platform_database_owner` as a separate non-login platform role. `mosaic_schema_owner` owns application/ledger schemas only, not the database and has no database CREATE/ALTER/extension authority.
4. Replaced blocking `pg_advisory_lock` with `pg_try_advisory_lock` and the deterministic `DATABASE_MIGRATION_LOCKED` failure.
5. Review also flagged active `.mosaic/orchestrator` state. It was pre-existing launcher state and remains unstaged/uncommitted.
6. Second review added `packages/db/drizzle.config.ts` to the migration-only slice, mandates `DATABASE_MIGRATION_URL` with a missing-variable negative, grants runtime only `USAGE` plus `SELECT` on `drizzle.__drizzle_migrations`, and verifies/revokes its ledger writes.
7. Security review added `DATABASE_TLS_CA_CERT_PATH` / `DatabaseTlsConfigDto` with authenticated TLS and hostname/CA verification in production-like modes, explicit database `TEMPORARY` revocation/catalog denial testing, and default-PUBLIC function EXECUTE revocation with SECURITY DEFINER prohibited by default.
8. Final review corrected the runtime login to inherit only its capability role with SET/ADMIN denied, and moved the known hash-complete migration-runner/journal repair into KBN-101-03 before the foundation certificate.
9. Final manifest review added all live runtime DDL paths (`packages/storage/src/tier-detection.ts`, Gateway startup, and `fleet-backlog`) to KBN-101-02, requiring read-only extension probes and no PostgreSQL runtime auto-migration. It also requires KBN-101-04 to stop persisting either DSN into generated `.env`/`mosaic.config.json`, using only Vault/deployment references and injected variables.
10. Provisioning review separated the external privileged platform bootstrap actor from the NOCREATEDB database-owner role and added KBN-101-00. That IaC/bootstrap card owns fresh/existing database role/ownership/grant/Vault transition evidence and is a foundation-certificate dependency.
## Results
- `pnpm exec prettier --check` on every authorized Markdown file: PASS.
- Markdown link and whitespace checker on all seven authorized Markdown files: PASS.
- `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json`: PASS (frozen strict contracts unchanged).
- Codex code review iterated through role inheritability, hash-complete migration ordering, all reachable runtime DDL entrypoints, installer DSN persistence, and platform-bootstrap ownership; each finding was incorporated into the final frozen contract/DAG. The last security review found no new KBN-101 vulnerability; its sole low finding is the pre-existing unstaged Mosaic session-lock metadata, which is excluded from this commit.
- Commit: `82ce3252df38a687c50485f8d048b53ca8db5989` (`docs(#771): freeze database runtime role split`).
- Pre-push queue guard: `ci-queue-wait.sh --purpose push -B main` returned `state=unknown` without failure. The push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`: PASS.
- Pushed branch `docs/771-kbn101-db-role-split` at the exact commit above; no PR was opened, merged, or closed. `web1:mosaic-100` received the handoff with head, decisions, DAG, and validation.
- Awaiting independent security/Ultron review.
## 2026-07-15 — rc.6 exact-head remediation session
- **Objective / correction:** Replace the prior planning-author handoff and close every finding in the [independent exact-head report](../reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) against `da742ca2da4a2ff466916c818fe275c4f7ffd384`. The report is a verbatim durable copy of the task-supplied review artifact; scope remains documentation-only, `.mosaic` is excluded, and source/config/compose/CI/deployment/secrets/migrations remain untouched.
- **Finding 1 — closed DDL control plane:** rc.6 names `mosaic-db-migrator` as the sole application/CI/test PostgreSQL DDL runner, requires `DATABASE_MIGRATION_URL` before connection/DDL, inventories `runMigrations`, Drizzle config/scripts, `db:push`, storage CLI, adapter/Gateway startup, fleet-backlog, extension probes/bootstrap, direct federated integration DDL, CI, and future scripts, and specifies route/deny/test disposition for each. Tests use runner-prepared disposable PostgreSQL or invoke that runner; `db:push` is local-disposable-only and rejects production-like URLs.
- **Finding 2 — deployable TLS:** rc.6 freezes distinct runtime/migrator URL and CA/server leaf Vault/compose/Swarm secret identifiers, `0400` key and `0600` URL/cert/CA mount requirements, actual compose/Swarm service-DNS SANs, PostgreSQL TLS settings, legacy-client drain/termination plus `hostssl` enforcement, verified-TLS readiness ordering, fresh/existing transition, CA-overlap rotation/TLS-only rollback, and standalone plus federated/Swarm positive and missing/wrong CA/SAN/downgrade negatives. PGlite is explicitly non-PostgreSQL evidence.
- **Finding 3 — manifest/0009:** rc.6 defines manifest v1 canonical UTF-8 serialization and raw SQL-byte SHA-256, logical journal order, manifest ownership/grants, exact one-to-one observed hash tuple mapping, non-normative physical insertion order, safe original-0009 conditions, ambiguous-effect fail-closed recovery, and the full required reconciliation/backup test matrix. It preserves shipped 0009 bytes and forbids manual ledger adoption/insertion.
- **Finding 4 — advisory lock:** replaced `hashtext` with fixed signed-int4-safe `(1297044289,1262636593)` (`MOSA`,`KBN1`), one `max:1` runner session, close-on-crash semantics, and contention/crash/readiness/unrelated-key evidence.
- **Finding 5 — identifiers/search path:** selects `mosaic`, exact `pg_catalog,mosaic` per pooled connection and `SET LOCAL` transactions, plans audited public-object/extension/Drizzle relocation, forbids config-derived identifiers, limits bootstrap quoting to server-side `%I` on fixed allowlist, and requires injection/pool-reset negatives.
- **Finding 6 — safe DAG:** cards 0007 are inactive prepared capability while owner-runtime remains N-1; KBN-101-08 is the one atomic activation release after platform roles/TLS and compatible code. Mosaic control plane/Jason alone can activate/rollback; no force-on-red, runtime bypass, or temporary compatibility survives the gate. The approved role graph, post-KBN-100 immutable certification, and KBN-105 gate remain unchanged.
- **Review remediation:** Codex review found the legacy plaintext cutover gap, missing URL-secret bindings, historical `public` migration incompatibility, non-reproducible checkout-byte hashing, CONNECT allowlisting regression, and undocumented direct-DDL operator instructions. rc.6 now requires drain/scale-to-zero, residual non-TLS session termination, `hostssl` with no `host` rule, zero plaintext-session proof, TLS-only post-enforcement rollback, distinct named runtime/migrator secret consumers, canonical Git-blob/LF manifest bytes, a runner-only owner-controlled legacy-public bootstrap followed by `mosaic` relocation, explicit CONNECT/TEMP revocation, and KBN-101-07 replacement of direct-DDL documentation. It also required the durable exact-head report link above. Pre-existing `.mosaic` runtime state remains excluded.
- **Validation:** Prettier on all changed Markdown, repository Markdown link/whitespace check, and strict native-kanban contract TypeScript passed before final staging; the final staged diff excludes `.mosaic`. No source-code TDD applies because this is contract-only remediation.
## 2026-07-15 — rc.7 residual remediation session
- **Objective / correction:** Close every residual in the independent exact-head rc.6 re-review at `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview-45ba3d6.md` for `45ba3d6ad4d5383f457a303c05bc816144cfa48a`, without changing source, compose, CI, deployment, migration, or secret artifacts. Only the existing authorized planning/documentation paths are eligible; pre-existing `.mosaic` state remains excluded.
- **Source-backed scope confirmed:** the active `federated-pgvector.integration.test.ts` executes `CREATE TEMP TABLE`; tracked `docker/init-db.sql` and `infra/pg-init/01-extensions.sql` both create `vector`; `migrate-tier.ts` advertises raw `CREATE EXTENSION`; and `tools/federation-harness/docker-compose.two-gateways.yml` is current plaintext two-PostgreSQL/two-Gateway topology. Current `schema.ts` has 36 default-schema `pgTable` declarations, 6 default `pgEnum` declarations, and an unqualified `vector` custom type; historical migrations contain `public` references.
- **Plan:** (1) make the finite DDL/static-bypass inventory and `DATABASE_URL`-only denial matrix exact, including the runner-prepared persistent pgvector fixture and migrated two-gateway harness; (2) freeze executable `public`-to-`mosaic` and `mosaic_extensions` transition, Drizzle ownership, object-catalog classes/order, eligibility and rollback tests; (3) bind repository/control-plane ownership, UID/GID validation, exact artifact/mount rules, and both gateway TLS topology; (4) correct PRD acceptance mapping and cross-document rc.7 status; then run formatting, link/contract, source-path, diff, review, commit, queue guard, and push.
- **Independent review closure:** initial Codex review found Gateway-key consumer wording, `CLAUDE.md` omission, final schema-owner set, and placeholder SANs; all are now explicit. Re-review found the legacy `0001` vector-type resolution problem and `docs/federation/SETUP.md` raw-DDL instruction; the legacy runner now uses only its fixed non-writable `pg_catalog,public,mosaic_extensions` history path, while runtime remains `pg_catalog,mosaic`, and the federation setup path is assigned to KBN-101-07/static inventory. Security review final verdict: no confident vulnerability. The review also repeated the pre-existing tracked `.mosaic` session-state concern; it remains deliberately unstaged/excluded by this task.
- **Completion evidence:** changed Markdown is Prettier-formatted; local links and strict native-kanban TypeScript passed; source-path inventory confirmed all current referenced paths (the new `apps/gateway/Dockerfile` is explicitly a planned KBN-101-05 artifact); diff check and authorized-doc allowlist passed. No source-code TDD applies to this documentation-only remediation.
## 2026-07-15 — rc.8 exact residual remediation session
- **Objective / correction:** Close all three HIGH findings in the independent rc.7 exact-head re-review at `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview2-0778eba.md` for `0778eba2db3c2dfbaca3af352b12ba0389d3552b`. Scope remains documentation-only: no source, config, Compose, CI, deployment, secret, or migration artifact changed; pre-existing `.mosaic/orchestrator` state remains excluded.
- **Finite authority closure:** KBN-101-06 now classifies exact current source/scripts/package bins, operator docs, and deploy manifests. `packages/db/src/index.ts` has explicit removal/compile-import negative ownership; `docs/fleet/backlog-conventions.md` and `docs/PERFORMANCE.md` now remove first-use/direct-Drizzle/Gateway-startup migration instructions and point to sole runner/readiness. Byte-immutable historical SQL, PGlite-only routines, negative-test literals, vendored/generated artifacts, and labeled historical reports are exact-path/category reviewed allowlists; unknown hits fail. The contract explicitly rejects relying on a naive token scan alone.
- **Executable and exclusive handoff closure:** KBN-101-03 exclusively owns the published `mosaic-db-migrator` bin, `packages/db/src/cli.ts`, private migrator modules, `docker/db-migrator.Dockerfile`, exact `--run|--verify|--help`, environment/argv limits, sanitized exits, and command/order tests. KBN-101-00 exclusively owns `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, and bootstrap tests. KBN-101-05 exclusively owns `tools/db/render-postgres-secrets.ts`, its tests, and deployment declarations, consuming the versioned bootstrap interface without overlap.
- **pgvector owner closure:** `mosaic_extension_owner` is dedicated NOLOGIN, available only to the external bootstrap actor during bootstrap; fresh vector/member ownership remains there. The contract records PostgreSQL's unsupported extension-owner transfer and forbids catalog mutation, ownership adoption, and `DROP CASCADE`. Approved-owner existing extensions use verified `ALTER EXTENSION ... SET SCHEMA`; legacy runtime-owned extensions fail closed to a controlled backup/shadow/runner/copy-evidence/quiesce/final-delta/atomic-switch/read-only-rollback migration. It requires `pg_extension.extowner`, member/schema/version, and runtime/migrator/schema-owner ALTER/DROP/member-update denial tests across clean, approved-owner, legacy shadow, partial/resume/rollback, and N-1.
- **Cross-document state:** PRD, KBN contract, shared contract, task decomposition, index, sitemap, current operator docs, and this scratchpad are rc.8-consistent. The only intended next action is a fresh independent exact-head re-review after validation/push.
- **Validation / review:** Prettier passed for all nine changed Markdown documents; local links passed (9 documents); `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` passed; source-path inventory passed (20 paths: 8 current, 12 explicitly planned); finite-authority requirement checklist and `git diff --check` passed. Manual documentation/security review checked the three requested paths, private-only runner boundary/exit contract, non-overlapping 00/03/05 ownership, extension-owner denial and shadow path, and `.mosaic` exclusion. No source-code TDD applies because this is contract-only remediation.
- **Delivery evidence:** committed `1423c2ad02b5471eab006fb4c878808e5b29c387` as `docs(#771): close role split rc.8 residuals`. Push queue guard returned `state=unknown` without error; push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`, all PASS; branch push succeeded. This final evidence append is committed next, then the exact remote head is verified. The only intended next action is a fresh independent exact-head re-review.
## 2026-07-15 — rc.9 final residual remediation session
- **Objective / correction:** Close the three findings in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview3-9cf5d2f.md` against exact head `9cf5d2f6641b14082dc3294e2a84d1fb4ccc019d`: move `mosaic_extensions` schema ownership to `mosaic_extension_owner`; replace all broad/conflicting KBN-101 card ownership with a complete disjoint exact-path/test manifest; and classify the current architecture-plan `db:migrate` instruction with pinned scanner mechanics. Scope remains documentation-only; no source/config/Compose/CI/deployment/secret/migration artifact and no `.mosaic` path may be modified.
- **Plan:** inspect current tracked source topology to name only existing paths; update the normative contract first and synchronize PRD/shared/task/index/sitemap/version language; run Prettier, changed-doc links, strict contract TypeScript, source/path and manifest-overlap checks, diff allowlist, independent documentation/security review; then stage docs only, commit, queue-guard, push, and verify exact remote SHA. No source-code TDD applies because this is contract-only remediation.
- **Closure implemented:** rc.9 makes `mosaic_extension_owner` create and own `mosaic_extensions`, `vector`, and members; the external bootstrap actor alone temporarily `SET ROLE`s for fresh/approved-owner work, while schema owner has only `USAGE` for legacy type resolution and never temporary `CREATE`. Catalog/default-ACL plus direct DDL/member denials now cover runtime, migrator, and schema owner through fresh, relocation, shadow/resume, and rollback evidence.
- **Delivery decomposition:** Replaced broad ownership with complete disjoint 0009 manifests, exact tests/evidence, producer-before-consumer edges, and an explicit no-intermediate-deploy N-1 activation statement. `packages/storage/src/{cli,migrate-tier}.ts` belongs only to -02; the current tracked init artifacts are retired by -02 as direct-DLL closure; -03 owns all runner/index/migrate/config/schema assets and exact compiled-bin/image mapping; -07 owns docs only; -08/-09 own evidence only.
- **Classifier closure:** -06 has exact scanner/inventory/matrix paths, canonical inventory fields, classes/dispositions, fixed token/rule set, exact allowlist categories/restrictions, and self-test requirements for unknown, duplicate-owner, ownerless, missing-path, and historical masking cases. The architecture plan now marks direct `db:migrate` superseded and uses `mosaic-db-migrator --run`.
- **Validation:** Prettier check, strict native-kanban contract TypeScript, changed-document local-link resolution, `git diff --check`, and an automated manifest-overlap/owner/current-source-path check passed. Targeted documentation/security review verified role ownership/default privileges/search path/preflight/legacy/shadow/rollback consistency, disjoint manifests/DAG/activation, scanner mechanics, exact bin/entrypoint, and no `.mosaic` staging intent. No source-code TDD applies because this is documentation-only remediation.
- **Next:** stage documentation only, commit, queue-guard, push, verify exact remote SHA, then wait for fresh exact-head review.
- **Delivery evidence:** committed `8cbad2bcd9bc7507052f74f35670ef7c8e39e44e` as `docs(#771): close role split rc.9 residuals`; `ci-queue-wait.sh --purpose push -B main` returned `state=unknown` without error; push-hook `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` all passed; push succeeded and `origin/docs/771-kbn101-db-role-split` resolved to that exact SHA. Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains modified but intentionally unstaged/excluded. The only next action is a fresh independent exact-head re-review.
## 2026-07-15 — rc.10 Ultron NO-GO remediation intake
- **Objective / scope:** Close only HIGH-1 and HIGH-2 in `/home/hermes/agent-work/reviews/771-kbn101-ultron-11f09a1.md` against exact head `11f09a15e43e72afda6a0374668996b4bda9e536`. Documentation only: preserve approved content; no source/config/Compose/CI/deploy/secret/migration edits and no `.mosaic` staging.
- **Plan:** (1) replace the impossible `NOLOGIN NOSUPERUSER` extension owner with the exact non-login, zero-member `NOLOGIN SUPERUSER` external-control exception and document the non-delegable superuser residual; (2) require the audited external superuser session to `SET ROLE`/`RESET ROLE` for fresh, approved-owner, and shadow extension work, then prove catalog ownership and all service-role denial; (3) assign the active migrate-tier guide exclusively to KBN-101-07, add its active secure route to the -06 inventory/matrix/scanner schema, and freeze `--target-url-file /run/secrets/mosaic_migrate_target_url` plus pre-migrated target/dedicated non-DDL importer requirements; (4) synchronize PRD/shared/tasks/index/sitemap/guide and rc.10 status; (5) validate formatting, links, contracts, source paths, finite operator inventory, diff, review, commit, queue guard, push, and exact remote SHA.
- **Target-image evidence before edits:** local `pgvector/pgvector:pg17` control file reports `default_version = '0.8.2'`, `relocatable = true`, and no `trusted`/`superuser` override (untrusted PostgreSQL extension). An isolated PostgreSQL 17 container proved a `NOLOGIN SUPERUSER` `mosaic_extension_owner` can create `mosaic_extensions` and `vector` under external-superuser `SET ROLE`, returns to the external session after `RESET ROLE`, has `rolcanlogin=false`, `rolsuper=true`, zero role members, exact extension/schema and owner-bearing-member ownership, and denies `SET ROLE`, `ALTER EXTENSION`, `DROP EXTENSION`, and schema ownership changes to runtime, migrator, schema owner, and data importer. Ownerless PostgreSQL catalog member classes (`pg_am`, `pg_cast`) were intentionally not misrepresented as ownable members.
- **TDD decision:** skipped as not applicable: this is a documentation-only contract remediation. Future KBN-101-00/-02/-06 tests are specified as the situational evidence; no source/test artifact is permitted in this task.
- **Final scope correction:** Per control-plane direction, remediation remains bounded to the two Ultron findings. The active guide is explicitly a non-operative KBN-101 contract until its owned implementation/activation lands; no additional design, source, CI, deployment, secret, or test artifact was added.
- **Validation evidence:** target-image/container role proof PASS (pgvector `0.8.2`, `relocatable=true`, trusted absent/untrusted; external-superuser `SET ROLE`/`RESET ROLE`; exact extension/schema/owner-bearing-member ownership; zero membership and service-role denials). Changed-doc Prettier, strict native-kanban contract TypeScript, local-link resolver (8 docs), finite operator-doc inventory (one active KBN-101-07 route with no credential argv in executable blocks), source-path check (6 current paths), and `git diff --check` PASS. Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains intentionally excluded.
## 2026-07-15 — rc.11 exact-head re-review remediation intake
- **Objective / scope:** Close only HIGH-1 and HIGH-2 in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview5-f60144e.md` against `f60144eb3eab6234ab01bda592052081c777897e`: target-bind the non-DDL tier importer with a runner-produced signed attestation, and disposition every current non-normative documentation scanner hit, including the active user-guide route and federation historical status. Documentation only; no source/config/Compose/CI/deployment/secret/migration edits and no `.mosaic` staging.
- **Frozen decision:** `mosaic-db-migrator --verify` is trusted only after TLS/identity/manifest/schema verification and signs a credential-free JCS/Ed25519 v1 artifact from a runner-only fixed root-owned private-key file. The artifact binds secret version/exact URL-file digest, canonical TLS/CA/SPKI/server/database/importer/manifest/schema identity, issued/expiry/nonce, and producer build/correlation; importer gets pinned public key plus artifact only. It validates both files and all bindings before target connection, opens/digests/connects from one in-memory URL read, validates server identity before DML, and distinguishes zero connection from zero DML. Key overlap/revocation, secret-rotation invalidation, replay cache, atomic rename, and sanitized errors are mandatory.
- **Ownership:** -03 owns producer/signing DTO/tests; -02 importer interface/verification tests; -05 key/artifact mounts/render tests; -06 inventory/matrix and non-masking scanner tests; -07 operator guide. The exact manifests remain disjoint.
- **Operator correction:** `storage migrate` is schema-wrapper delegation only; legacy `--from hot --to cold` tier-copy guidance is unavailable. Secure tier copy is `migrate-tier` with `--target-url-file` plus `--target-attestation-file`. Federation M1 task language is status-only and adjacent KBN-101 text says it authorizes no current DDL.
- **TDD decision:** skipped as not applicable: this bounded task changes documentation only. Future -02/-03/-05/-06 tests are specified as the required implementation evidence.
- **Validation / review:** Prettier PASS on all 18 changed Markdown/root-doc files; `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` PASS; changed-doc local-link resolver PASS (71 links); full current docs scanner/disposition PASS (10 non-normative paths, 4 normative-contract paths; no unknown active command); legacy `storage migrate --from` and raw target-URL bypass scan PASS; attestation field/interface assertion PASS; exact source ownership/manifest-overlap assertion PASS; `git diff --check`, docs-only scope, and secret-leak scan PASS. Manual documentation/security review verified key isolation, JCS/detached signature, secret-file hash as non-secret evidence, verification ordering/TOCTOU/replay/rotation, no connection vs zero DML, non-masking scanner class, status-only federation history, and no regression to pgvector closure. No source-code TDD applies.
- **Delivery evidence:** committed `6227f076c819bd124383851633b16d4ef9c88a98` as `docs(#771): bind tier importer to verified target`; staged scope was 18 documentation files only and excluded `.mosaic`. Pre-push queue guard returned `state=unknown` without failure. Push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`: PASS. Branch push succeeded. Verify the exact remote SHA after this final delivery-evidence append, then idle for independent exact-head re-review and Ultron reverify. `.mosaic/orchestrator/{mission.json,session.lock}` remains pre-existing and excluded.
## 2026-07-15 — rc.12 bounded deployable-importer/SETUP remediation plan
- **Objective / scope:** Close the two HIGH findings in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview6-65663d4.md` against exact head `65663d4f72f2ace5148bce9aeba04b5a8d5beee9`. Documentation/tracking only; preserve every earlier closure; do not modify source, deployment, Compose, CI, migration, Vault, or `.mosaic` artifacts.
- **Plan:** (1) make KBN-101-05 own canonical KV-v2 importer URL/version provenance, separate immutable generation-pinned renderer consumers, importer CA/public-key/attestation mounts, fixed importer/migrator identities, safe-fd lifecycle, isolation/rotation/TOCTOU/error evidence, and -02/-03/-06 handoffs; (2) convert `docs/federation/SETUP.md` to a non-operative N-1 reference with only the required external-bootstrap → TLS/roles → runner `--run``--verify` → Gateway-readiness sequence; (3) broaden scanner grammar plus path-specific semantic negatives so indirect first-boot/startup/init/Compose authority cannot be masked by a named, normative, or status record; (4) synchronize PRD/shared/tasks/index/sitemap/federation task state and this scratchpad; (5) run formatting, links, strict contracts, complete-doc scanner/semantic assertions, diff/operator inventory, material/manifest overlap, review, docs-only stage, commit, queue guard, push, and exact remote-SHA verification.
- **TDD decision:** skipped because this bounded change is documentation-only; the affected -02/-03/-05/-06 implementation tests and scanner semantic fixtures are specified as mandatory future evidence.
- **Review correction:** independent Codex review found the initial `10003` producer → immutable `10002` importer artifact handoff impossible. rc.12 now specifies the required privileged deployment handoff controller: after runner success it safe-opens/verifies producer artifact plus generation, exact-byte copies/fsyncs/atomically renames to a distinct `10002:10002` `0400` importer mount, seals it read-only, and starts no importer on partial/wrong-generation/owner/mode failure. It receives only a root-owned non-secret expected-version/URL-digest/generation descriptor plus public verifier key, never URL bytes/private key; producer/importer share no writable file or mount. Dry-run nonce consumption now requires fresh `--verify` and artifact before `--yes`; the active-route schema requires `targetCredentialVersionFile`. Pre-existing `.mosaic` state is confirmed excluded from staging.
- **Validation result:** changed-doc Prettier and `git diff --check` PASS; strict `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` PASS; contract/SETUP material and non-operative semantic assertions PASS. Pending final docs-only stage, commit, queue guard, push, and remote-head verification.
## 2026-07-15 — rc.13 MILESTONES semantic-scan remediation intake
- **Objective / scope:** Close only HIGH-1 from `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview7-7365dcf.md` against `7365dcf15c09262a46132b9c011769ad98243641`. Documentation/tracking only: assign `docs/federation/MILESTONES.md` exclusively to KBN-101-07 and its exact former startup-extension wording to the KBN-101-06 semantic fixture/inventory; replace the wording with a non-operative historical/status disposition. No source, deployment, Compose, CI, Vault, migration, provider, or `.mosaic` artifact is authorized.
- **Frozen remediation:** runtime/startup extension provisioning is superseded and forbidden. The sole eligible sequence is external bootstrap → TLS/roles → `mosaic-db-migrator --run``mosaic-db-migrator --verify` → Gateway readiness. The MILESTONES record authorizes no current DDL, Compose/init, or startup path. The scanner must prove the exact former wording fails before any inventory/status-only mask and rerun its full current-doc operator/deploy-manifest scan outside reports/scratchpads.
- **Plan:** update only MILESTONES plus the exact KBN-101 contract/inventory/manifests and necessary PRD/shared/task/index/sitemap/version/status references; run full lexical+semantic scan, Prettier, links, strict contract TypeScript, diff and manifest-overlap checks; stage docs only (excluding pre-existing `.mosaic`), commit, queue-guard, push, and verify the exact remote SHA. No source-code TDD applies because this bounded task changes documentation only.
- **Remediation result (pre-commit):** `MILESTONES.md` now makes runtime/startup extension provisioning superseded and forbidden, with only external bootstrap → TLS/roles → `mosaic-db-migrator --run``--verify` → Gateway readiness; it authorizes no current DDL/Compose/init/startup path. The KBN-101-07 manifest/inventory is exclusive and KBN-101-06 documents the exact former wording as a semantic negative that fails before inventory masking. Full current-doc scan outside reports/scratchpads: 103 Markdown files, 10 lexical-hit paths classified, 2 owned Compose-before-runner references, and zero ownerless indirect/literal routes. Prettier, strict native-kanban TypeScript, local links (64/0), diff check, and 95-path manifest-overlap reconstruction (0 overlaps; MILESTONES only -07) passed. Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains excluded.

View File

@@ -0,0 +1,110 @@
# FCM-M2-001 — Generated Environment Boundary
- **Issue/card:** #758 / FCM-M2-001
- **Branch/base:** `feat/758-generated-env-boundary` from `origin/main` `e9c4aa3e8b3780719cd5a43c0ef3f37fc70de666`
- **Budget assumption:** 30K-card budget; implement only the deterministic generated/local environment boundary and its launch-chain/docs/tests.
## Objective
Replace the generic fleet agent `.env` authority/merge path with a deterministic roster-derived `<agent>.env.generated` projection and strict, data-only `<agent>.env.local`. The roster remains the desired-state authority. Reject bad input before the launcher creates a tmux session; never print sensitive or privileged-command values.
## Scope and non-goals
- In scope: deterministic render/write, strict generated/local parse rules, legacy `.env` disposition/quarantine, systemd/launcher boundary, permission/path checks, focused fail-closed tests, operator/reference documentation, USC interface evidence.
- Excluded: roster CRUD/mutation, v2 roster schema changes, lifecycle/reconcile/apply behavior, migration/canary rollout, connectors, remote surfaces, live-fleet actions, and M2-002.
## Plan
1. Add red tests for generated-key shadowing, malformed/duplicate/unknown/command/sensitive input, no-value diagnostics, deterministic/idempotent projection, secure file modes, and legacy disposition.
2. Implement a pure strict environment contract plus atomic projection/quarantine helper.
3. Replace the generic `.env` writer/merge path and systemd reference with `.env.generated` + `.env.local` ownership.
4. Make the shell launcher parse the files without `source`/`eval`, reject unsafe input before tmux creation, and construct only the roster-derived runtime command.
5. Add operator/reference documentation with the requested USC M1 interface evidence and M2M4 gate statement.
6. Run focused/package/root gates and audit the USC interface packet. Per continuation scope, stop before review, commit, push, PR, or live mutation.
## Initial evidence
- No existing owner: target worktree path absent; no target local/remote branch; `pr-list.sh -s open` returned no open PRs.
- M1 compiler/API/docs and executable disposition evidence are present at the assigned base.
- Existing launch chain writes `fleet/agents/<agent>.env`, preserves arbitrary legacy lines via `mergeAgentEnv`, sources `MOSAIC_AGENT_COMMAND`, and executes it through `bash -c`; all are M2 remediation targets.
- `~/.config/mosaic/guides/SECURITY.md` is absent. Read the available security-review role contract and the vault/secrets guide instead.
## Verification log
### Continuation (2026-07-14)
- Preserved the inherited 14-file delta; no reset, stash, rebase, roster mutation, lifecycle action,
live-fleet action, commit, push, or PR action was performed.
- Focused gates passed:
- `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` — 1 file, 10 tests passed.
- `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` — passed.
- `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — passed.
- `pnpm --dir packages/mosaic test -- src/commands/fleet.spec.ts` — 1 file, 192 tests passed.
- Package gates passed before final documentation/format follow-up:
- `pnpm --dir packages/mosaic typecheck` — passed.
- `pnpm --dir packages/mosaic lint` — passed.
- `pnpm --dir packages/mosaic test` — 52 files, 752 tests passed.
- `pnpm format:check` initially failed only for the new boundary reference and generated-boundary
TypeScript files; targeted Prettier normalization was applied. A final `pnpm format:check` passed.
- USC packet audit: the M1 structural compiler is `parseRosterV2` with roster `version: 2`; the
semantic resolver is `validateRosterV2Semantics`; disposition artifacts retain `version: 1` fixture
evidence. `docs/TASKS.md` records M1-001 done, M1-002 in-progress, and M1-003 not-started; no
product release version is claimed. The packet now distinguishes these statuses from checkout
artifact presence and states the M2 → M3 → M4 downstream gates.
## Review remediation (2026-07-14)
- **Blocker 1 red-first:** Added a launcher reproducer with a `0777` `fleet/agents` parent and a private generated file. Before implementation, `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` failed: `FAIL: generated file under a world-writable parent was accepted`. The failure occurred after the launch path reached fake tmux, proving the parent was not validated.
- **Blocker 2 red-first:** Added a `symlink()` projection-directory reproducer that preloads generated/local/quarantine/legacy target files and asserts no target mutation. Before implementation, `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` failed the new test because the existing writer followed the `agentEnvDir` symlink and parsed its target legacy input (`expected /unsafe-directory/i`, received `code=malformed-line`). The initial test-only missing `mkdir` import was corrected before recording this behavior failure.
- **Blocker 3 red-first:** Added fresh/stale/absent native-heartbeat regression coverage. Before implementation, an isolated fake-tmux launcher reproducer with a fresh `<agent>.hb.native` marker failed `FAIL: fresh native heartbeat was overwritten`; the existing sidecar immediately replaced native `status=busy`/`model` content.
- **Remediation result:** The launcher now rejects a group/world-accessible or symlinked `fleet/agents` parent before an environment read or tmux call. The projection writer uses `lstat` before chmod/write processing and rejects a symlinked directory without creating generated/local/quarantine files or deleting legacy input. The heartbeat sidecar defers to a fresh non-symlink native marker and falls back when stale/absent. Focused green evidence before independent review: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` and `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` (11 tests) passed.
- **Independent-review follow-up red-first:** Codex code review returned one blocker and security review one medium CWE-732 finding: the writer repaired an already `0777` directory with `chmod` before trusting its contents. Added a reproducer with a safe local file beneath an existing `0777` directory. Before the follow-up fix, `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` failed because the promise resolved and wrote `coder0.env.generated` instead of rejecting.
- **Independent-review remediation:** Existing directories now pass non-following private-directory validation before any read or chmod; only a directory created in this call is normalized to `0700`. The fleet-add test fixture now creates its simulated trusted `fleet/agents` boundary at `0700`; this corrects fixture setup to match the new required contract rather than weakening the rejection assertion. Focused reruns passed: generated-boundary 12 tests, launcher boundary suite, and fleet suite 192 tests.
- **Final verification before re-review:** Launcher + systemd suites passed; package suite passed (52 files, 754 tests); package lint/typecheck, root typecheck (42 tasks), format check, and diff check passed. The rerun code review still reports a tmux command-arity blocker, and the security rerun reports systemd `EnvironmentFile` pre-validation injection findings for both agent units. These were discovered after the specified three-remediation scope; no additional source changes were made. Independent review therefore remains `REQUEST CHANGES` despite the requested three fixes passing their behavioral suites.
## Systemd pre-validation remediation (2026-07-14)
- **Red-first:** Updated the fleet unit contract to reject any `EnvironmentFile=` projection preload, require a cleared bootstrap environment, and require a validated exact-stop path. Before implementation, `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` failed: `FAIL: agent units must not preload projections before strict parsing`.
- **Red-first parser/stop coverage:** Added interaction-wrapper and exact-stop cases to the launcher boundary suite. Before implementation, `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` failed: `FAIL: interaction did not use shared strict parser first`, because the interaction wrapper consumed inherited environment before projection validation.
- **Focused green:** Both unit templates now use `env -i` with fixed `HOME`, agent instance, and PATH; neither has `Environment=`/`EnvironmentFile=`. The interaction wrapper delegates to `start-agent-session.sh --interaction`, so strict generated/local parsing precedes pinned Pi profile checks. `--stop` reuses the strict generated parser before exact `=<agent>` socket/session termination. Passed: systemd unit suite, launcher boundary suite (including malformed interaction, pinned profile, and ambient-socket stop cases), and 210 focused TypeScript tests.
- **Final verification:** `pnpm --dir packages/mosaic test` passed (52 files, 754 tests); package lint/typecheck, root typecheck (42 tasks), format/diff, and shell syntax checks passed. Security review passed with no findings. Code review repeated the previously refuted tmux argv concern and a pre-existing Claude trust-lock suggestion; per the assigned narrow follow-up, no tmux or unrelated trust-path change was made.
## Risks and next review
- This card is uncommitted and unreleased. The canonical tracker still records its dependencies as
M1-002 in progress and M1-003 not started; this continuation does not reinterpret those task states.
- Final post-documentation checks passed: `pnpm --dir packages/mosaic typecheck`,
`pnpm --dir packages/mosaic lint`, `pnpm --dir packages/mosaic test` (52 files, 752 tests),
`pnpm typecheck` (42 Turbo tasks), and `pnpm format:check`.
- Obtain independent code and security review of the complete delta next. Do not run commit, push,
PR, or live-fleet commands in this continuation.
## Fresh-install directory remediation (2026-07-14)
- **Objective:** Remediate only the fresh-install path where `installFleet` created `fleet/agents`
with host-umask permissions before the boundary writer correctly rejected it.
- **Plan:** Add a real `fleet install --no-enable` integration reproducer; prove red; let the
existing boundary writer own directory creation; run focused and full gates. No commit, push,
PR, review disposition, or live-fleet action.
- **Red evidence:** Before the one-line remediation,
`pnpm --dir packages/mosaic test -- src/commands/fleet.spec.ts` failed the new test with
`AgentEnvBoundaryError: code=unsafe-permissions` at `ensurePrivateProjectionDirectory`, after
`installFleet` pre-created the directory.
- **Change:** Removed only the recursive `mkdir(activePaths.agentEnvDir)` in `installFleet`.
`writeAgentEnvironmentProjection` remains the sole creator and retains its existing `lstat`,
private-directory, symlink, and existing-unsafe-directory fail-closed checks.
- **Focused green:** `pnpm --dir packages/mosaic test -- src/commands/fleet.spec.ts` — 193 tests
passed. The new integration executes a fresh `fleet install --no-enable`, asserts a real
non-symlink `0700` directory and a `0600` generated projection. Existing unsafe-directory
coverage remains in `generated-env-boundary.spec.ts` and asserts no chmod repair/no generated
file write.
- **Full gates green:** generated-boundary 12 tests; launcher and systemd suites; package
typecheck/lint and 52 files / 755 tests; root typecheck (42 tasks), lint, format, diff check,
and root test (42 tasks) all passed.
- **Independent review:** The complete inherited uncommitted delta still has Codex `REQUEST CHANGES`
findings outside this narrow fix (tmux command arity and Claude trust-lock regression), plus a
security-review medium finding on unvalidated writable ancestor directories. No out-of-scope
source changes were made.
- **Risk:** The writer's existing create-then-validate sequence is relied on for the creation
boundary; a concurrent substitution causes fail-closed validation rather than repair. The
review findings above remain residual risks for the complete card delta.

View File

@@ -9,7 +9,8 @@ package, normally at:
``` ```
The default tmux socket is `mosaic-fleet` so fleet commands do not touch the The default tmux socket is `mosaic-fleet` so fleet commands do not touch the
default tmux server. default tmux server. The roster is the desired-state authority; generated environment files are
rebuildable projections, never a second source of configuration.
## Examples ## Examples
@@ -31,6 +32,17 @@ The installed `tools/fleet/print-interaction-effective-policy.sh` prints only
the resolved name, runtime, model, reasoning, and tool policy. It never reads the resolved name, runtime, model, reasoning, and tool policy. It never reads
or prints credential variables. or prints credential variables.
## Generated agent environment boundary
`mosaic fleet install` writes a private deterministic projection at
`~/.config/mosaic/fleet/agents/<agent>.env.generated`. It may relocate only approved local machine
data to `<agent>.env.local`; generated keys, arbitrary commands, secret-like keys, duplicate keys,
unknown keys, and unsafe permissions fail before a tmux session is created. Legacy `.env` input is
regenerated, relocated, or quarantined and is not a launch authority.
See [`docs/fleet/reference/generated-env-boundary.md`](../../../../docs/fleet/reference/generated-env-boundary.md)
for allowed local keys and the USC downstream interface evidence.
Initialize a roster: Initialize a roster:
```bash ```bash

View File

@@ -24,45 +24,56 @@ The agent template calls:
which starts or reuses a tmux session on `MOSAIC_TMUX_SOCKET`. which starts or reuses a tmux session on `MOSAIC_TMUX_SOCKET`.
## Local customization ## Generated environment and local data
Per-agent overrides live outside the package in: The roster-derived projection is written outside the package at:
```text ```text
~/.config/mosaic/fleet/agents/<agent>.env ~/.config/mosaic/fleet/agents/<agent>.env.generated
``` ```
Example: Systemd does not read either environment file. It starts the launcher with a fixed cleared bootstrap
environment; before it creates, queries, or stops an exact agent tmux session, `start-agent-session.sh`
strictly parses the generated projection and the optional local data file:
```dotenv ```text
MOSAIC_TMUX_SOCKET=mosaic-fleet ~/.config/mosaic/fleet/agents/<agent>.env.local
MOSAIC_AGENT_RUNTIME=claude
MOSAIC_AGENT_WORKDIR=$HOME/src/your-project
# Optional escape hatch for PoC/canary agents:
# MOSAIC_AGENT_COMMAND=mosaic yolo claude
``` ```
The local file may contain only safe machine-specific data (`MOSAIC_RUNTIME_BIN`, heartbeat paths or
interval, and Claude configuration paths). It cannot override roster-derived keys, carry a command,
or contain secret-like/unknown keys. Both files must be private regular files. Do not hand-edit the
generated projection; update the roster and regenerate it instead. A legacy `<agent>.env` is
consumed only for regeneration, strict relocation, or private quarantine and is never launch input.
See `docs/fleet/reference/generated-env-boundary.md` for the full contract.
## Manual canary sequence ## Manual canary sequence
```bash Use the roster and the supported installer; do not pre-create the agent environment directory or
mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents edit a generated projection. `mosaic fleet install` validates the roster, installs the units and
cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/ helpers, and writes private roster-derived projections before any service is started.
cp packages/mosaic/framework/tools/fleet/*.sh ~/.config/mosaic/tools/fleet/
chmod +x ~/.config/mosaic/tools/fleet/*.sh
systemctl --user daemon-reload
systemctl --user start mosaic-tmux-holder.service
systemctl --user start mosaic-agent@canary.service
tmux -L mosaic-fleet ls
# For an operator-interaction service, the roster/env identity selects the ```bash
# generic unit instance; no service source is renamed for an instance. # Create a site-owned canary roster. Inspect an existing roster before using --force.
mosaic fleet init --profile minimal --write
mosaic fleet install
systemctl --user daemon-reload
mosaic fleet start canary-pi
tmux -L mosaic-fleet ls
```
For an operator-interaction service, first put `<agent-name>` in the roster with the pinned Pi
runtime, model, reasoning, and `operator-interaction` tool policy. Re-run `mosaic fleet install` after
that roster change so it writes `<agent-name>.env.generated`; ambient `MOSAIC_AGENT_*` values are not
launch authority. The generic unit instance uses that generated identity, and no service source is
renamed for an instance:
```bash
mosaic fleet install
systemctl --user daemon-reload
systemctl --user start mosaic-interaction-agent@<agent-name>.service systemctl --user start mosaic-interaction-agent@<agent-name>.service
MOSAIC_AGENT_NAME=<agent-name> \ ~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh <agent-name>
MOSAIC_AGENT_RUNTIME=pi \
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol \
MOSAIC_AGENT_REASONING=high \
MOSAIC_AGENT_TOOL_POLICY=operator-interaction \
~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh
``` ```
Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant

View File

@@ -7,16 +7,13 @@ PartOf=mosaic-tmux-holder.service
[Service] [Service]
Type=oneshot Type=oneshot
# Remove loader and noninteractive-shell controls before ExecStart loads env.
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
RemainAfterExit=yes RemainAfterExit=yes
# No default MOSAIC_TMUX_SOCKET: an absent roster socket means the literal # Never preload the projection. The launcher starts from a fixed minimal
# default tmux socket (no -L). The per-agent .env sets it when the roster names # environment and strictly validates generated/local data before tmux effects.
# one; otherwise it stays unset and start-agent-session.sh uses the default socket. ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
Environment=MOSAIC_AGENT_NAME=%i ExecStop=-/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh --stop %i
Environment=MOSAIC_AGENT_RUNTIME=pi
Environment=MOSAIC_AGENT_WORKDIR=%h
EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi'
[Install] [Install]
WantedBy=default.target WantedBy=default.target

View File

@@ -7,11 +7,13 @@ PartOf=mosaic-tmux-holder.service
[Service] [Service]
Type=oneshot Type=oneshot
# Remove loader and noninteractive-shell controls before ExecStart loads env.
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
RemainAfterExit=yes RemainAfterExit=yes
Environment=MOSAIC_AGENT_NAME=%i # The interaction wrapper delegates to the shared strict parser before pinned
EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env # profile checks; no projection data reaches Bash through systemd.
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i
ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi' ExecStop=-/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh --stop %i
[Install] [Install]
WantedBy=default.target WantedBy=default.target

View File

@@ -6,10 +6,11 @@ After=default.target
[Service] [Service]
Type=oneshot Type=oneshot
RemainAfterExit=yes RemainAfterExit=yes
Environment=MOSAIC_TMUX_SOCKET=mosaic-fleet # The holder owns the tmux server, so clear loader, shell-control, and stale
Environment=MOSAIC_TMUX_HOLDER=_holder # manager/session variables before the server process starts.
ExecStart=/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" "while true; do sleep 3600; done"' UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
ExecStop=-/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server' ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet MOSAIC_TMUX_HOLDER=_holder /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-tmux-holder.sh
ExecStop=-/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet /bin/bash --noprofile --norc -c 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server'
[Install] [Install]
WantedBy=default.target WantedBy=default.target

View File

@@ -5,6 +5,8 @@ SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service" HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service"
AGENT="$SCRIPT_DIR/mosaic-agent@.service" AGENT="$SCRIPT_DIR/mosaic-agent@.service"
INTERACTION="$SCRIPT_DIR/mosaic-interaction-agent@.service" INTERACTION="$SCRIPT_DIR/mosaic-interaction-agent@.service"
HOLDER_START="$SCRIPT_DIR/../../tools/fleet/start-tmux-holder.sh"
START_AGENT="$SCRIPT_DIR/../../tools/fleet/start-agent-session.sh"
fail() { fail() {
echo "FAIL: $*" >&2 echo "FAIL: $*" >&2
@@ -14,16 +16,40 @@ fail() {
[ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service" [ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service"
[ -f "$AGENT" ] || fail "missing mosaic-agent@.service" [ -f "$AGENT" ] || fail "missing mosaic-agent@.service"
[ -f "$INTERACTION" ] || fail "missing mosaic-interaction-agent@.service" [ -f "$INTERACTION" ] || fail "missing mosaic-interaction-agent@.service"
[ -x "$HOLDER_START" ] || fail "missing executable start-tmux-holder.sh"
[ -x "$START_AGENT" ] || fail "missing executable start-agent-session.sh"
grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart" grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart"
grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket" grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket"
grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit" grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit"
grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$HOLDER" || \
fail "holder does not remove loader and shell-control variables"
grep -qF 'ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet MOSAIC_TMUX_HOLDER=_holder /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-tmux-holder.sh' "$HOLDER" || \
fail "holder does not clear manager environment before starting tmux"
grep -qF 'ExecStop=-/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet /bin/bash --noprofile --norc -c' "$HOLDER" || \
fail "holder stop does not clear manager environment"
if grep -qF -- '/bin/bash -lc' "$HOLDER"; then
fail "holder must not start tmux through a login shell"
fi
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder" grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh" grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session" if grep -qE '^Environment(File)?=' "$AGENT" "$INTERACTION"; then
fail "agent units must not accept ambient or projection environment before strict parsing"
fi
grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$AGENT" || \
fail "agent unit does not remove loader and shell-control variables"
grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$INTERACTION" || \
fail "interaction unit does not remove loader and shell-control variables"
grep -qF 'ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc' "$AGENT" || \
fail "agent unit does not clear bootstrap environment before strict parsing"
grep -qF 'start-agent-session.sh --stop %i' "$AGENT" || \
fail "agent stop does not use the validated exact-stop path"
grep -qF 'Requires=mosaic-tmux-holder.service' "$INTERACTION" || fail "interaction service does not require holder" grep -qF 'Requires=mosaic-tmux-holder.service' "$INTERACTION" || fail "interaction service does not require holder"
grep -qF 'EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env' "$INTERACTION" || fail "interaction service does not require per-agent config" grep -qF 'ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc' "$INTERACTION" || \
grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not validate before startup" fail "interaction unit does not clear bootstrap environment before strict parsing"
grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not use shared strict parsing"
grep -qF 'start-agent-session.sh --stop %i' "$INTERACTION" || \
fail "interaction stop does not use the validated exact-stop path"
if command -v systemd-analyze >/dev/null 2>&1; then if command -v systemd-analyze >/dev/null 2>&1; then
systemd-analyze verify --user "$HOLDER" "$AGENT" "$INTERACTION" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || { systemd-analyze verify --user "$HOLDER" "$AGENT" "$INTERACTION" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
@@ -32,4 +58,102 @@ if command -v systemd-analyze >/dev/null 2>&1; then
} }
fi fi
# Real isolated socket regression: a preexisting server with an LD_PRELOAD
# constructor marker must fail closed, while a fresh named server is created.
if command -v tmux >/dev/null 2>&1 && command -v cc >/dev/null 2>&1; then
TEST_ROOT=$(mktemp -d)
TEST_SOCKET="mosaic-holder-test-$$"
trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT
MARKER="$TEST_ROOT/loader-marker"
LIBRARY="$TEST_ROOT/marker.so"
HOLDER_HOME="$TEST_ROOT/holder-home"
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
printf '123e4567-e89b-12d3-a456-426614174000\n' > \
"$HOLDER_HOME/.config/mosaic/fleet/run/holder-owner"
chmod 600 "$HOLDER_HOME/.config/mosaic/fleet/run/holder-owner"
cat > "$TEST_ROOT/marker.c" <<'EOF'
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
__attribute__((constructor)) static void mark_loader(void) {
const char *path = getenv("MOSAIC_LOADER_MARKER");
if (path != NULL) {
int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600);
if (fd >= 0) { write(fd, "loaded\\n", 7); close(fd); }
}
}
EOF
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \
tmux -L "$TEST_SOCKET" new-session -d -s _holder 'sleep 60'
[ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor"
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
: > "$MARKER"
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START" \
>"$TEST_ROOT/holder.out" 2>&1; then
fail "holder adopted contaminated named server"
fi
grep -qF 'global environment does not match the owned-server contract' "$TEST_ROOT/holder.out" || \
fail "holder did not report contaminated server environment"
[ "$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')" = "$server_pid" ] || \
fail "holder replaced a contaminated server instead of failing closed"
[ ! -s "$MARKER" ] || fail "holder execution triggered a contaminated loader"
# Agent validation must reject the same unmanaged server without cleaning its
# global environment or adding a managed session.
AGENT_HOME="$HOLDER_HOME/.config/mosaic"
AGENT_NAME=loader-safe
AGENT_WORKDIR="$AGENT_HOME/work"
AGENT_BIN="$TEST_ROOT/agent-bin"
mkdir -p "$AGENT_HOME/fleet/agents" "$AGENT_WORKDIR" "$AGENT_BIN"
chmod 700 "$AGENT_HOME/fleet/agents"
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
MOSAIC_AGENT_NAME=$AGENT_NAME
MOSAIC_AGENT_CLASS=code
MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=
MOSAIC_AGENT_REASONING=
MOSAIC_AGENT_TOOL_POLICY=code
MOSAIC_AGENT_WORKDIR=$AGENT_WORKDIR
MOSAIC_TMUX_SOCKET=$TEST_SOCKET
EOF
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$AGENT_BIN" > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.local"
chmod 600 "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" \
"$AGENT_HOME/fleet/agents/$AGENT_NAME.env.local"
cat > "$AGENT_BIN/mosaic" <<'EOF'
#!/bin/sh
sleep 30
EOF
chmod 700 "$AGENT_BIN/mosaic"
server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort)
server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort)
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
"$START_AGENT" "$AGENT_NAME" >"$TEST_ROOT/agent.out" 2>&1; then
fail "agent launcher adopted contaminated named server"
fi
[ "$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')" = "$server_pid" ] || \
fail "agent launcher changed unmanaged server PID"
[ "$(tmux -L "$TEST_SOCKET" show-environment -g | sort)" = "$server_environment_before" ] || \
fail "agent launcher changed unmanaged global environment"
[ "$(tmux -L "$TEST_SOCKET" list-sessions | sort)" = "$server_sessions_before" ] || \
fail "agent launcher changed unmanaged sessions"
tmux -L "$TEST_SOCKET" kill-server
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START"
tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created"
if tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then
fail "fresh holder retained LD_PRELOAD"
fi
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
"$START_AGENT" "$AGENT_NAME"
tmux -L "$TEST_SOCKET" has-session -t "=$AGENT_NAME:0.0" || \
fail "agent did not launch on a valid owned server"
tmux -L "$TEST_SOCKET" kill-server
trap - EXIT
rm -rf "$TEST_ROOT"
fi
echo "ok - fleet systemd unit templates" echo "ok - fleet systemd unit templates"

View File

@@ -1,39 +1,207 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} # FCM-M2-001 boundary: only a roster-derived .env.generated projection and a
# Absent socket ⇒ the LITERAL default tmux socket (no -L). The roster's # separately parsed data-only .env.local can influence launch. Never source an
# socket_name is honored when set; absent never silently becomes mosaic-fleet # environment file and never accept a command string from either file.
# (spawn stays consistent with the onboarding cheat-sheet + fleet ps observe).
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-}
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-}
MOSAIC_AGENT_REASONING=${MOSAIC_AGENT_REASONING:-}
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
MOSAIC_HEARTBEAT_INTERVAL=${MOSAIC_HEARTBEAT_INTERVAL:-15}
if [ -z "$AGENT_NAME" ]; then MODE=launch
echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2 case "${1:-}" in
exit 64 --stop)
fi MODE=stop
AGENT_NAME=${2:-}
case "$MOSAIC_AGENT_REASONING" in
''|low|medium|high) ;;
*)
echo "ERROR: MOSAIC_AGENT_REASONING must be low, medium, or high" >&2
exit 64
;; ;;
--interaction)
MODE=interaction
AGENT_NAME=${2:-}
;;
*) AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} ;;
esac esac
MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic}
fail() {
echo "ERROR: $*" >&2
exit 64
}
hash_value() {
printf '%s' "$1" | sha256sum | awk '{print $1}'
}
fail_env() {
local code="$1"
local key="$2"
local value="$3"
echo "ERROR: agent environment rejected: code=${code} key=${key} sha256=$(hash_value "$value")" >&2
exit 64
}
safe_agent_name() {
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]
}
safe_policy_name() {
[[ "$1" =~ ^[a-z][a-z0-9-]*$ ]]
}
safe_path() {
[[ "$1" == /* ]] || return 1
[[ "$1" != *".."* ]] || return 1
[[ ! "$1" =~ [[:space:]\"\'\`\$\\\;\|\&\<\>\(\)\{\}] ]]
}
assert_private_regular_file() {
local file="$1"
[ -f "$file" ] && [ ! -L "$file" ] || fail_env unsafe-file '(file)' "$file"
local mode
mode=$(stat -c '%a' -- "$file") || fail_env unsafe-file '(file)' "$file"
(( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(file)' "$file"
}
assert_managed_directory() {
local directory="$1"
[ -d "$directory" ] && [ ! -L "$directory" ] || fail_env unsafe-directory '(directory)' "$directory"
local mode
mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory"
(( (8#$mode & 8#022) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory"
}
assert_private_directory() {
local directory="$1"
assert_managed_directory "$directory"
local mode
mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory"
(( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory"
}
[ -n "$AGENT_NAME" ] || fail "agent name argument or MOSAIC_AGENT_NAME is required"
safe_agent_name "$AGENT_NAME" || fail_env unsafe-agent-name MOSAIC_AGENT_NAME "$AGENT_NAME"
safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME"
FLEET_DIR="$MOSAIC_HOME/fleet"
AGENT_ENV_DIR="$FLEET_DIR/agents"
assert_managed_directory "$MOSAIC_HOME"
assert_managed_directory "$FLEET_DIR"
assert_private_directory "$AGENT_ENV_DIR"
GENERATED_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.generated"
LOCAL_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.local"
declare -A GENERATED_VALUES=()
declare -A LOCAL_VALUES=()
declare -A SEEN_KEYS=()
is_sensitive_key() {
[[ "$1" =~ (API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN) ]]
}
is_generated_key() {
case "$1" in
MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
*) return 1 ;;
esac
}
is_local_key() {
case "$1" in
MOSAIC_RUNTIME_BIN|MOSAIC_HEARTBEAT_RUN_DIR|MOSAIC_HEARTBEAT_INTERVAL|MOSAIC_CLAUDE_JSON|CLAUDE_CONFIG_DIR) return 0 ;;
*) return 1 ;;
esac
}
validate_generated_value() {
local key="$1"
local value="$2"
case "$key" in
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
MOSAIC_AGENT_RUNTIME)
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
;;
MOSAIC_AGENT_MODEL) [[ "$value" =~ ^[A-Za-z0-9._/:+-]*$ ]] || fail_env unsafe-model "$key" "$value" ;;
MOSAIC_AGENT_REASONING)
case "$value" in ''|low|medium|high) ;; *) fail_env unsupported-reasoning "$key" "$value" ;; esac
;;
MOSAIC_AGENT_TOOL_POLICY) [ -z "$value" ] || safe_policy_name "$value" || fail_env unsafe-tool-policy "$key" "$value" ;;
MOSAIC_AGENT_WORKDIR) safe_path "$value" || fail_env unsafe-path "$key" "$value" ;;
MOSAIC_TMUX_SOCKET) [[ "$value" =~ ^[A-Za-z0-9_.-]*$ ]] || fail_env unsafe-socket "$key" "$value" ;;
esac
}
validate_local_value() {
local key="$1"
local value="$2"
if [ "$key" = MOSAIC_HEARTBEAT_INTERVAL ]; then
[[ "$value" =~ ^[1-9][0-9]*$ ]] || fail_env invalid-interval "$key" "$value"
else
safe_path "$value" || fail_env unsafe-path "$key" "$value"
fi
}
load_environment_file() {
local file="$1"
local kind="$2"
[ -e "$file" ] || {
[ "$kind" = generated ] && fail_env missing-file '(generated)' "$file"
return 0
}
assert_private_regular_file "$file"
SEEN_KEYS=()
local line key value
while IFS= read -r line || [ -n "$line" ]; do
[ -z "$line" ] && continue
if [[ ! "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]]; then
fail_env malformed-line '(malformed)' "$line"
fi
key=${BASH_REMATCH[1]}
value=${BASH_REMATCH[2]}
[ -z "${SEEN_KEYS[$key]+set}" ] || fail_env duplicate-key "$key" "$value"
SEEN_KEYS[$key]=1
is_sensitive_key "$key" && fail_env sensitive-key "$key" "$value"
if [ "$kind" = generated ]; then
is_generated_key "$key" || fail_env unknown-key "$key" "$value"
validate_generated_value "$key" "$value"
GENERATED_VALUES[$key]=$value
else
is_generated_key "$key" && fail_env generated-key-shadow "$key" "$value"
is_local_key "$key" || fail_env unknown-key "$key" "$value"
validate_local_value "$key" "$value"
LOCAL_VALUES[$key]=$value
fi
done < "$file"
}
load_environment_file "$GENERATED_ENV" generated
for required_key in \
MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
done
load_environment_file "$LOCAL_ENV" local
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
MOSAIC_HEARTBEAT_RUN_DIR=${LOCAL_VALUES[MOSAIC_HEARTBEAT_RUN_DIR]:-$MOSAIC_HOME/fleet/run}
MOSAIC_HEARTBEAT_INTERVAL=${LOCAL_VALUES[MOSAIC_HEARTBEAT_INTERVAL]:-15}
MOSAIC_CLAUDE_JSON=${LOCAL_VALUES[MOSAIC_CLAUDE_JSON]:-}
CLAUDE_CONFIG_DIR=${LOCAL_VALUES[CLAUDE_CONFIG_DIR]:-}
if ! command -v tmux >/dev/null 2>&1; then if ! command -v tmux >/dev/null 2>&1; then
echo "ERROR: tmux is required" >&2 echo "ERROR: tmux is required" >&2
exit 69 exit 69
fi fi
# tmux wrapper: pass -L only when a socket is configured. An absent/empty socket
# means the default tmux socket (no -L), keeping spawn == observe == cheat-sheet.
_tmux() { _tmux() {
if [ -n "$MOSAIC_TMUX_SOCKET" ]; then if [ -n "$MOSAIC_TMUX_SOCKET" ]; then
tmux -L "$MOSAIC_TMUX_SOCKET" "$@" tmux -L "$MOSAIC_TMUX_SOCKET" "$@"
@@ -42,140 +210,90 @@ _tmux() {
fi fi
} }
assert_owned_tmux_server() {
local owner_file="$MOSAIC_HOME/fleet/run/holder-owner"
[ -f "$owner_file" ] && [ ! -L "$owner_file" ] || fail "private tmux ownership identity is missing"
local owner_mode
owner_mode=$(stat -c '%a' -- "$owner_file") || fail "private tmux ownership identity is unreadable"
(( (8#$owner_mode & 8#077) == 0 )) || fail "private tmux ownership identity has unsafe permissions"
local owner
owner=$(tr -d '\n' < "$owner_file")
[[ "$owner" =~ ^[a-f0-9-]{36}$ ]] || fail "private tmux ownership identity is malformed"
_tmux has-session -t '=_holder:0.0' 2>/dev/null || fail "owned tmux holder session is absent"
local environment expected
environment=$(_tmux show-environment -g 2>/dev/null) || fail "owned tmux global environment is unreadable"
expected=$(printf '%s\n' \
"HOME=$HOME" \
'PATH=/usr/bin:/bin' \
"PWD=$HOME" \
"MOSAIC_FLEET_OWNER=$owner" \
'MOSAIC_TMUX_HOLDER=_holder' \
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort)
[ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \
fail "tmux server ownership or environment validation failed"
}
# Validate exact server ownership before querying, cleaning, or creating any
# managed session. An unmanaged or contaminated named socket is never repaired.
assert_owned_tmux_server
if [ "$MODE" = interaction ]; then
[ "$MOSAIC_AGENT_RUNTIME" = pi ] || fail "operator interaction service requires runtime pi"
[ "$MOSAIC_AGENT_MODEL" = openai/gpt-5.6-sol ] || \
fail "operator interaction service requires the pinned model"
[ "$MOSAIC_AGENT_REASONING" = high ] || \
fail "operator interaction service requires high reasoning"
[ "$MOSAIC_AGENT_TOOL_POLICY" = operator-interaction ] || \
fail "operator interaction service requires the operator-interaction tool policy"
fi
if [ "$MODE" = stop ]; then
_tmux kill-session -t "=${AGENT_NAME}" >/dev/null 2>&1 || true
exit 0
fi
if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}" echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}"
exit 0 exit 0
fi fi
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then # Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under
# Map the roster's per-agent model_hint to `--model` so workers launch on the # that home. Derive the pane home from the canonical path when available so an
# configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset. # inherited pane/session HOME cannot become runtime authority.
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}${MOSAIC_AGENT_REASONING:+ --thinking $MOSAIC_AGENT_REASONING}" PANE_HOME=$HOME
fi case "$MOSAIC_HOME" in
*/.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;;
esac
# ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
# Precedence:
# 1. $MOSAIC_RUNTIME_BIN (explicit override)
# 2. $(npm config get prefix)/bin (if npm is on PATH)
# 3. Fallbacks: $HOME/.npm-global/bin and $HOME/.local/bin
#
# Only directories that already exist are included. The prefix is baked into
# the pane command regardless of what the LAUNCHER process's $PATH contains,
# because the tmux pane inherits the tmux SERVER environment (not this script's
# environment). A dir on the launcher's PATH may be absent from the server PATH,
# so every existing candidate must always be included. Dedup within the
# constructed prefix avoids listing the same dir twice.
_build_runtime_bin_prefix() { _build_runtime_bin_prefix() {
local candidates=() local candidates=()
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
if [ -n "${MOSAIC_RUNTIME_BIN:-}" ]; then
candidates+=("$MOSAIC_RUNTIME_BIN")
fi
if command -v npm >/dev/null 2>&1; then if command -v npm >/dev/null 2>&1; then
local npm_prefix local npm_prefix
npm_prefix=$(npm config get prefix 2>/dev/null) || true npm_prefix=$(npm config get prefix 2>/dev/null) || true
if [ -n "$npm_prefix" ]; then if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
candidates+=("${npm_prefix}/bin")
fi
fi fi
candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin")
candidates+=("$HOME/.npm-global/bin") local prefix="" dir
candidates+=("$HOME/.local/bin")
local prefix=""
for dir in "${candidates[@]}"; do for dir in "${candidates[@]}"; do
[ -d "$dir" ] || continue [ -d "$dir" ] || continue
if [ -z "$prefix" ]; then case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
prefix="$dir"
else
case ":${prefix}:" in
*":${dir}:"*) ;; # already in our prefix — skip
*) prefix="${prefix}:${dir}" ;;
esac
fi
done done
printf '%s' "$prefix" printf '%s' "$prefix"
} }
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix) MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
# ── Build the pane command ────────────────────────────────────────────────────
# The pane command must:
# - Export the augmented PATH so the runtime binary is found.
# - exec the agent command so the runtime is the pane's foreground process
# (makes `fleet ps` pane_current_command check reliable; no DRIFT false-positive).
#
# Quoting strategy: single-quote the inner shell snippet so that variable
# references in MOSAIC_AGENT_COMMAND are NOT expanded here — they expand inside
# the pane shell. However, MOSAIC_RUNTIME_BIN_PREFIX and PATH must be expanded
# NOW (in this script) because the pane shell inherits the tmux server
# environment, not this script's env.
#
# We build the snippet as a double-quoted here-string embedded in a printf call
# to avoid nested quoting problems.
#
# MOSAIC_AGENT_NAME must also be exported INTO the pane: panes inherit the tmux
# server environment (not this script's, and not the systemd unit's), so the
# name would otherwise be empty in-pane and the runtime's native heartbeat
# (which gates on MOSAIC_AGENT_NAME) would never fire. %q-quote it so it is a
# safe single bash token regardless of the name's characters.
AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME")
# MOSAIC_AGENT_CLASS must ALSO be exported INTO the pane, for the same reason as
# MOSAIC_AGENT_NAME above: the pane inherits the tmux SERVER environment (not this
# script's env, and not the systemd unit's EnvironmentFile), so the per-agent class
# written to agents/<name>.env would otherwise be invisible in-pane. The launcher
# composes the persona contract from process.env.MOSAIC_AGENT_CLASS at launch
# (compose-contract -> readPersonaContractBlock); without this export it sees an
# undefined class and silently injects NO persona contract. %q-quote it so it is a
# safe single bash token; an empty/unset class %q-quotes to '' and is a harmless
# no-op downstream (readPersonaContractBlock returns '' for an empty class).
AGENT_CLASS_Q=$(printf '%q' "${MOSAIC_AGENT_CLASS:-}")
AGENT_TOOL_POLICY_Q=$(printf '%q' "${MOSAIC_AGENT_TOOL_POLICY:-}")
if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
else
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; exec ${MOSAIC_AGENT_COMMAND}"
fi
mkdir -p "$MOSAIC_AGENT_WORKDIR"
# ── Pre-trust the workdir for the Claude runtime ─────────────────────────────
# Claude Code shows a one-time "Is this a project you trust?" folder-trust gate
# the first time it opens a directory. A fleet-launched agent has no human to
# answer it, so the pane stalls forever at the prompt while its heartbeat keeps
# reporting "healthy" (the pane process IS alive — it's just blocked).
#
# IMPORTANT: --dangerously-skip-permissions does NOT bypass this gate, and
# neither does `trustedProjectDirectories` in settings.json (verified empirically
# 2026-06-24). The ONLY thing the gate honors is the per-project record in
# ~/.claude.json: projects["<dir>"].hasTrustDialogAccepted == true (exactly what
# answering the prompt writes). So we pre-seed that record here.
#
# Idempotent, atomic, best-effort: any failure is non-fatal (the agent still
# launches — worst case it stalls on the gate, i.e. the pre-fix status quo).
# Only the claude runtime needs this; codex/pi have no such gate.
_ensure_claude_workdir_trusted() { _ensure_claude_workdir_trusted() {
local workdir="$1" local workdir="$1"
# The path claude keys on is the resolved cwd it is launched in. local resolved
local rp resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
rp=$(cd "$workdir" 2>/dev/null && pwd -P) || rp="$workdir"
# ~/.claude.json lives next to the claude config dir; honor CLAUDE_CONFIG_DIR.
local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}" local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
claude_json="${claude_json:-$HOME/.claude.json}" claude_json="${claude_json:-$HOME/.claude.json}"
command -v python3 >/dev/null 2>&1 || return 1
if ! command -v python3 >/dev/null 2>&1; then MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
echo "WARNING: python3 not found; cannot pre-trust '$rp' for claude (agent may stall on the folder-trust gate)" >&2
return 1
fi
# Serialize concurrent agent launches that share ~/.claude.json (flock if available).
local lock="${claude_json}.mosaic-lock"
_seed() {
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$rp" python3 - <<'PY'
import json, os, sys, tempfile import json, os, sys, tempfile
cj = os.environ["MOSAIC_CJ"] cj = os.environ["MOSAIC_CJ"]
d = os.environ["MOSAIC_TRUST_DIR"] d = os.environ["MOSAIC_TRUST_DIR"]
@@ -184,22 +302,19 @@ try:
if not isinstance(data, dict): if not isinstance(data, dict):
data = {} data = {}
except Exception: except Exception:
# Never corrupt an unreadable/partial file — bail without writing.
sys.exit(2) sys.exit(2)
projects = data.setdefault("projects", {}) projects = data.setdefault("projects", {})
entry = projects.get(d) entry = projects.get(d)
if not isinstance(entry, dict): if not isinstance(entry, dict):
entry = {} entry = {}
projects[d] = entry projects[d] = entry
if entry.get("hasTrustDialogAccepted") is True:
sys.exit(0) # already trusted — nothing to do
entry["hasTrustDialogAccepted"] = True entry["hasTrustDialogAccepted"] = True
tmp_dir = os.path.dirname(cj) or "." tmp_dir = os.path.dirname(cj) or "."
fd, tmp = tempfile.mkstemp(dir=tmp_dir, prefix=".claude.json.mosaic.") fd, tmp = tempfile.mkstemp(dir=tmp_dir, prefix=".claude.json.mosaic.")
try: try:
with os.fdopen(fd, "w") as f: with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
os.replace(tmp, cj) # atomic os.replace(tmp, cj)
except Exception: except Exception:
try: try:
os.unlink(tmp) os.unlink(tmp)
@@ -207,56 +322,56 @@ except Exception:
pass pass
sys.exit(3) sys.exit(3)
PY PY
}
if command -v flock >/dev/null 2>&1; then
( flock 9; _seed ) 9>"$lock" 2>/dev/null || _seed
else
_seed
fi
} }
case "$MOSAIC_AGENT_RUNTIME" in if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
claude) _ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" \ echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|| echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2 fi
;;
esac
# ── Launch the tmux session (no exec — we continue to wire the heartbeat) ──── LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
# The tmux holder owns a named server. Explicitly clear the pane environment
# so server/session variables cannot cross the launch boundary; retain only
# trusted bootstrap, generated, and approved local data as argv assignments.
LAUNCH_ENV=(
/usr/bin/env
-i
"HOME=$PANE_HOME"
"PATH=$PANE_PATH"
"MOSAIC_HOME=$MOSAIC_HOME"
"MOSAIC_AGENT_NAME=$AGENT_NAME"
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
"MOSAIC_AGENT_REASONING=$MOSAIC_AGENT_REASONING"
"MOSAIC_AGENT_TOOL_POLICY=$MOSAIC_AGENT_TOOL_POLICY"
"MOSAIC_AGENT_WORKDIR=$MOSAIC_AGENT_WORKDIR"
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET"
"MOSAIC_HEARTBEAT_RUN_DIR=$MOSAIC_HEARTBEAT_RUN_DIR"
)
mkdir -p "$MOSAIC_AGENT_WORKDIR"
_tmux new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \ _tmux new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \
bash -c "$PANE_SHELL_SNIPPET" "${LAUNCH_ENV[@]}" "${LAUNCH_COMMAND[@]}"
# ── Resolve the pane PID (retry briefly to let the session initialise) ────────
PANE_PID="" PANE_PID=""
for _retry in 1 2 3 4 5; do for _retry in 1 2 3 4 5; do
PANE_PID=$(_tmux list-panes \ PANE_PID=$(_tmux list-panes -t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
-t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
[ -n "$PANE_PID" ] && break [ -n "$PANE_PID" ] && break
sleep 0.2 sleep 0.2
done done
# ── Spawn the heartbeat sidecar (detached, best-effort) ──────────────────────
# The sidecar writes ~/.config/mosaic/fleet/run/<AGENT>.hb atomically while the
# pane process is alive, then exits so the file goes stale (fleet ps shows stale
# then PANE=dead). It is runtime-agnostic: it only cares about the pane PID.
_start_heartbeat_sidecar() { _start_heartbeat_sidecar() {
local agent="$1" local agent="$1" pane_pid="$2" run_dir="$3" interval="$4"
local pane_pid="$2"
local run_dir="$3"
local interval="$4"
local hb_file="${run_dir}/${agent}.hb" local hb_file="${run_dir}/${agent}.hb"
mkdir -p "$run_dir" mkdir -p "$run_dir"
# Write the sidecar as a self-contained bash one-liner so it carries no
# references to any variables from this script's environment.
local sidecar_script local sidecar_script
sidecar_script=$(printf \ sidecar_script=$(printf \
'hb=%q; pid=%q; iv=%q; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do nat="$hb.native"; if [ -f "$nat" ] && [ "$(( $(date +%%s) - $(stat -c %%Y "$nat" 2>/dev/null || echo 0) ))" -lt "$(( iv * 2 ))" ]; then sleep "$iv"; continue; fi; tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \ 'hb=%q; pid=%q; iv=%q; native="$hb.native"; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do now=$(date +%%s); marker=$(stat -c %%Y -- "$native" 2>/dev/null || true); if [ -z "$marker" ] || [ -L "$native" ] || (( now - marker > iv * 2 + 1 )); then tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; fi; sleep "$iv"; done' \
"$hb_file" "$pane_pid" "$interval") "$hb_file" "$pane_pid" "$interval")
# setsid + disown ensures the sidecar survives this script exiting.
# stderr/stdout go to /dev/null; failures are non-fatal.
if command -v setsid >/dev/null 2>&1; then if command -v setsid >/dev/null 2>&1; then
setsid bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 & setsid bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 &
else else
@@ -266,7 +381,6 @@ _start_heartbeat_sidecar() {
} }
if [ -n "$PANE_PID" ]; then if [ -n "$PANE_PID" ]; then
# Guard: do not let sidecar startup failures abort the launcher (set -e).
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \ _start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \ "$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2 echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2

View File

@@ -9,11 +9,7 @@ fail() {
} }
[ -n "$AGENT_NAME" ] || fail "agent name argument is required" [ -n "$AGENT_NAME" ] || fail "agent name argument is required"
[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || fail "agent name contains unsupported characters"
[ "${MOSAIC_AGENT_NAME:-}" = "$AGENT_NAME" ] || fail "configured agent name must exactly match the service instance"
[ "${MOSAIC_AGENT_RUNTIME:-}" = "pi" ] || fail "operator interaction service requires runtime pi"
[ "${MOSAIC_AGENT_MODEL:-}" = "openai/gpt-5.6-sol" ] || fail "operator interaction service requires the pinned model"
[ "${MOSAIC_AGENT_REASONING:-}" = "high" ] || fail "operator interaction service requires high reasoning"
[ "${MOSAIC_AGENT_TOOL_POLICY:-}" = "operator-interaction" ] || fail "operator interaction service requires the operator-interaction tool policy"
exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" "$AGENT_NAME" # The shared launcher strictly validates the generated/local data boundary
# before it applies this interaction service's pinned profile checks.
exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" --interaction "$AGENT_NAME"

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
# A holder may create only the configured named socket. Existing servers are
# accepted only when their private install-derived ownership identity, exact
# holder session, and complete approved global environment all match.
MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic}
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-fleet}
MOSAIC_TMUX_HOLDER=${MOSAIC_TMUX_HOLDER:-_holder}
OWNER_FILE="$MOSAIC_HOME/fleet/run/holder-owner"
TMUX_BIN=/usr/bin/tmux
fail() {
echo "ERROR: refusing unmanaged Mosaic tmux server on socket ${MOSAIC_TMUX_SOCKET}: $1" >&2
exit 64
}
[ -x "$TMUX_BIN" ] || fail "tmux binary is unavailable"
[ -f "$OWNER_FILE" ] && [ ! -L "$OWNER_FILE" ] || fail "private ownership identity is missing"
owner_mode=$(stat -c '%a' -- "$OWNER_FILE") || fail "private ownership identity is unreadable"
(( (8#$owner_mode & 8#077) == 0 )) || fail "private ownership identity has unsafe permissions"
MOSAIC_FLEET_OWNER=$(tr -d '\n' < "$OWNER_FILE")
[[ "$MOSAIC_FLEET_OWNER" =~ ^[a-f0-9-]{36}$ ]] || fail "private ownership identity is malformed"
_tmux() {
"$TMUX_BIN" -L "$MOSAIC_TMUX_SOCKET" "$@"
}
server_running() {
_tmux list-sessions >/dev/null 2>&1
}
assert_owned_server() {
_tmux has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || fail "exact holder session is absent"
local environment
environment=$(_tmux show-environment -g 2>/dev/null) || fail "global environment is unreadable"
local expected
expected=$(printf '%s\n' \
"HOME=$HOME" \
'PATH=/usr/bin:/bin' \
"PWD=$HOME" \
"MOSAIC_FLEET_OWNER=$MOSAIC_FLEET_OWNER" \
"MOSAIC_TMUX_HOLDER=$MOSAIC_TMUX_HOLDER" \
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort)
[ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \
fail "global environment does not match the owned-server contract"
}
if server_running; then
assert_owned_server
else
cd "$HOME" || fail "trusted home is unavailable"
# Start the tmux server itself under the approved environment. The holder pane
# receives the same closed environment rather than arbitrary server globals.
/usr/bin/env -i \
"HOME=$HOME" \
PATH=/usr/bin:/bin \
"MOSAIC_FLEET_OWNER=$MOSAIC_FLEET_OWNER" \
"MOSAIC_TMUX_HOLDER=$MOSAIC_TMUX_HOLDER" \
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" \
"$TMUX_BIN" -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" \
/usr/bin/env -i "HOME=$HOME" PATH=/usr/bin:/bin /bin/sh -c 'while true; do sleep 3600; done'
fi

View File

@@ -3,373 +3,410 @@ set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
START="$SCRIPT_DIR/start-agent-session.sh" START="$SCRIPT_DIR/start-agent-session.sh"
SOCKET="mosaic-agent-test-$RANDOM-$$" INTERACTION_START="$SCRIPT_DIR/start-interaction-service.sh"
AGENT="agent-$RANDOM" ROOT=$(mktemp -d)
WORKDIR=$(mktemp -d) FAKE_BIN=$(mktemp -d)
TMUX_CALLS=$(mktemp)
# Keep a single cleanup trap that accumulates resources. trap 'rm -rf "$ROOT" "$FAKE_BIN" "$TMUX_CALLS"' EXIT
CLEANUP_DIRS=("$WORKDIR")
CLEANUP_SOCKETS=("$SOCKET")
trap '_cleanup' EXIT
_cleanup() {
for s in "${CLEANUP_SOCKETS[@]:-}"; do
tmux -L "$s" kill-server >/dev/null 2>&1 || true
done
for d in "${CLEANUP_DIRS[@]:-}"; do
rm -rf "$d"
done
}
fail() { fail() {
echo "FAIL: $*" >&2 echo "FAIL: $*" >&2
exit 1 exit 1
} }
# ── Test 1: basic session creation with workdir check ───────────────────────── cat > "$FAKE_BIN/tmux" <<'SHIM'
MOSAIC_TMUX_SOCKET="$SOCKET" \
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
"$START" "$AGENT"
tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created"
# Retry: pane_current_path briefly reflects the tmux server's cwd until the pane
# process establishes its own cwd (the -c start dir). Poll until it settles.
actual_dir=""
for _ in $(seq 1 30); do
actual_dir=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}')
[ "$actual_dir" = "$WORKDIR" ] && break
sleep 0.1
done
[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir (expected $WORKDIR)"
# ── Test 2: idempotency (duplicate start prints 'already running') ─────────────
MOSAIC_TMUX_SOCKET="$SOCKET" \
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
"$START" "$AGENT" >/tmp/mosaic-start-agent-idempotent.out
grep -qF 'already running' /tmp/mosaic-start-agent-idempotent.out || fail "duplicate start was not idempotent"
# ── Test 3: runtime-bin PATH prefix is baked into the pane command ────────────
#
# We capture the command the script would hand to tmux by injecting a fake
# 'tmux' shim into PATH. The shim:
# - Intercepts 'new-session' calls and records its arguments to a file.
# - For 'has-session' calls, exits 1 (session does not exist) so the script
# proceeds to launch instead of printing "already running".
# - For 'list-panes' calls, returns empty so PANE_PID stays unset and the
# heartbeat sidecar is NOT spawned (heartbeat is not the focus of this test;
# test 6 and 7 cover that path). This prevents any real-filesystem side
# effects or leaked background processes.
# - For all other subcommands, exits 0.
#
# Assertions:
# a) 'export PATH=' with the synthetic MOSAIC_RUNTIME_BIN prefix appears.
# b) 'exec' appears so the runtime replaces the wrapper shell.
# c) MOSAIC_AGENT_COMMAND with flags is forwarded intact.
FAKE_BIN=$(mktemp -d)
FAKE_RUNTIME_BIN=$(mktemp -d)
TMUX_ARGS_FILE=$(mktemp)
HB_RUN_DIR3=$(mktemp -d)
CLEANUP_DIRS+=("$FAKE_BIN" "$FAKE_RUNTIME_BIN" "$HB_RUN_DIR3")
# Write the fake tmux shim (uses only positional args, no sourced vars).
cat > "$FAKE_BIN/tmux" <<SHIM
#!/usr/bin/env bash #!/usr/bin/env bash
# Fake tmux: record new-session args; report has-session as missing. set -euo pipefail
subcmd="\$3" # argv: tmux -L <socket> <subcmd> ... printf '%s\0' "$@" >> "${MOSAIC_TEST_TMUX_CALLS:?}"
if [ "\$subcmd" = "has-session" ]; then args=("$@")
exit 1 # session not found → script will attempt new-session index=0
fi if [ "${args[0]:-}" = -L ]; then index=2; fi
if [ "\$subcmd" = "new-session" ]; then case "${args[$index]:-}" in
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE" has-session)
exit 0 for argument in "${args[@]}"; do
fi [ "$argument" = '=_holder:0.0' ] && exit 0
if [ "\$subcmd" = "list-panes" ]; then done
# Return empty: no sidecar spawned (heartbeat is not the focus of this test). exit 1
echo "" ;;
exit 0 show-environment)
fi printf '%s\n' \
exit 0 "HOME=${MOSAIC_TEST_HOME:?}" \
'PATH=/usr/bin:/bin' \
"PWD=${MOSAIC_TEST_HOME:?}" \
"MOSAIC_FLEET_OWNER=${MOSAIC_TEST_FLEET_OWNER:?}" \
'MOSAIC_TMUX_HOLDER=_holder' \
'MOSAIC_TMUX_SOCKET=mosaic-test'
exit 0
;;
list-panes) printf '%s\n' "${MOSAIC_TEST_PANE_PID:-}"; exit 0 ;;
new-session)
if [ "${MOSAIC_TEST_EXECUTE_PANE:-}" = 1 ]; then
for ((index = 0; index < ${#args[@]}; index++)); do
if [ "${args[$index]}" = /usr/bin/env ]; then
"${args[@]:$index}"
break
fi
done
fi
exit 0
;;
*) exit 0 ;;
esac
SHIM SHIM
chmod +x "$FAKE_BIN/tmux" chmod +x "$FAKE_BIN/tmux"
SOCKET3="mosaic-agent-test3-$RANDOM-$$" cat > "$FAKE_BIN/mosaic" <<'SHIM'
AGENT3="agent3-$RANDOM"
WORKDIR3=$(mktemp -d)
CLEANUP_DIRS+=("$WORKDIR3")
PATH="$FAKE_BIN:$PATH" \
MOSAIC_TMUX_SOCKET="$SOCKET3" \
MOSAIC_AGENT_WORKDIR="$WORKDIR3" \
MOSAIC_AGENT_RUNTIME="pi" \
MOSAIC_AGENT_CLASS="code" \
MOSAIC_AGENT_TOOL_POLICY="operator-interaction" \
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \
MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \
"$START" "$AGENT3"
all_args=$(cat "$TMUX_ARGS_FILE" 2>/dev/null || true)
rm -f "$TMUX_ARGS_FILE"
echo "--- captured tmux new-session args ---"
echo "$all_args"
echo "--- end args ---"
# a) PATH prefix containing FAKE_RUNTIME_BIN must appear.
echo "$all_args" | grep -qF "export PATH=" || fail "pane command does not export PATH"
echo "$all_args" | grep -qF "$FAKE_RUNTIME_BIN" || fail "pane command does not include MOSAIC_RUNTIME_BIN in PATH prefix"
# b) exec must appear so the runtime replaces the wrapper shell.
echo "$all_args" | grep -qF "exec " || fail "pane command does not use exec"
# c) Full MOSAIC_AGENT_COMMAND (with flags) must be forwarded.
echo "$all_args" | grep -qF "mosaic yolo pi --model openai-codex/gpt-5.5:high" || \
fail "pane command does not forward MOSAIC_AGENT_COMMAND with flags intact"
# d) MOSAIC_AGENT_NAME and the per-agent MOSAIC_AGENT_CLASS must BOTH be exported
# INTO the pane. The pane inherits the tmux SERVER environment (not this
# script's env, nor the systemd unit's EnvironmentFile), so any per-agent var
# the launcher needs in-pane must be re-exported in the snippet. CLASS is
# load-bearing: the launcher composes the persona contract from
# process.env.MOSAIC_AGENT_CLASS, so a missing export silently drops the
# persona (regression guard for the A3a pane-propagation gap).
echo "$all_args" | grep -qF "export MOSAIC_AGENT_NAME=" || \
fail "pane command does not export MOSAIC_AGENT_NAME into the pane"
echo "$all_args" | grep -qF "export MOSAIC_AGENT_CLASS=code" || \
fail "pane command does not export MOSAIC_AGENT_CLASS into the pane (persona would silently drop)"
echo "$all_args" | grep -qF "export MOSAIC_AGENT_TOOL_POLICY=operator-interaction" || \
fail "pane command does not export MOSAIC_AGENT_TOOL_POLICY into the pane"
# ── Test 4: when no extra runtime-bin dirs exist, exec still appears ───────────
TMUX_ARGS_FILE2=$(mktemp)
FAKE_BIN2=$(mktemp -d)
HB_RUN_DIR4=$(mktemp -d)
CLEANUP_DIRS+=("$FAKE_BIN2" "$HB_RUN_DIR4")
cat > "$FAKE_BIN2/tmux" <<SHIM2
#!/usr/bin/env bash #!/usr/bin/env bash
subcmd="\$3" set -euo pipefail
if [ "\$subcmd" = "has-session" ]; then exit 1; fi env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
if [ "\$subcmd" = "new-session" ]; then SHIM
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE2" chmod +x "$FAKE_BIN/mosaic"
exit 0
write_generated() {
local home="$1"
local agent="$2"
mkdir -p "$home/fleet/agents" "$home/fleet/run"
chmod 700 "$home" "$home/fleet" "$home/fleet/agents" "$home/fleet/run"
printf '123e4567-e89b-12d3-a456-426614174000\n' > "$home/fleet/run/holder-owner"
chmod 600 "$home/fleet/run/holder-owner"
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
MOSAIC_AGENT_NAME=$agent
MOSAIC_AGENT_CLASS=code
MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
MOSAIC_AGENT_REASONING=high
MOSAIC_AGENT_TOOL_POLICY=code
MOSAIC_AGENT_WORKDIR=$home/work
MOSAIC_TMUX_SOCKET=mosaic-test
EOF
chmod 600 "$home/fleet/agents/$agent.env.generated"
mkdir -p "$home/work"
}
run_start() {
local home="$1"
local agent="$2"
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
MOSAIC_TEST_HOME="$home" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$home" "$START" "$agent"
}
# Valid generated data launches only the fixed runtime argument array. It never
# reads an agent-command string or constructs a bash -c pane payload.
HOME_VALID="$ROOT/valid"
AGENT_VALID="coder0"
write_generated "$HOME_VALID" "$AGENT_VALID"
run_start "$HOME_VALID" "$AGENT_VALID"
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
echo "$valid_args" | grep -qF 'yolo' || fail "fixed yolo launcher command missing"
echo "$valid_args" | grep -qF 'pi' || fail "roster runtime missing"
if echo "$valid_args" | grep -qF 'bash -c'; then
fail "launcher constructed a shell command payload"
fi fi
if [ "\$subcmd" = "list-panes" ]; then
# Return empty: no sidecar spawned (heartbeat is not the focus of this test). # The pane must start through an absolute clean-environment boundary. Its
echo "" # runtime command remains an argv vector, but no holder/session environment
exit 0 # control variable can pass through the pane command.
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared"
# The generated-file parent is a security boundary too: even a private regular
# file is untrusted if its parent can be replaced or written by another user.
# Validation must happen before fake tmux receives even a has-session call.
: > "$TMUX_CALLS"
HOME_UNSAFE_PARENT="$ROOT/unsafe-parent"
write_generated "$HOME_UNSAFE_PARENT" "coder-parent"
chmod 777 "$HOME_UNSAFE_PARENT/fleet/agents"
if output=$(run_start "$HOME_UNSAFE_PARENT" coder-parent 2>&1); then
fail "generated file under a world-writable parent was accepted"
fi fi
exit 0 [ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
SHIM2 echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
chmod +x "$FAKE_BIN2/tmux"
SOCKET4="mosaic-agent-test4-$RANDOM-$$" : > "$TMUX_CALLS"
AGENT4="agent4-$RANDOM" HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
WORKDIR4=$(mktemp -d) write_generated "$HOME_SYMLINK_PARENT" "coder-symlink-parent"
CLEANUP_DIRS+=("$WORKDIR4") mv "$HOME_SYMLINK_PARENT/fleet/agents" "$HOME_SYMLINK_PARENT/private-agents"
ln -s "$HOME_SYMLINK_PARENT/private-agents" "$HOME_SYMLINK_PARENT/fleet/agents"
# MOSAIC_RUNTIME_BIN points to a non-existent dir so prefix will be empty; if output=$(run_start "$HOME_SYMLINK_PARENT" coder-symlink-parent 2>&1); then
# .npm-global/bin and .local/bin may or may not exist but we just want exec. fail "generated file under a symlinked parent was accepted"
PATH="$FAKE_BIN2:$PATH" \
MOSAIC_TMUX_SOCKET="$SOCKET4" \
MOSAIC_AGENT_WORKDIR="$WORKDIR4" \
MOSAIC_AGENT_RUNTIME="pi" \
MOSAIC_RUNTIME_BIN="/nonexistent-dir-$$" \
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR4" \
"$START" "$AGENT4"
all_args4=$(cat "$TMUX_ARGS_FILE2" 2>/dev/null || true)
rm -f "$TMUX_ARGS_FILE2"
rm -rf "$WORKDIR4"
echo "$all_args4" | grep -qF "exec " || fail "pane command (no prefix dirs) does not use exec"
echo "$all_args4" | grep -qF "mosaic yolo pi" || fail "pane command does not include agent command when no prefix"
# ── Test 5: candidate dir already in LAUNCHER $PATH is still baked into pane ──
#
# Regression guard for the bug where _build_runtime_bin_prefix() used to skip
# a candidate because it was already present in the launcher process's $PATH.
# That check was wrong: the pane inherits the tmux SERVER environment, not the
# launcher's env. Even if a dir is on the launcher's PATH it must always be
# baked into the pane's PATH export.
#
# We prove this by setting PATH to include FAKE_RUNTIME_BIN5 (the candidate),
# then asserting the generated new-session command still exports it.
TMUX_ARGS_FILE5=$(mktemp)
FAKE_BIN5=$(mktemp -d)
FAKE_RUNTIME_BIN5=$(mktemp -d) # this dir IS on the launcher's PATH below
HB_RUN_DIR5=$(mktemp -d)
CLEANUP_DIRS+=("$FAKE_BIN5" "$FAKE_RUNTIME_BIN5" "$HB_RUN_DIR5")
cat > "$FAKE_BIN5/tmux" <<SHIM5
#!/usr/bin/env bash
subcmd="\$3"
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
if [ "\$subcmd" = "new-session" ]; then
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE5"
exit 0
fi fi
if [ "\$subcmd" = "list-panes" ]; then [ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
# Return empty: no sidecar spawned (heartbeat is not the focus of this test). echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
echo ""
exit 0
fi
exit 0
SHIM5
chmod +x "$FAKE_BIN5/tmux"
SOCKET5="mosaic-agent-test5-$RANDOM-$$" # Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A
AGENT5="agent5-$RANDOM" # symlink or group/world-writable ancestor must fail before environment parsing,
WORKDIR5=$(mktemp -d) # workdir creation, or tmux effects. The malformed local input proves parsing
CLEANUP_DIRS+=("$WORKDIR5") # was not reached when the ancestor rejection is reported.
CLEANUP_SOCKETS+=("$SOCKET5") assert_managed_ancestor_rejected() {
local ancestor="$1"
local hazard="$2"
local home="$ROOT/managed-${ancestor//\//-}-${hazard}"
local agent="coder-managed-${ancestor//\//-}-${hazard}"
local node
write_generated "$home" "$agent"
printf 'MOSAIC_AGENT_COMMAND=must-not-be-parsed\n' > "$home/fleet/agents/$agent.env.local"
chmod 600 "$home/fleet/agents/$agent.env.local"
rm -rf "$home/work"
# FAKE_RUNTIME_BIN5 is deliberately placed on the LAUNCHER PATH so that the case "$ancestor" in
# old (buggy) code would have skipped it. The correct code must still include MOSAIC_HOME) node="$home" ;;
# it in the pane PATH export. MOSAIC_HOME/fleet) node="$home/fleet" ;;
PATH="$FAKE_BIN5:$FAKE_RUNTIME_BIN5:$PATH" \ MOSAIC_HOME/fleet/agents) node="$home/fleet/agents" ;;
MOSAIC_TMUX_SOCKET="$SOCKET5" \ *) fail "unknown managed ancestor: $ancestor" ;;
MOSAIC_AGENT_WORKDIR="$WORKDIR5" \ esac
MOSAIC_AGENT_RUNTIME="pi" \
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN5" \
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR5" \
"$START" "$AGENT5"
all_args5=$(cat "$TMUX_ARGS_FILE5" 2>/dev/null || true) if [ "$hazard" = symlink ]; then
rm -f "$TMUX_ARGS_FILE5" local target="${node}-target"
rm -rf "$WORKDIR5" mv "$node" "$target"
ln -s "$target" "$node"
else
chmod 777 "$node"
fi
echo "--- test 5: launcher-PATH candidate must still appear in pane export ---" : > "$TMUX_CALLS"
echo "$all_args5" if output=$(run_start "$home" "$agent" 2>&1); then
echo "--- end test 5 args ---" fail "${hazard} $ancestor was accepted"
fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
[ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection"
echo "$output" | grep -qF "code=unsafe-" || fail "managed ancestor diagnostic missing"
if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then
fail "environment parsing ran before $hazard $ancestor rejection"
fi
}
echo "$all_args5" | grep -qF "export PATH=" || \ for managed_ancestor in MOSAIC_HOME MOSAIC_HOME/fleet MOSAIC_HOME/fleet/agents; do
fail "test5: pane command does not export PATH when candidate is on launcher PATH" assert_managed_ancestor_rejected "$managed_ancestor" symlink
echo "$all_args5" | grep -qF "$FAKE_RUNTIME_BIN5" || \ assert_managed_ancestor_rejected "$managed_ancestor" group-world-writable
fail "test5: candidate dir (already on launcher PATH) was NOT baked into pane PATH — regression"
# ── Test 6: heartbeat sidecar — pane PID resolved + .hb file written ──────────
#
# Uses a real tmux session (same socket as test 1 which already has $AGENT) so
# list-panes returns a real pane PID. We override MOSAIC_HEARTBEAT_RUN_DIR to
# a temp dir and set a 1-second interval, then wait up to 3 s for the .hb file
# to appear and check its content.
HB_RUN_DIR=$(mktemp -d)
CLEANUP_DIRS+=("$HB_RUN_DIR")
# Re-use the session+agent created in Test 1 (still alive on $SOCKET / $AGENT).
# We need to invoke the script for a NEW agent on the same socket to exercise
# the heartbeat path with a real pane PID.
AGENT6="agent6-$RANDOM"
MOSAIC_TMUX_SOCKET="$SOCKET" \
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR" \
MOSAIC_HEARTBEAT_INTERVAL="1" \
"$START" "$AGENT6"
HB_FILE="$HB_RUN_DIR/${AGENT6}.hb"
# Wait up to 5 seconds for the heartbeat file to appear.
_waited=0
until [ -f "$HB_FILE" ] || [ "$_waited" -ge 5 ]; do
sleep 0.5
_waited=$((_waited + 1))
done done
[ -f "$HB_FILE" ] || fail "test6: heartbeat file not written at $HB_FILE within 5s" # A local file cannot shadow any roster-derived generated key. Validation must
# happen before fake tmux receives even a has-session call.
: > "$TMUX_CALLS"
HOME_SHADOW="$ROOT/shadow"
write_generated "$HOME_SHADOW" "coder1"
printf 'MOSAIC_AGENT_RUNTIME=codex\n' > "$HOME_SHADOW/fleet/agents/coder1.env.local"
chmod 600 "$HOME_SHADOW/fleet/agents/coder1.env.local"
if output=$(run_start "$HOME_SHADOW" coder1 2>&1); then
fail "generated-key shadow was accepted"
fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash"
if echo "$output" | grep -qF 'codex'; then
fail "shadow diagnostic leaked value"
fi
hb_content=$(cat "$HB_FILE") # Arbitrary command compatibility is quarantined/rejected as data. Diagnostics
echo "--- test 6: heartbeat file content ---" # may name the key and hash but must never echo the privileged command text.
echo "$hb_content" : > "$TMUX_CALLS"
echo "--- end test 6 ---" HOME_COMMAND="$ROOT/command"
write_generated "$HOME_COMMAND" "coder2"
COMMAND_VALUE='mosaic yolo codex --dangerous'
printf 'MOSAIC_AGENT_COMMAND=%s\n' "$COMMAND_VALUE" > "$HOME_COMMAND/fleet/agents/coder2.env.local"
chmod 600 "$HOME_COMMAND/fleet/agents/coder2.env.local"
if output=$(run_start "$HOME_COMMAND" coder2 2>&1); then
fail "arbitrary command override was accepted"
fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash"
if echo "$output" | grep -qF "$COMMAND_VALUE"; then
fail "command diagnostic leaked command value"
fi
# Verify required fields are present. # Group/world-readable local input is not trusted even when its syntax is safe.
echo "$hb_content" | grep -qE '^ts=[0-9]{4}-[0-9]{2}-[0-9]{2}T' || \ : > "$TMUX_CALLS"
fail "test6: heartbeat ts field missing or malformed" HOME_PERMS="$ROOT/perms"
echo "$hb_content" | grep -qE '^pid=[0-9]+' || \ write_generated "$HOME_PERMS" "coder3"
fail "test6: heartbeat pid field missing or malformed" printf 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n' > "$HOME_PERMS/fleet/agents/coder3.env.local"
echo "$hb_content" | grep -qF 'status=ok' || \ chmod 644 "$HOME_PERMS/fleet/agents/coder3.env.local"
fail "test6: heartbeat status=ok missing" if output=$(run_start "$HOME_PERMS" coder3 2>&1); then
fail "world-readable local input was accepted"
fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection"
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "permission diagnostic missing"
# ── Test 7: heartbeat sidecar — targets correct .hb path per agent name ──────── # A unit/holder-like clean bootstrap must yield a pane with trusted HOME and
# # computed PATH only. The pane command itself must not carry loader, shell
# Uses the fake-tmux shim approach (like tests 3-5) to capture the sidecar # control, arbitrary sentinel, or stale bootstrap variables.
# invocation without needing a real session. A fake setsid shim records its : > "$TMUX_CALLS"
# arguments so we can assert the sidecar script targets the expected .hb path HOME_PANE_BOUNDARY="$ROOT/pane-boundary/.config/mosaic"
# and uses the configured interval. write_generated "$HOME_PANE_BOUNDARY" "coder-pane-boundary"
PANE_TRUSTED_HOME="${HOME_PANE_BOUNDARY%/.config/mosaic}"
PANE_STALE_HOME="$ROOT/stale-home"
PANE_STALE_PATH="$ROOT/stale-bin"
PANE_BASH_ENV="$ROOT/pane-boundary.bash-env"
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \
"$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \
BASH_ENV="$PANE_BASH_ENV" \
MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \
HOME="$PANE_STALE_HOME" \
PATH="$PANE_STALE_PATH" \
/usr/bin/env -i \
"HOME=$PANE_TRUSTED_HOME" \
"PATH=$FAKE_BIN:/usr/bin:/bin" \
"MOSAIC_HOME=$HOME_PANE_BOUNDARY" \
"MOSAIC_TEST_TMUX_CALLS=$TMUX_CALLS" \
"MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_TEST_EXECUTE_PANE=1 \
"$START" coder-pane-boundary
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
fail "pane did not restore trusted HOME"
echo "$pane_args" | grep -qF "HOME=$PANE_STALE_HOME" && \
fail "pane inherited stale HOME"
echo "$pane_args" | grep -qF "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
echo "$pane_args" | grep -qF "$blocked" && fail "pane inherited $blocked"
done
FAKE_BIN7=$(mktemp -d) after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1)
FAKE_RUNTIME_BIN7=$(mktemp -d) [ -n "$after_pane_env" ] || fail "pane command did not use absolute env"
SETSID_ARGS_FILE=$(mktemp) printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
HB_RUN_DIR7=$(mktemp -d) fail "pane command did not clear its environment"
CLEANUP_DIRS+=("$FAKE_BIN7" "$FAKE_RUNTIME_BIN7" "$HB_RUN_DIR7") pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
fail "runtime pane did not receive trusted HOME"
echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked"
done
AGENT7="my-fleet-agent-$RANDOM" write_interaction_generated() {
INTERVAL7="42" local home="$1"
local agent="$2"
mkdir -p "$home/fleet/agents" "$home/fleet/run" "$home/work"
chmod 700 "$home" "$home/fleet" "$home/fleet/agents" "$home/fleet/run"
printf '123e4567-e89b-12d3-a456-426614174000\n' > "$home/fleet/run/holder-owner"
chmod 600 "$home/fleet/run/holder-owner"
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
MOSAIC_AGENT_NAME=$agent
MOSAIC_AGENT_CLASS=operator-interaction
MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
MOSAIC_AGENT_REASONING=high
MOSAIC_AGENT_TOOL_POLICY=operator-interaction
MOSAIC_AGENT_WORKDIR=$home/work
MOSAIC_TMUX_SOCKET=mosaic-test
EOF
chmod 600 "$home/fleet/agents/$agent.env.generated"
}
# Fake tmux: has-session → not found; new-session → ok; list-panes → known PID. run_interaction() {
cat > "$FAKE_BIN7/tmux" <<SHIM7 local home="$1"
#!/usr/bin/env bash local agent="$2"
subcmd="\$3" HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
if [ "\$subcmd" = "has-session" ]; then exit 1; fi MOSAIC_TEST_HOME="$home" \
if [ "\$subcmd" = "new-session" ]; then exit 0; fi MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
if [ "\$subcmd" = "list-panes" ]; then echo "88888"; exit 0; fi MOSAIC_HOME="$home" "$INTERACTION_START" "$agent"
exit 0 }
SHIM7
chmod +x "$FAKE_BIN7/tmux"
# Fake setsid: capture the bash -c <script> argument for inspection, then write_heartbeat_local() {
# background an actual bash subshell so disown succeeds in the caller. local home="$1"
cat > "$FAKE_BIN7/setsid" <<'SETSID_SHIM' local agent="$2"
#!/usr/bin/env bash mkdir -p "$home/run"
# argv: setsid bash -c <sidecar_script> cat > "$home/fleet/agents/$agent.env.local" <<EOF
# Record the full argument list to the capture file, then exit cleanly. MOSAIC_HEARTBEAT_RUN_DIR=$home/run
printf '%s\0' "$@" > __SETSID_ARGS_FILE__ MOSAIC_HEARTBEAT_INTERVAL=1
exit 0 EOF
SETSID_SHIM chmod 600 "$home/fleet/agents/$agent.env.local"
# Patch the placeholder with the real capture-file path (avoids heredoc expansion issues). }
sed -i "s|__SETSID_ARGS_FILE__|${SETSID_ARGS_FILE}|g" "$FAKE_BIN7/setsid"
chmod +x "$FAKE_BIN7/setsid"
SOCKET7="mosaic-agent-test7-$RANDOM-$$" wait_for_sidecar_status() {
WORKDIR7=$(mktemp -d) local file="$1"
CLEANUP_DIRS+=("$WORKDIR7") for _retry in $(seq 1 30); do
grep -qF 'status=ok' "$file" 2>/dev/null && return 0
sleep 0.1
done
fail "heartbeat sidecar did not resume after native marker became stale or absent"
}
PATH="$FAKE_BIN7:$PATH" \ # A fresh Pi-native marker is authoritative: the shell sidecar may start but
MOSAIC_TMUX_SOCKET="$SOCKET7" \ # must not overwrite Pi's busy/ok/model heartbeat. It must resume only when
MOSAIC_AGENT_WORKDIR="$WORKDIR7" \ # the marker is stale or absent.
MOSAIC_AGENT_RUNTIME="pi" \ HOME_NATIVE_FRESH="$ROOT/native-fresh"
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN7" \ write_generated "$HOME_NATIVE_FRESH" "coder-native-fresh"
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \ write_heartbeat_local "$HOME_NATIVE_FRESH" "coder-native-fresh"
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR7" \ FRESH_HB="$HOME_NATIVE_FRESH/run/coder-native-fresh.hb"
MOSAIC_HEARTBEAT_INTERVAL="$INTERVAL7" \ printf 'ts=native\npid=1\nstatus=busy\nmodel=authoritative-model\n' > "$FRESH_HB"
"$START" "$AGENT7" touch "$FRESH_HB.native"
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_FRESH" coder-native-fresh
sleep 0.3
fresh_content=$(cat "$FRESH_HB")
[ "$fresh_content" = 'ts=native
pid=1
status=busy
model=authoritative-model' ] || fail "fresh native heartbeat was overwritten"
# Give the background setsid shim a moment to finish writing the capture file. HOME_NATIVE_STALE="$ROOT/native-stale"
sleep 0.5 write_generated "$HOME_NATIVE_STALE" "coder-native-stale"
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
touch -d '10 seconds ago' "$STALE_HB.native"
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
wait_for_sidecar_status "$STALE_HB"
setsid_args=$(cat "$SETSID_ARGS_FILE" 2>/dev/null | tr '\0' '\n' || true) HOME_NATIVE_ABSENT="$ROOT/native-absent"
rm -f "$SETSID_ARGS_FILE" write_generated "$HOME_NATIVE_ABSENT" "coder-native-absent"
rm -rf "$WORKDIR7" write_heartbeat_local "$HOME_NATIVE_ABSENT" "coder-native-absent"
ABSENT_HB="$HOME_NATIVE_ABSENT/run/coder-native-absent.hb"
printf 'ts=native\npid=1\nstatus=busy\nmodel=absent-model\n' > "$ABSENT_HB"
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_ABSENT" coder-native-absent
wait_for_sidecar_status "$ABSENT_HB"
echo "--- test 7: captured setsid args ---" # The interaction wrapper delegates to the shared strict parser before applying
echo "$setsid_args" # its pinned policy, so malformed projection data wins over profile diagnostics.
echo "--- end test 7 ---" : > "$TMUX_CALLS"
HOME_INTERACTION_MALFORMED="$ROOT/interaction-malformed"
write_interaction_generated "$HOME_INTERACTION_MALFORMED" "interaction-malformed"
printf 'UNTRUSTED_BOOTSTRAP=value\n' >> "$HOME_INTERACTION_MALFORMED/fleet/agents/interaction-malformed.env.generated"
if output=$(run_interaction "$HOME_INTERACTION_MALFORMED" interaction-malformed 2>&1); then
fail "interaction wrapper accepted malformed generated data"
fi
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection"
echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use shared strict parser first"
# The sidecar script (bash -c <script>) must reference the correct .hb path. # A syntactically valid but policy-incompatible projection reaches the pinned
expected_hb="${HB_RUN_DIR7}/${AGENT7}.hb" # interaction policy check only after strict parsing and never starts tmux.
echo "$setsid_args" | grep -qF "$expected_hb" || \ : > "$TMUX_CALLS"
fail "test7: sidecar script does not reference correct .hb path ($expected_hb)" HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
perl -0pi -e 's/MOSAIC_AGENT_RUNTIME=pi/MOSAIC_AGENT_RUNTIME=codex/' \
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
fail "interaction wrapper accepted a policy-incompatible projection"
fi
interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$interaction_policy_args" | grep -qF 'new-session' && \
fail "interaction pinned-policy rejection created a tmux session"
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
fail "interaction pinned-policy check did not follow strict parsing"
# The sidecar script must use the configured interval. # Exact stop derives the socket exclusively from the validated generated
echo "$setsid_args" | grep -qF "$INTERVAL7" || \ # projection and ignores an ambient socket supplied by the caller.
fail "test7: sidecar script does not reference configured interval ($INTERVAL7)" : > "$TMUX_CALLS"
HOME_STOP="$ROOT/stop"
write_generated "$HOME_STOP" "coder-stop"
HOME="$HOME_STOP" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_HOME="$HOME_STOP" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$stop_args" | grep -qxF 'mosaic-test' || fail "exact stop did not use the validated generated socket"
echo "$stop_args" | grep -qxF 'kill-session' || fail "exact stop did not request session termination"
echo "$stop_args" | grep -qxF '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
if echo "$stop_args" | grep -qF 'ambient-socket'; then
fail "exact stop trusted an ambient socket"
fi
echo "ok - start-agent-session" echo 'ok - start-agent-session generated environment boundary'

View File

@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { chmod, lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { Command } from 'commander'; import { Command } from 'commander';
@@ -284,6 +284,8 @@ describe('fleet roster parsing', () => {
'MOSAIC_AGENT_CLASS=implementer', 'MOSAIC_AGENT_CLASS=implementer',
'MOSAIC_AGENT_RUNTIME=codex', 'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_MODEL=', 'MOSAIC_AGENT_MODEL=',
'MOSAIC_AGENT_REASONING=',
'MOSAIC_AGENT_TOOL_POLICY=',
'MOSAIC_AGENT_WORKDIR=/srv/mosaic', 'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
'MOSAIC_TMUX_SOCKET=mosaic-fleet', 'MOSAIC_TMUX_SOCKET=mosaic-fleet',
'', '',
@@ -325,57 +327,38 @@ describe('fleet roster parsing', () => {
); );
}); });
it('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => { it('rejects legacy command overrides instead of merging them into generated authority', () => {
const generated = [ const generated = generateAgentEnv(
'MOSAIC_AGENT_NAME=coder0', {
'MOSAIC_AGENT_RUNTIME=codex', version: 1,
'MOSAIC_AGENT_WORKDIR=/srv/new', transport: 'tmux',
'MOSAIC_TMUX_SOCKET=mosaic-fleet', tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
'', defaults: { workingDirectory: '/srv/new' },
].join('\n'); runtimes: {},
const existing = [ agents: [],
'MOSAIC_AGENT_NAME=old-name', },
'MOSAIC_AGENT_RUNTIME=old-runtime', { name: 'coder0', className: 'code', runtime: 'codex' },
'MOSAIC_AGENT_WORKDIR=/srv/old', );
'MOSAIC_TMUX_SOCKET=old-socket',
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
'# site note',
'',
].join('\n');
expect(mergeAgentEnv(generated, existing)).toBe( expect((): string => mergeAgentEnv(generated, 'MOSAIC_AGENT_COMMAND=legacy-command\n')).toThrow(
[ /MOSAIC_AGENT_COMMAND/,
'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_WORKDIR=/srv/new',
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
'# site note',
'',
].join('\n'),
); );
}); });
it('updates (does not duplicate) MOSAIC_AGENT_CLASS on re-launch', () => { it('does not merge a valid local value into the generated projection', () => {
const generated = [ const generated = generateAgentEnv(
'MOSAIC_AGENT_NAME=coder0', {
'MOSAIC_AGENT_CLASS=orchestrator', version: 1,
'MOSAIC_AGENT_RUNTIME=codex', transport: 'tmux',
'', tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
].join('\n'); defaults: { workingDirectory: '/srv/new' },
const existing = [ runtimes: {},
'MOSAIC_AGENT_NAME=coder0', agents: [],
'MOSAIC_AGENT_CLASS=worker', },
'MOSAIC_AGENT_RUNTIME=codex', { name: 'coder0', className: 'code', runtime: 'codex' },
'', );
].join('\n');
const merged = mergeAgentEnv(generated, existing); expect(mergeAgentEnv(generated, 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n')).toBe(generated);
// mergeAgentEnv keys by VAR name, so the regenerated CLASS wins and there is
// exactly one MOSAIC_AGENT_CLASS line (no stale worker value left behind).
expect(merged).toContain('MOSAIC_AGENT_CLASS=orchestrator');
expect(merged).not.toContain('MOSAIC_AGENT_CLASS=worker');
expect(merged.match(/^MOSAIC_AGENT_CLASS=/gm)).toHaveLength(1);
}); });
it('rejects unknown roster fields instead of silently defaulting', async () => { it('rejects unknown roster fields instead of silently defaulting', async () => {
@@ -1129,6 +1112,92 @@ describe('fleet command construction', () => {
} }
}); });
it('creates private managed directories and roster/projection files through fresh init and install under umask 022', async () => {
const originalHome = process.env.HOME;
const originalMosaicHome = process.env.MOSAIC_HOME;
const originalUmask = process.umask(0o022);
const home = await tempDir();
process.env.HOME = home;
delete process.env.MOSAIC_HOME;
const mosaicHome = join(home, '.config', 'mosaic');
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { frameworkRoot: resolve(process.cwd(), 'framework') });
try {
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', 'init', '--profile', 'minimal', '--write']),
).resolves.toBeDefined();
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
).resolves.toBeDefined();
for (const path of [
mosaicHome,
join(mosaicHome, 'fleet'),
join(mosaicHome, 'fleet', 'agents'),
]) {
expect((await stat(path)).mode & 0o777).toBe(0o700);
}
expect((await stat(join(mosaicHome, 'fleet', 'roster.yaml'))).mode & 0o777).toBe(0o600);
expect(
(await stat(join(mosaicHome, 'fleet', 'agents', 'canary-pi.env.generated'))).mode & 0o777,
).toBe(0o600);
} finally {
process.umask(originalUmask);
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalMosaicHome === undefined) delete process.env.MOSAIC_HOME;
else process.env.MOSAIC_HOME = originalMosaicHome;
await rm(home, { recursive: true, force: true });
}
});
it('creates a private real projection directory and private generated files on fresh install', async () => {
const originalHome = process.env.HOME;
const home = await tempDir();
process.env.HOME = home;
const mosaicHome = join(home, '.config', 'mosaic');
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
const fleetDir = join(mosaicHome, 'fleet');
await mkdir(fleetDir, { recursive: true, mode: 0o700 });
await chmod(mosaicHome, 0o700);
await chmod(fleetDir, 0o700);
await writeFile(
join(mosaicHome, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: coder0',
' runtime: pi',
' class: code',
].join('\n'),
);
const program = new Command();
program.exitOverride();
registerFleetCommand(program, {
frameworkRoot: resolve(process.cwd(), 'framework'),
mosaicHome,
});
try {
await expect(
program.parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
).resolves.toBeDefined();
const directoryMetadata = await lstat(agentEnvDir);
expect(directoryMetadata.isDirectory()).toBe(true);
expect(directoryMetadata.isSymbolicLink()).toBe(false);
expect(directoryMetadata.mode & 0o777).toBe(0o700);
expect((await stat(join(agentEnvDir, 'coder0.env.generated'))).mode & 0o777).toBe(0o600);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
await rm(home, { recursive: true, force: true });
}
});
it.each(['start', 'stop', 'restart', 'status'] as const)( it.each(['start', 'stop', 'restart', 'status'] as const)(
'rejects single-agent %s for agents outside the roster', 'rejects single-agent %s for agents outside the roster',
async (action) => { async (action) => {
@@ -1687,8 +1756,10 @@ describe('fleet ps — drift detection', () => {
describe('fleet-polish bundle — boot-survival symmetry', () => { describe('fleet-polish bundle — boot-survival symmetry', () => {
async function rosterHome(agents: string): Promise<string> { async function rosterHome(agents: string): Promise<string> {
const home = await tempDir(); const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true }); const fleetDir = join(home, 'fleet');
await writeFile(join(home, 'fleet', 'roster.yaml'), agents); await mkdir(fleetDir, { recursive: true, mode: 0o700 });
await chmod(fleetDir, 0o700);
await writeFile(join(fleetDir, 'roster.yaml'), agents);
return home; return home;
} }
@@ -3459,7 +3530,11 @@ describe('fleet add command', () => {
async function makeHome(agents = ['orchestrator']): Promise<string> { async function makeHome(agents = ['orchestrator']): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-add-')); const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-add-'));
await mkdir(join(dir, 'fleet', 'agents'), { recursive: true }); const fleetDir = join(dir, 'fleet');
const agentEnvDir = join(fleetDir, 'agents');
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
await chmod(fleetDir, 0o700);
await chmod(agentEnvDir, 0o700);
const agentLines = agents.map((name) => { const agentLines = agents.map((name) => {
const cls = name === 'orchestrator' ? 'orchestrator' : 'worker'; const cls = name === 'orchestrator' ? 'orchestrator' : 'worker';
return ` - name: ${name}\n runtime: claude\n class: ${cls}`; return ` - name: ${name}\n runtime: claude\n class: ${cls}`;
@@ -3493,11 +3568,110 @@ describe('fleet add command', () => {
const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml')); const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
expect(roster.agents.map((a) => a.name)).toContain('coder0'); expect(roster.agents.map((a) => a.name)).toContain('coder0');
const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8'); const envContent = await readFile(
join(home, 'fleet', 'agents', 'coder0.env.generated'),
'utf8',
);
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0'); expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex'); expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
}); });
it('rejects an unprojectable dogfood runtime without mutating roster or projection state', async () => {
home = await makeHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentEnvDir = join(home, 'fleet', 'agents');
const trackedPaths = [
rosterPath,
join(agentEnvDir, 'dogfood.env.generated'),
join(agentEnvDir, 'dogfood.env.local'),
join(agentEnvDir, 'dogfood.env.quarantine'),
];
await writeFile(trackedPaths[1]!, 'generated-before\n');
await writeFile(trackedPaths[2]!, 'MOSAIC_RUNTIME_BIN=/safe/runtime\n');
await writeFile(trackedPaths[3]!, 'quarantine-before\n');
const beforeState = new Map<string, Buffer>();
for (const path of trackedPaths) beforeState.set(path, await readFile(path));
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await expect(
program.parseAsync([
'node',
'mosaic',
'fleet',
'add',
'dogfood',
'--runtime',
'dogfood',
'--class',
'worker',
'--no-start',
]),
).rejects.toThrow(/runtime/i);
for (const path of trackedPaths) {
expect(await readFile(path)).toEqual(beforeState.get(path));
}
expect(calls).toEqual([]);
});
it('rejects supported add with a deterministic quarantine conflict before roster persistence', async () => {
home = await makeHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentEnvDir = join(home, 'fleet', 'agents');
const trackedPaths = [
rosterPath,
join(agentEnvDir, 'coder1.env.generated'),
join(agentEnvDir, 'coder1.env.local'),
join(agentEnvDir, 'coder1.env'),
join(agentEnvDir, 'coder1.env.quarantine'),
];
await writeFile(trackedPaths[1]!, 'generated-before\n', { mode: 0o600 });
await writeFile(trackedPaths[2]!, 'MOSAIC_RUNTIME_BIN=/safe/runtime\n', { mode: 0o600 });
await writeFile(trackedPaths[3]!, 'MOSAIC_AGENT_COMMAND=must-be-quarantined\n', {
mode: 0o600,
});
await writeFile(trackedPaths[4]!, 'quarantine-before\n', { mode: 0o600 });
const beforeState = new Map<string, Buffer>();
for (const path of trackedPaths) beforeState.set(path, await readFile(path));
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
await expect(
program.parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder1',
'--runtime',
'codex',
'--class',
'worker',
'--no-start',
]),
).rejects.toThrow('quarantine-exists');
for (const path of trackedPaths) {
expect(await readFile(path)).toEqual(beforeState.get(path));
}
expect(calls).toEqual([]);
});
it('--no-start skips the start command', async () => { it('--no-start skips the start command', async () => {
home = await makeHome(); home = await makeHome();
const calls: string[][] = []; const calls: string[][] = [];
@@ -3659,7 +3833,10 @@ describe('fleet remove command', () => {
].join('\n'), ].join('\n'),
); );
// Create env and heartbeat files for coder0 // Create env and heartbeat files for coder0
await writeFile(join(dir, 'fleet', 'agents', 'coder0.env'), 'MOSAIC_AGENT_NAME=coder0\n'); await writeFile(
join(dir, 'fleet', 'agents', 'coder0.env.generated'),
'MOSAIC_AGENT_NAME=coder0\n',
);
await writeFile(join(dir, 'fleet', 'run', 'coder0.hb'), 'ts=2026-01-01T00:00:00.000Z\n'); await writeFile(join(dir, 'fleet', 'run', 'coder0.hb'), 'ts=2026-01-01T00:00:00.000Z\n');
return dir; return dir;
} }
@@ -3738,12 +3915,15 @@ describe('fleet remove command', () => {
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0', '--keep-files']); await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0', '--keep-files']);
// Env file should still exist // Generated projection should still exist.
const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8'); const envContent = await readFile(
join(home, 'fleet', 'agents', 'coder0.env.generated'),
'utf8',
);
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0'); expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
}); });
it('env file is removed by default (no --keep-files)', async () => { it('generated projection is removed by default (no --keep-files)', async () => {
home = await makeHome(); home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 }); const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command(); const program = new Command();
@@ -3752,7 +3932,9 @@ describe('fleet remove command', () => {
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']); await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
await expect(readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8')).rejects.toThrow(); await expect(
readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
).rejects.toThrow();
}); });
it('removing the sole orchestrator throws with a clear error about the guard', async () => { it('removing the sole orchestrator throws with a clear error about the guard', async () => {

View File

@@ -19,6 +19,16 @@ import * as readline from 'node:readline';
import type { Command } from 'commander'; import type { Command } from 'commander';
import YAML from 'yaml'; import YAML from 'yaml';
import { resolveCommsBlock } from '../fleet/comms-onboarding.js'; import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
import {
applyPreparedAgentEnvironmentProjection,
ensureFleetHolderIdentity,
GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES,
parseAgentEnvironment,
prepareAgentEnvironmentProjection,
renderGeneratedAgentEnvironment,
writeAgentEnvironmentProjection,
writeManagedFleetRoster,
} from '../fleet/generated-env-boundary.js';
import { registerFleetBacklogCommand } from './fleet-backlog.js'; import { registerFleetBacklogCommand } from './fleet-backlog.js';
import { registerFleetPersonaCommand } from './fleet-personas.js'; import { registerFleetPersonaCommand } from './fleet-personas.js';
import { registerFleetProfileCommand } from './fleet-profiles.js'; import { registerFleetProfileCommand } from './fleet-profiles.js';
@@ -509,50 +519,35 @@ export async function generateNorthStarMarkdown(repoRoot?: string): Promise<stri
} }
export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string { export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string {
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory; return renderGeneratedAgentEnvironment(generateAgentEnvValues(roster, agent));
return [
`MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`,
// Per-agent class → start-agent-session.sh / launcher reads this to inject the
// matching persona contract for the agent's class (default `worker`).
`MOSAIC_AGENT_CLASS=${shellEnvValue(agent.className)}`,
`MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`,
// Per-agent model hint → start-agent-session.sh appends `--model <hint>` to
// the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on
// openai-codex/gpt-5.5:high). Empty when the agent declares no model_hint.
`MOSAIC_AGENT_MODEL=${shellEnvValue(agent.modelHint ?? '')}`,
...(agent.reasoningLevel !== undefined
? [`MOSAIC_AGENT_REASONING=${shellEnvValue(agent.reasoningLevel)}`]
: []),
...(agent.toolPolicy !== undefined
? [`MOSAIC_AGENT_TOOL_POLICY=${shellEnvValue(agent.toolPolicy)}`]
: []),
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
'',
].join('\n');
} }
/**
* Compatibility shim for callers that previously merged site-owned data into a
* generated file. Local data is validated but never appended to the generated
* projection, preventing a local override from becoming hidden authority.
*/
export function mergeAgentEnv(generatedEnv: string, existingEnv?: string): string { export function mergeAgentEnv(generatedEnv: string, existingEnv?: string): string {
if (!existingEnv?.trim()) { parseAgentEnvironment(generatedEnv, 'generated');
return generatedEnv; if (existingEnv?.trim()) parseAgentEnvironment(existingEnv, 'local');
} return generatedEnv;
const generatedKeys = new Set( }
generatedEnv
.split('\n') function generateAgentEnvValues(
.map((line) => line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1]) roster: FleetRoster,
.filter((key): key is string => key !== undefined), agent: FleetAgent,
); ): Readonly<Record<string, string>> {
const preservedLines = existingEnv.split('\n').filter((line) => { const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
if (!line.trim()) { return {
return false; MOSAIC_AGENT_NAME: agent.name,
} MOSAIC_AGENT_CLASS: agent.className,
const key = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1]; MOSAIC_AGENT_RUNTIME: agent.runtime,
return key === undefined || !generatedKeys.has(key); MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
}); MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
if (preservedLines.length === 0) { MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
return generatedEnv; MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory),
} MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
return [generatedEnv.trimEnd(), ...preservedLines, ''].join('\n'); };
} }
export function buildFleetServiceCommand(action: FleetServiceAction, agentName?: string): string[] { export function buildFleetServiceCommand(action: FleetServiceAction, agentName?: string): string[] {
@@ -1544,8 +1539,12 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
`Fleet roster already exists: ${destination}. Re-run with --force to overwrite.`, `Fleet roster already exists: ${destination}. Re-run with --force to overwrite.`,
); );
} }
await mkdir(dirname(destination), { recursive: true }); if (resolve(destination) === resolve(activePaths.rosterPath)) {
await writeFile(destination, content); await writeManagedFleetRoster(activePaths.mosaicHome, destination, content);
} else {
await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, content);
}
// Guarantee the two-agent floor: exactly one orchestrator AND at least // Guarantee the two-agent floor: exactly one orchestrator AND at least
// one enhancer for every profile except the sanctioned no-orchestrator // one enhancer for every profile except the sanctioned no-orchestrator
@@ -1948,15 +1947,17 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
}; };
const updatedRoster = addAgentToRoster(roster, newAgent); const updatedRoster = addAgentToRoster(roster, newAgent);
const generated = generateAgentEnvValues(updatedRoster, newAgent);
// Preflight every deterministic projection, local, legacy, quarantine,
// and managed-directory condition before roster authority changes.
const preparedProjection = await prepareAgentEnvironmentProjection({
mosaicHome: activePaths.mosaicHome,
agentEnvDir: activePaths.agentEnvDir,
agentName: name,
generated,
});
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster)); await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
await applyPreparedAgentEnvironmentProjection(preparedProjection);
const envPath = join(activePaths.agentEnvDir, `${name}.env`);
const existingEnv = (await canRead(envPath)) ? await readFile(envPath, 'utf8') : undefined;
await mkdir(activePaths.agentEnvDir, { recursive: true });
await writeFile(
envPath,
mergeAgentEnv(generateAgentEnv(updatedRoster, newAgent), existingEnv),
);
console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`); console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`);
@@ -2037,10 +2038,11 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// Write updated roster // Write updated roster
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster)); await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
// Delete env and heartbeat files (best-effort, non-fatal) // Delete the non-authoritative generated projection and heartbeat files
// (best-effort, non-fatal). Operator local/quarantine data is retained.
if (!opts.keepFiles) { if (!opts.keepFiles) {
try { try {
await unlink(join(activePaths.agentEnvDir, `${name}.env`)); await unlink(join(activePaths.agentEnvDir, `${name}.env.generated`));
} catch { } catch {
// best-effort // best-effort
} }
@@ -2328,16 +2330,17 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome); const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome); assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
const roster = await loadRosterForCommand(cmd); const roster = await loadRosterForCommand(cmd);
await ensureFleetHolderIdentity(activePaths.mosaicHome);
await mkdir(activePaths.fleetToolsDir, { recursive: true }); await mkdir(activePaths.fleetToolsDir, { recursive: true });
await mkdir(activePaths.tmuxToolsDir, { recursive: true }); await mkdir(activePaths.tmuxToolsDir, { recursive: true });
await mkdir(activePaths.systemdUserDir, { recursive: true }); await mkdir(activePaths.systemdUserDir, { recursive: true });
await mkdir(activePaths.agentEnvDir, { recursive: true });
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh'); const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
const startInteractionServicePath = join( const startInteractionServicePath = join(
activePaths.fleetToolsDir, activePaths.fleetToolsDir,
'start-interaction-service.sh', 'start-interaction-service.sh',
); );
const startTmuxHolderPath = join(activePaths.fleetToolsDir, 'start-tmux-holder.sh');
const printInteractionPolicyPath = join( const printInteractionPolicyPath = join(
activePaths.fleetToolsDir, activePaths.fleetToolsDir,
'print-interaction-effective-policy.sh', 'print-interaction-effective-policy.sh',
@@ -2347,6 +2350,7 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
const executableToolPaths = [ const executableToolPaths = [
startAgentSessionPath, startAgentSessionPath,
startInteractionServicePath, startInteractionServicePath,
startTmuxHolderPath,
printInteractionPolicyPath, printInteractionPolicyPath,
sendMessagePath, sendMessagePath,
agentSendPath, agentSendPath,
@@ -2359,6 +2363,10 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'), join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
startInteractionServicePath, startInteractionServicePath,
); );
await copyFile(
join(frameworkRoot, 'tools', 'fleet', 'start-tmux-holder.sh'),
startTmuxHolderPath,
);
await copyFile( await copyFile(
join(frameworkRoot, 'tools', 'fleet', 'print-interaction-effective-policy.sh'), join(frameworkRoot, 'tools', 'fleet', 'print-interaction-effective-policy.sh'),
printInteractionPolicyPath, printInteractionPolicyPath,
@@ -2382,9 +2390,12 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
); );
for (const agent of roster.agents) { for (const agent of roster.agents) {
const envPath = join(activePaths.agentEnvDir, `${agent.name}.env`); await writeAgentEnvironmentProjection({
const existingEnv = (await canRead(envPath)) ? await readFile(envPath, 'utf8') : undefined; mosaicHome: activePaths.mosaicHome,
await writeFile(envPath, mergeAgentEnv(generateAgentEnv(roster, agent), existingEnv)); agentEnvDir: activePaths.agentEnvDir,
agentName: agent.name,
generated: generateAgentEnvValues(roster, agent),
});
} }
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`); console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
@@ -2656,19 +2667,6 @@ function expandHome(path: string): string {
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path; return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
} }
function shellEnvValue(value: string): string {
// Empty ⇒ a bare `VAR=` (unambiguous empty in a systemd EnvironmentFile and
// when shell-sourced). Quoting it as '' risks a literal two-char value (e.g.
// a tmux socket named "''"), which would defeat the default-socket behavior.
if (value === '') {
return '';
}
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
return value;
}
return `'${value.replaceAll("'", "'\"'\"'")}'`;
}
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> { async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
const failures: string[] = []; const failures: string[] = [];
for (const agentName of agentNames) { for (const agentName of agentNames) {
@@ -2770,13 +2768,8 @@ export function countEnhancers(roster: FleetRoster): number {
} }
/** Valid runtime identifiers for fleet agents. */ /** Valid runtime identifiers for fleet agents. */
export const VALID_FLEET_RUNTIMES: readonly string[] = [ /** Runtime launchers accepted by `fleet add`; shared with the generated projection validator. */
'pi', export const VALID_FLEET_RUNTIMES: readonly string[] = GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES;
'claude',
'codex',
'opencode',
'dogfood',
];
/** /**
* Add a new agent to a fleet roster (immutable — returns a new FleetRoster). * Add a new agent to a fleet roster (immutable — returns a new FleetRoster).

View File

@@ -0,0 +1,279 @@
import {
chmod,
mkdir,
mkdtemp,
readFile,
rename,
rm,
stat,
symlink,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
AgentEnvBoundaryError,
parseAgentEnvironment,
renderGeneratedAgentEnvironment,
writeAgentEnvironmentProjection,
} from './generated-env-boundary.js';
const generatedValues = {
MOSAIC_AGENT_NAME: 'coder0',
MOSAIC_AGENT_CLASS: 'code',
MOSAIC_AGENT_RUNTIME: 'pi',
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
MOSAIC_AGENT_REASONING: 'high',
MOSAIC_AGENT_TOOL_POLICY: 'code',
MOSAIC_AGENT_WORKDIR: '/srv/mosaic',
MOSAIC_TMUX_SOCKET: 'mosaic-fleet',
};
describe('generated fleet agent environment boundary', (): void => {
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
if (cleanup) {
await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
}
});
it('renders a deterministic complete generated projection', (): void => {
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
[
'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_CLASS=code',
'MOSAIC_AGENT_RUNTIME=pi',
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
'MOSAIC_AGENT_REASONING=high',
'MOSAIC_AGENT_TOOL_POLICY=code',
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
'',
].join('\n'),
);
});
it.each([
['generated-key shadowing', 'MOSAIC_AGENT_RUNTIME=codex\n'],
['unknown key', 'UNRELATED_SETTING=value\n'],
['arbitrary command', 'MOSAIC_AGENT_COMMAND=mosaic yolo codex\n'],
['sensitive key', 'MOSAIC_AGENT_TOKEN=do-not-log-me\n'],
['malformed syntax', 'export MOSAIC_RUNTIME_BIN=/opt/bin\n'],
['duplicate keys', 'MOSAIC_RUNTIME_BIN=/opt/bin\nMOSAIC_RUNTIME_BIN=/other/bin\n'],
])('rejects local %s without echoing values', (_name: string, source: string): void => {
let error: unknown;
try {
parseAgentEnvironment(source, 'local');
} catch (caught: unknown) {
error = caught;
}
expect(error).toBeInstanceOf(AgentEnvBoundaryError);
expect(String(error)).not.toContain('mosaic yolo codex');
expect(String(error)).not.toContain('do-not-log-me');
expect(String(error)).toMatch(/key=.*sha256=/);
});
it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
expect((): void => {
renderGeneratedAgentEnvironment({
...generatedValues,
MOSAIC_AGENT_WORKDIR: '../outside',
});
}).toThrow(AgentEnvBoundaryError);
});
it('creates missing managed directories privately before writing a projection', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic');
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
const result = await writeAgentEnvironmentProjection({
mosaicHome,
agentEnvDir,
agentName: 'coder0',
generated: generatedValues,
});
for (const directory of [mosaicHome, join(mosaicHome, 'fleet'), agentEnvDir]) {
expect((await stat(directory)).mode & 0o777).toBe(0o700);
}
expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600);
});
it('regenerates desired keys, relocates safe legacy local data, and quarantines forbidden legacy input', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic');
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
await writeFile(
join(agentEnvDir, 'coder0.env'),
[
'MOSAIC_AGENT_NAME=stale-name',
'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin',
'MOSAIC_AGENT_COMMAND=mosaic yolo codex --dangerous',
'',
].join('\n'),
{ mode: 0o600 },
);
const result = await writeAgentEnvironmentProjection({
mosaicHome,
agentEnvDir,
agentName: 'coder0',
generated: generatedValues,
});
expect(await readFile(result.generatedPath, 'utf8')).toBe(
renderGeneratedAgentEnvironment(generatedValues),
);
expect(await readFile(result.localPath, 'utf8')).toBe('MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n');
const quarantine = await readFile(result.quarantinePath!, 'utf8');
expect(quarantine).toContain('MOSAIC_AGENT_COMMAND=mosaic yolo codex --dangerous');
await expect(readFile(join(agentEnvDir, 'coder0.env'), 'utf8')).rejects.toThrow();
expect((await stat(result.generatedPath)).mode & 0o077).toBe(0);
expect((await stat(result.localPath)).mode & 0o077).toBe(0);
expect(result.diagnostics).toEqual([expect.objectContaining({ key: 'MOSAIC_AGENT_COMMAND' })]);
expect(JSON.stringify(result.diagnostics)).not.toContain('mosaic yolo codex --dangerous');
});
it('fails closed on an existing local override with unsafe permissions', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic');
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
const localPath = join(agentEnvDir, 'coder0.env.local');
await writeFile(localPath, 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n', { mode: 0o600 });
await chmod(localPath, 0o644);
await expect(
writeAgentEnvironmentProjection({
mosaicHome,
agentEnvDir,
agentName: 'coder0',
generated: generatedValues,
}),
).rejects.toThrow(/permissions/i);
});
it('rejects an existing group/world-accessible projection directory before reading it', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic');
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
const localPath = join(agentEnvDir, 'coder0.env.local');
const generatedPath = join(agentEnvDir, 'coder0.env.generated');
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
await writeFile(localPath, 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n', { mode: 0o600 });
await chmod(agentEnvDir, 0o777);
await expect(
writeAgentEnvironmentProjection({
mosaicHome,
agentEnvDir,
agentName: 'coder0',
generated: generatedValues,
}),
).rejects.toThrow(/unsafe-permissions/i);
await expect(readFile(localPath, 'utf8')).resolves.toBe('MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n');
await expect(readFile(generatedPath, 'utf8')).rejects.toThrow();
});
it.each([
['MOSAIC_HOME', 'symlink'],
['MOSAIC_HOME/fleet', 'symlink'],
['MOSAIC_HOME/fleet/agents', 'symlink'],
['MOSAIC_HOME', 'group/world-writable'],
['MOSAIC_HOME/fleet', 'group/world-writable'],
['MOSAIC_HOME/fleet/agents', 'group/world-writable'],
])(
'rejects a %s %s managed ancestor before any projection mutation',
async (ancestor: string, hazard: string): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic');
const fleetDir = join(mosaicHome, 'fleet');
const agentEnvDir = join(fleetDir, 'agents');
const generatedPath = join(agentEnvDir, 'coder0.env.generated');
const localPath = join(agentEnvDir, 'coder0.env.local');
const legacyPath = join(agentEnvDir, 'coder0.env');
const quarantinePath = join(agentEnvDir, 'coder0.env.quarantine');
const managedPaths: Record<string, string> = {
MOSAIC_HOME: mosaicHome,
'MOSAIC_HOME/fleet': fleetDir,
'MOSAIC_HOME/fleet/agents': agentEnvDir,
};
const unsafePath = managedPaths[ancestor];
if (unsafePath === undefined) throw new Error(`Unknown managed ancestor: ${ancestor}`);
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
await chmod(mosaicHome, 0o700);
await chmod(fleetDir, 0o700);
await chmod(agentEnvDir, 0o700);
await writeFile(generatedPath, 'generated-before\n', { mode: 0o600 });
await writeFile(localPath, 'MOSAIC_RUNTIME_BIN=/safe/runtime\n', { mode: 0o600 });
await writeFile(legacyPath, 'MOSAIC_AGENT_COMMAND=legacy-command\n', { mode: 0o600 });
if (hazard === 'symlink') {
const targetPath = `${unsafePath}-target`;
await rename(unsafePath, targetPath);
await symlink(targetPath, unsafePath, 'dir');
} else {
await chmod(unsafePath, 0o777);
}
await expect(
writeAgentEnvironmentProjection({
mosaicHome,
agentEnvDir,
agentName: 'coder0',
generated: generatedValues,
}),
).rejects.toThrow(/unsafe-(directory|permissions)/i);
await expect(readFile(generatedPath, 'utf8')).resolves.toBe('generated-before\n');
await expect(readFile(localPath, 'utf8')).resolves.toBe('MOSAIC_RUNTIME_BIN=/safe/runtime\n');
await expect(readFile(legacyPath, 'utf8')).resolves.toBe(
'MOSAIC_AGENT_COMMAND=legacy-command\n',
);
await expect(readFile(quarantinePath, 'utf8')).rejects.toThrow();
},
);
it('rejects a symlinked projection directory without mutating its target', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic');
const targetDirectory = join(cleanup, 'target');
const linkedDirectory = join(mosaicHome, 'fleet', 'agents');
const legacyPath = join(targetDirectory, 'coder0.env');
const generatedPath = join(targetDirectory, 'coder0.env.generated');
const localPath = join(targetDirectory, 'coder0.env.local');
const quarantinePath = join(targetDirectory, 'coder0.env.quarantine');
await mkdir(join(mosaicHome, 'fleet'), { recursive: true, mode: 0o700 });
await mkdir(targetDirectory, { mode: 0o700 });
await writeFile(legacyPath, 'MOSAIC_AGENT_COMMAND=legacy-command\n', { mode: 0o600 });
await writeFile(generatedPath, 'generated-before\n', { mode: 0o600 });
await writeFile(localPath, 'local-before\n', { mode: 0o600 });
await writeFile(quarantinePath, 'quarantine-before\n', { mode: 0o600 });
await symlink(targetDirectory, linkedDirectory, 'dir');
await expect(
writeAgentEnvironmentProjection({
mosaicHome,
agentEnvDir: linkedDirectory,
agentName: 'coder0',
generated: generatedValues,
}),
).rejects.toThrow(/unsafe-directory/i);
await expect(readFile(legacyPath, 'utf8')).resolves.toBe(
'MOSAIC_AGENT_COMMAND=legacy-command\n',
);
await expect(readFile(generatedPath, 'utf8')).resolves.toBe('generated-before\n');
await expect(readFile(localPath, 'utf8')).resolves.toBe('local-before\n');
await expect(readFile(quarantinePath, 'utf8')).resolves.toBe('quarantine-before\n');
});
});

View File

@@ -0,0 +1,509 @@
import { createHash, randomUUID } from 'node:crypto';
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
export type AgentEnvironmentKind = 'generated' | 'local';
export interface AgentEnvironmentDiagnostic {
readonly code: string;
readonly key: string;
readonly sha256: string;
}
export interface AgentEnvironmentProjectionOptions {
readonly mosaicHome: string;
readonly agentEnvDir: string;
readonly agentName: string;
readonly generated: Readonly<Record<string, string>>;
}
export interface AgentEnvironmentProjectionResult {
readonly generatedPath: string;
readonly localPath: string;
readonly quarantinePath?: string;
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
}
/** A projection fully validated without changing managed files. */
export interface PreparedAgentEnvironmentProjection {
readonly mosaicHome: string;
readonly agentEnvDir: string;
readonly generatedPath: string;
readonly localPath: string;
readonly legacyPath: string;
readonly generated: string;
readonly local: string;
readonly legacy?: string;
readonly quarantinePath?: string;
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
}
export class AgentEnvBoundaryError extends Error {
readonly diagnostic: AgentEnvironmentDiagnostic;
constructor(code: string, key: string, value: string) {
const diagnostic: AgentEnvironmentDiagnostic = {
code,
key,
sha256: hashValue(value),
};
super(`Agent environment rejected: code=${code} key=${key} sha256=${diagnostic.sha256}`);
this.name = 'AgentEnvBoundaryError';
this.diagnostic = diagnostic;
}
}
export const GENERATED_AGENT_ENV_KEYS = [
'MOSAIC_AGENT_NAME',
'MOSAIC_AGENT_CLASS',
'MOSAIC_AGENT_RUNTIME',
'MOSAIC_AGENT_MODEL',
'MOSAIC_AGENT_REASONING',
'MOSAIC_AGENT_TOOL_POLICY',
'MOSAIC_AGENT_WORKDIR',
'MOSAIC_TMUX_SOCKET',
] as const;
export const LOCAL_AGENT_ENV_KEYS = [
'MOSAIC_RUNTIME_BIN',
'MOSAIC_HEARTBEAT_RUN_DIR',
'MOSAIC_HEARTBEAT_INTERVAL',
'MOSAIC_CLAUDE_JSON',
'CLAUDE_CONFIG_DIR',
] as const;
const GENERATED_KEY_SET = new Set<string>(GENERATED_AGENT_ENV_KEYS);
const LOCAL_KEY_SET = new Set<string>(LOCAL_AGENT_ENV_KEYS);
const SENSITIVE_KEY = /(?:API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN)/i;
const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const POLICY_NAME = /^[a-z][a-z0-9-]*$/;
const TMUX_SOCKET = /^[A-Za-z0-9_.-]*$/;
const MODEL = /^[A-Za-z0-9._/:+-]*$/;
/** Runtime launchers supported by the generated environment contract. */
export const GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES = [
'claude',
'codex',
'opencode',
'pi',
] as const;
const SUPPORTED_RUNTIMES = new Set<string>(GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES);
const REASONING = new Set(['', 'low', 'medium', 'high']);
/** Parses a strict data-only generated or local environment file without shell evaluation. */
export function parseAgentEnvironment(
source: string,
kind: AgentEnvironmentKind,
): Readonly<Record<string, string>> {
const values: Record<string, string> = {};
const seen = new Set<string>();
for (const line of source.split('\n')) {
if (line === '') continue;
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
if (!match) throw new AgentEnvBoundaryError('malformed-line', '(malformed)', line);
const [, key, value] = match;
if (key === undefined || value === undefined) {
throw new AgentEnvBoundaryError('malformed-line', '(malformed)', line);
}
if (seen.has(key)) throw new AgentEnvBoundaryError('duplicate-key', key, value);
seen.add(key);
if (SENSITIVE_KEY.test(key)) throw new AgentEnvBoundaryError('sensitive-key', key, value);
if (containsShellSyntax(value)) throw new AgentEnvBoundaryError('unsafe-value', key, value);
if (kind === 'generated') {
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
} else {
if (GENERATED_KEY_SET.has(key))
throw new AgentEnvBoundaryError('generated-key-shadow', key, value);
if (!LOCAL_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
}
values[key] = value;
}
if (kind === 'generated') assertGeneratedValues(values);
else assertLocalValues(values);
return Object.freeze(values);
}
/** Renders the roster-derived generated projection in a stable, complete key order. */
export function renderGeneratedAgentEnvironment(values: Readonly<Record<string, string>>): string {
const normalized = normalizeGeneratedValues(values);
return `${GENERATED_AGENT_ENV_KEYS.map((key): string => `${key}=${normalized[key]}`).join('\n')}\n`;
}
/** Validates all deterministic projection inputs and target state without mutation. */
export async function prepareAgentEnvironmentProjection(
options: AgentEnvironmentProjectionOptions,
): Promise<PreparedAgentEnvironmentProjection> {
if (!AGENT_NAME.test(options.agentName)) {
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
}
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
const localPath = join(options.agentEnvDir, `${options.agentName}.env.local`);
const legacyPath = join(options.agentEnvDir, `${options.agentName}.env`);
const generated = renderGeneratedAgentEnvironment(options.generated);
await assertPrivateRegularFileIfPresent(generatedPath);
const currentLocal = await readOptionalPrivateFile(localPath);
const parsedLocal =
currentLocal === undefined ? {} : parseAgentEnvironment(currentLocal, 'local');
const legacy = await readOptionalPrivateFile(legacyPath);
const legacyDisposition =
legacy === undefined ? emptyLegacyDisposition() : classifyLegacyEnvironment(legacy);
for (const [key, value] of Object.entries(legacyDisposition.localValues)) {
if (parsedLocal[key] !== undefined && parsedLocal[key] !== value) {
throw new AgentEnvBoundaryError('legacy-local-conflict', key, value);
}
}
const local = renderLocalAgentEnvironment({ ...legacyDisposition.localValues, ...parsedLocal });
const quarantinePath = legacyDisposition.diagnostics.length
? join(options.agentEnvDir, `${options.agentName}.env.quarantine`)
: undefined;
if (quarantinePath !== undefined) {
const existingQuarantine = await readOptionalPrivateFile(quarantinePath);
if (existingQuarantine !== undefined) {
throw new AgentEnvBoundaryError('quarantine-exists', '(quarantine)', existingQuarantine);
}
}
return {
mosaicHome: options.mosaicHome,
agentEnvDir: options.agentEnvDir,
generatedPath,
localPath,
legacyPath,
generated,
local,
...(legacy === undefined ? {} : { legacy }),
...(quarantinePath === undefined ? {} : { quarantinePath }),
diagnostics: legacyDisposition.diagnostics,
};
}
/** Applies a previously prepared deterministic projection. */
export async function applyPreparedAgentEnvironmentProjection(
prepared: PreparedAgentEnvironmentProjection,
): Promise<AgentEnvironmentProjectionResult> {
await ensurePrivateProjectionDirectory(prepared.mosaicHome, prepared.agentEnvDir);
await writePrivateAtomically(prepared.generatedPath, prepared.generated);
if (prepared.local !== '') await writePrivateAtomically(prepared.localPath, prepared.local);
if (prepared.quarantinePath !== undefined && prepared.legacy !== undefined) {
await writePrivateAtomically(prepared.quarantinePath, prepared.legacy);
}
if (prepared.legacy !== undefined) await unlink(prepared.legacyPath);
return {
generatedPath: prepared.generatedPath,
localPath: prepared.localPath,
...(prepared.quarantinePath === undefined ? {} : { quarantinePath: prepared.quarantinePath }),
diagnostics: prepared.diagnostics,
};
}
/** Writes deterministic projection files and explicitly removes legacy `.env` authority. */
export async function writeAgentEnvironmentProjection(
options: AgentEnvironmentProjectionOptions,
): Promise<AgentEnvironmentProjectionResult> {
return applyPreparedAgentEnvironmentProjection(await prepareAgentEnvironmentProjection(options));
}
/** Creates the canonical managed roster path with private fresh-chain permissions. */
export async function writeManagedFleetRoster(
mosaicHome: string,
rosterPath: string,
content: string,
): Promise<void> {
const fleetDir = join(mosaicHome, 'fleet');
const expectedRosterPath = join(fleetDir, 'roster.yaml');
if (resolve(rosterPath) !== resolve(expectedRosterPath)) {
throw new AgentEnvBoundaryError('unsafe-file', '(roster)', rosterPath);
}
await ensureManagedDirectory(mosaicHome, false);
await ensureManagedDirectory(fleetDir, false);
await writePrivateAtomically(rosterPath, content);
}
/** Ensures the private install-derived identity used to own a named tmux server. */
export async function ensureFleetHolderIdentity(mosaicHome: string): Promise<string> {
const fleetDir = join(mosaicHome, 'fleet');
const runDir = join(fleetDir, 'run');
const identityPath = join(runDir, 'holder-owner');
await ensureManagedDirectory(mosaicHome, false);
await ensureManagedDirectory(fleetDir, false);
await ensureManagedDirectory(runDir, true);
const existing = await readOptionalPrivateFile(identityPath);
if (existing !== undefined) {
const identity = existing.trim();
if (!/^[a-f0-9-]{36}$/.test(identity)) {
throw new AgentEnvBoundaryError('unsafe-owner-identity', '(holder-owner)', existing);
}
return identity;
}
const identity = randomUUID();
await writePrivateAtomically(identityPath, `${identity}\n`);
return identity;
}
function normalizeGeneratedValues(
values: Readonly<Record<string, string>>,
): Readonly<Record<string, string>> {
const normalized: Record<string, string> = {};
for (const key of GENERATED_AGENT_ENV_KEYS) {
const value = values[key];
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
normalized[key] = value;
}
for (const [key, value] of Object.entries(values)) {
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
}
assertGeneratedValues(normalized);
return Object.freeze(normalized);
}
function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>): string {
if (Object.keys(values).length === 0) return '';
const parsed = parseAgentEnvironment(
Object.entries(values)
.sort(([left], [right]): number => left.localeCompare(right))
.map(([key, value]): string => `${key}=${value}`)
.join('\n'),
'local',
);
return `${Object.entries(parsed)
.sort(([left], [right]): number => left.localeCompare(right))
.map(([key, value]): string => `${key}=${value}`)
.join('\n')}\n`;
}
function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
for (const key of GENERATED_AGENT_ENV_KEYS) {
const value = values[key];
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
}
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
const reasoning = requiredGeneratedValue(values, 'MOSAIC_AGENT_REASONING');
const toolPolicy = requiredGeneratedValue(values, 'MOSAIC_AGENT_TOOL_POLICY');
const workingDirectory = requiredGeneratedValue(values, 'MOSAIC_AGENT_WORKDIR');
const socket = requiredGeneratedValue(values, 'MOSAIC_TMUX_SOCKET');
if (!AGENT_NAME.test(name))
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
if (!POLICY_NAME.test(className)) {
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
}
if (!SUPPORTED_RUNTIMES.has(runtime)) {
throw new AgentEnvBoundaryError('unsupported-runtime', 'MOSAIC_AGENT_RUNTIME', runtime);
}
if (!MODEL.test(model))
throw new AgentEnvBoundaryError('unsafe-model', 'MOSAIC_AGENT_MODEL', model);
if (!REASONING.has(reasoning)) {
throw new AgentEnvBoundaryError('unsupported-reasoning', 'MOSAIC_AGENT_REASONING', reasoning);
}
if (toolPolicy !== '' && !POLICY_NAME.test(toolPolicy)) {
throw new AgentEnvBoundaryError('unsafe-tool-policy', 'MOSAIC_AGENT_TOOL_POLICY', toolPolicy);
}
if (!isSafeAbsolutePath(workingDirectory)) {
throw new AgentEnvBoundaryError('unsafe-path', 'MOSAIC_AGENT_WORKDIR', workingDirectory);
}
if (!TMUX_SOCKET.test(socket)) {
throw new AgentEnvBoundaryError('unsafe-socket', 'MOSAIC_TMUX_SOCKET', socket);
}
}
function requiredGeneratedValue(values: Readonly<Record<string, string>>, key: string): string {
const value = values[key];
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
return value;
}
function assertLocalValues(values: Readonly<Record<string, string>>): void {
for (const [key, value] of Object.entries(values)) {
if (key === 'MOSAIC_HEARTBEAT_INTERVAL') {
if (!/^[1-9][0-9]*$/.test(value)) {
throw new AgentEnvBoundaryError('invalid-interval', key, value);
}
continue;
}
if (!isSafeAbsolutePath(value)) throw new AgentEnvBoundaryError('unsafe-path', key, value);
}
}
function isSafeAbsolutePath(value: string): boolean {
return (
value.startsWith('/') && !value.split('/').includes('..') && !/[\s"'`$\\;&|<>(){}]/.test(value)
);
}
function containsShellSyntax(value: string): boolean {
return /["'`$\\;&|<>(){}]/.test(value);
}
interface LegacyDisposition {
readonly localValues: Readonly<Record<string, string>>;
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
}
function emptyLegacyDisposition(): LegacyDisposition {
return { localValues: {}, diagnostics: [] };
}
function classifyLegacyEnvironment(source: string): LegacyDisposition {
const localValues: Record<string, string> = {};
const diagnostics: AgentEnvironmentDiagnostic[] = [];
const seen = new Set<string>();
for (const line of source.split('\n')) {
if (line === '') continue;
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
if (!match || match[1] === undefined || match[2] === undefined) {
diagnostics.push(diagnostic('malformed-line', '(malformed)', line));
continue;
}
const [, key, value] = match;
if (seen.has(key)) {
diagnostics.push(diagnostic('duplicate-key', key, value));
continue;
}
seen.add(key);
if (GENERATED_KEY_SET.has(key)) continue;
try {
parseAgentEnvironment(`${key}=${value}\n`, 'local');
localValues[key] = value;
} catch (error: unknown) {
if (error instanceof AgentEnvBoundaryError) diagnostics.push(error.diagnostic);
else throw error;
}
}
return { localValues: Object.freeze(localValues), diagnostics: Object.freeze(diagnostics) };
}
function diagnostic(code: string, key: string, value: string): AgentEnvironmentDiagnostic {
return { code, key, sha256: hashValue(value) };
}
function hashValue(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
async function readOptionalPrivateFile(path: string): Promise<string | undefined> {
try {
await assertPrivateRegularFile(path);
return await readFile(path, 'utf8');
} catch (error: unknown) {
if (isMissingFile(error)) return undefined;
throw error;
}
}
function isMissingFile(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
}
async function validatePrivateProjectionDirectory(
mosaicHome: string,
agentEnvDir: string,
): Promise<void> {
const fleetDir = join(mosaicHome, 'fleet');
const expectedAgentEnvDir = join(fleetDir, 'agents');
if (resolve(agentEnvDir) !== resolve(expectedAgentEnvDir)) {
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', agentEnvDir);
}
await assertManagedDirectoryIfPresent(mosaicHome, false);
await assertManagedDirectoryIfPresent(fleetDir, false);
await assertManagedDirectoryIfPresent(agentEnvDir, true);
}
async function ensurePrivateProjectionDirectory(
mosaicHome: string,
agentEnvDir: string,
): Promise<void> {
await validatePrivateProjectionDirectory(mosaicHome, agentEnvDir);
const fleetDir = join(mosaicHome, 'fleet');
await ensureManagedDirectory(mosaicHome, false);
await ensureManagedDirectory(fleetDir, false);
await ensureManagedDirectory(agentEnvDir, true);
}
async function ensureManagedDirectory(path: string, privateDirectory: boolean): Promise<void> {
let created = false;
try {
await lstat(path);
} catch (error: unknown) {
if (!isMissingFile(error)) throw error;
try {
await mkdir(path, { recursive: true, mode: 0o700 });
created = true;
} catch (mkdirError: unknown) {
if (!isAlreadyExists(mkdirError)) throw mkdirError;
}
}
await assertManagedDirectory(path, privateDirectory);
if (created) {
await chmod(path, 0o700);
await assertManagedDirectory(path, privateDirectory);
}
}
function isAlreadyExists(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST';
}
async function assertManagedDirectoryIfPresent(
path: string,
privateDirectory: boolean,
): Promise<void> {
try {
await assertManagedDirectory(path, privateDirectory);
} catch (error: unknown) {
if (isMissingFile(error)) return;
throw error;
}
}
async function assertManagedDirectory(path: string, privateDirectory: boolean): Promise<void> {
const metadata = await lstat(path);
if (!metadata.isDirectory()) {
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', path);
}
if ((metadata.mode & 0o022) !== 0 || (privateDirectory && (metadata.mode & 0o077) !== 0)) {
throw new AgentEnvBoundaryError('unsafe-permissions', '(directory)', path);
}
}
async function assertPrivateRegularFileIfPresent(path: string): Promise<void> {
try {
await assertPrivateRegularFile(path);
} catch (error: unknown) {
if (isMissingFile(error)) return;
throw error;
}
}
async function assertPrivateRegularFile(path: string): Promise<void> {
const metadata = await lstat(path);
if (!metadata.isFile()) throw new AgentEnvBoundaryError('unsafe-file', '(file)', path);
if ((metadata.mode & 0o077) !== 0) {
throw new AgentEnvBoundaryError('unsafe-permissions', '(file)', path);
}
}
async function writePrivateAtomically(path: string, content: string): Promise<void> {
try {
await assertPrivateRegularFile(path);
} catch (error: unknown) {
if (!isMissingFile(error)) throw error;
}
const temporaryPath = join(dirname(path), `.${randomUUID()}.tmp`);
await writeFile(temporaryPath, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
await rename(temporaryPath, path);
}