Compare commits

..
Author SHA1 Message Date
code-be-01 15a6969688 feat(config): installation minimal-subset — schema, adapters, core, render (CONFIGIMPL)
ci/woodpecker/pr/ci Pipeline was successful
Implements the config minimal desired-state subset per the PASSED spec
docs/specs/2026-08-29_mosaic-config-minimal-subset.md (spec review PASS
deff617d, rev-code-02). Dependency gate honored: registry adapter is
an explicit stub; profile/role resolution and desired-roster generation
are notChecked. 26 test arms green.
2026-08-28 22:00:14 -05:00
marcieandorch-01 41e8046371 framework tools/git: issue-comment R1/R4 usage-error contract (sync from brain) (#1462)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: marcie <[email protected]>
2026-08-29 01:58:42 +00:00
fred 6e16675ea2 docs: deployment mode and conversion contract (S2 contract 6) (#1439)
ci/woodpecker/push/publish Pipeline was successful
2026-08-28 22:57:11 +00:00
fred 19ebc422aa docs: tool-gateway mapping contract (S2 contract 5) (#1438)
ci/woodpecker/push/publish Pipeline was successful
2026-08-28 22:04:03 +00:00
16 changed files with 2242 additions and 645 deletions
+279
View File
@@ -0,0 +1,279 @@
# Deployment Mode and Conversion Contract (D3)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 6 of 9).
Authority: PRD D3 (Part I §3) — two modes chosen at install time,
Standalone and Enterprise, with the mode table (brains, user-data
isolation, secrets, conversion); Standalone → Enterprise conversion is
**one-way** and Enterprise is a **terminal state**. PRD D14 (Part I §7)
— the per-user brain split is optional in Standalone and keeping it is
the recommended default because it preserves forward-compatibility with
the one-way conversion. PRD D11 (Part I §9) — v1 ships the Standalone
flow only; Enterprise conversion is explicitly deferred. PRD D3
federation clause — federation is intentionally not fully designed,
deferred, and nothing in v1 may foreclose it.
Revision 2 (luna review F1F7): the identity precondition restated in
identity-contract terms with a conversion-local acknowledgment record
this contract owns (F1); a durable, keyed preparation state with a
Standalone-safe representation rule, an in-transaction re-check fence,
and an exact flip boundary (F2); the §5.4 unknown-value rule stated
directly without the contradictory non-exhaustiveness clause (F3); the
D14 boundary bound here with a stable column-allowlist witness instead
of delegated to an unratified layout (F4); the conversion witness
matrix extended to every §4.2/§4.4 condition (F5); the mode-record
writer coverage imported concretely from contract 1 §6.3 with a named
schema, closed writer set, crafted-write probe, and mode-resolution
assertion (F6); the mode read command flagged as a §12.1 drafting
addition rather than a D8 mandate (F7). Ownership language aligned
with contract 3 revision 2: mode is recorded at bootstrap and read by
the wizard as input.
This contract binds the mode as a canonical platform property (§2), the
per-mode obligations and which contract owns each (§3), the conversion
transition (§4), the v1 non-foreclosure obligations (§5), and their
witnesses (§6). Domain semantics stay with their owning contracts:
wizard branching (contract 3 §2), identity/SSO
(`identity-lifecycle.md`), custody and per-user brain mechanics
(contract 7, `custody-schema.md`), tool mapping
(`tool-gateway-mapping.md`).
## 1. Definitions
1. **Mode**: the platform-wide deployment mode, exactly one of
`standalone` or `enterprise`. The vocabulary is closed in v1;
extension (e.g. a federation mode) is by amendment to this contract,
never ad hoc.
2. **Conversion**: the one-way transition `standalone → enterprise`.
No other mode transition exists.
3. **Conversion preconditions**: the verifiable conditions of §4.2 that
must all hold before the mode record may change.
4. **Preparation unit**: one re-runnable piece of pre-conversion work —
the migration of one secret to the Vault backend, or the partition
of one user's brain content (§4.3).
## 2. Mode is a canonical recorded property
1. Mode is recorded canonically in the platform database at bootstrap
as the operator's install-time choice (D3: modes are "chosen at
install time"). The record is a single-row keyed record
(`platform_mode`: mode value, recorded-at timestamp, bootstrap epoch
reference); this contract owns it, the bootstrap writer performs the
one v1 write (§6.2), and the wizard reads it as input (contract 3
§2.3). Mode is never derived from feature state (presence of Vault,
count of brains, count of users), and no component may infer a
different mode than the record states.
2. The record is readable by any authenticated user through a Gateway
command with CLI exposure. This read command is a **drafting
addition** ratified with this contract (PRD §12.1), not a D8
mandate: D8 binds only that any surface exposing the value goes
through official tooling. When a webUI surface consumes the read, a
mapping row is added to `tool-gateway-mapping.md` by amendment —
the same route §4.4 already binds for the conversion command.
Components branch on the read value only.
3. The record is immutable except by the §4 conversion transition.
Editing it by direct database access, config file, environment
variable, or wizard re-run is non-conformant (contract 3 §2.3:
changing mode later is conversion, not a wizard re-run).
## 3. Per-mode obligations (owner map)
The PRD mode table binds four rows; this contract assigns each an
owning contract so no obligation is unowned and none is bound twice:
| Obligation | Standalone | Enterprise | Owner |
| ------------------- | -------------------------------------- | -------------------------------------------------- | --------------------------------------------- |
| Brains | one mosaic-brain (system + user files) | system brain for config + one brain per user | contract 7 (custody/brain mechanics) |
| User-data isolation | single user | no user-data leakage between users; sharing opt-in | contract 7 (enforced by architecture, D14) |
| Secrets | OpenBao/Vault or flat files | OpenBao/Vault REQUIRED | this contract (§4.2 gate; steady-state check) |
| Conversion | may convert to Enterprise, one-way | terminal state | this contract (§4) |
The Standalone brains row states the default layout, not the only
valid one: the D14 per-user split is a MAY in Standalone with keeping
it the recommended default (PRD §7, contract 7 §6), and Vault-backed
secrets are equally valid Standalone configuration. Both prepared
states are therefore themselves valid Standalone states — the fact
§4.3 relies on.
In Enterprise steady state, a flat-file secrets backend is
non-conformant; the platform refuses to start Enterprise-mode
components against a flat-file secrets configuration (fail-closed, not
warn-and-run).
## 4. Conversion transition
1. **Direction and terminality.** The only transition is
`standalone → enterprise`. `enterprise → standalone` does not exist:
there is no command, no admin override, and no support path. An
attempt is refused with the precondition/state error class of the
command envelope (`tool-gateway-mapping.md` §4.2).
2. **Preconditions (all verified before the record changes):**
- Secrets: OpenBao/Vault is configured and reachable, and every
required secret is served from the Vault backend — none from a
flat-file backend. Secret migration completes before conversion;
this contract does not define the migration tooling, only the
gate.
- Brains: the per-user brain split required by the Enterprise row of
§3 is established for **every** existing user (or the deployment
already kept the split, the D14 recommended default). Brain
partitioning mechanics are contract 7; this contract binds only
that the split is complete before the mode flips.
- Identity: at least one platform administrator account exists that
is active in identity-contract terms — authenticated capability,
not banned, not deactivated (identity §2, §5). And the conversion
request carries a **configuration acknowledgment**: the current
canonical values of registration mode and per-provider JIT
enablement (identity §2.2, §4.1), echoed back in the request. A
mismatch between the echoed values and the canonical values at
verification refuses the conversion. This acknowledgment record
is conversion-local, owned by this contract, and stored with the
§4.4 audit event as the precondition evidence; it adds no
identity-contract obligation and no mode-specific identity
default — identity's own defaults remain valid states.
3. **Preparation state and the flip boundary.** Preparatory work is
tracked durably: each preparation unit (§1.4) records its
completion in a preparation table keyed by (bootstrap epoch, unit
identity — the secret's path, the user's id), written in the same
transaction as the unit's own effect where the unit's backend
allows it, and reconciled from the backend's actual state where it
does not (a secret already served by Vault, a brain already split,
is complete regardless of the table). Units are at-most-once per
key and re-runnable across attempts. **Standalone-safe
representation:** every preparation unit moves the deployment into
a state that is itself valid Standalone configuration (§3 note), so
an interrupted preparation leaves a fully operational Standalone
deployment reading its state through the ordinary contracts — no
rollback, fencing, or special Standalone read path is needed, and
no component behavior may key on "preparation in progress".
**The flip:** one transaction that (a) locks the mode record, (b)
re-verifies every §4.2 precondition after acquiring the lock, and
(c) writes the mode record and the §4.4 audit event. Any re-check
failure aborts with no write. External state that changes after the
re-check but before commit is bounded by the transaction window;
an external backend (Vault) failing after conversion is an
Enterprise runtime fault handled by §3's fail-closed steady-state
rule, not a conversion defect. An interrupted or failed conversion
leaves the record `standalone` and the platform fully operational;
there is no intermediate mode and no half-converted state
observable through the record.
4. **Authority and audit.** Conversion is a platform-administrator
command carrying an explicit irreversibility acknowledgment in its
request (distinct from the §4.2 configuration acknowledgment). It
is an official Gateway/CLI command (D8): when built, it is added to
the tool↔Gateway mapping by amendment (`tool-gateway-mapping.md`
§3.3). The transition emits an audit event (actor, prior mode, new
mode, precondition evidence reference including the configuration
acknowledgment) in the same transaction as the record change; the
event survives indefinitely. A refused attempt emits a refusal
event naming the failed precondition class and actor, with no
mode-change event.
## 5. v1 obligations (non-foreclosure)
v1 ships Standalone only (D11); the conversion command is deferred
work. v1 still MUST:
1. Record the mode per §2 at bootstrap, with `enterprise` a reserved,
refused value for bootstrap — v1 bootstrap accepts `standalone`
only. The wizard reads the record (contract 3 §2.3); nothing in v1
writes it after bootstrap.
2. Keep the §2.3 immutability rule: no v1 surface mutates the mode
record.
3. Not foreclose conversion: the v1 platform database holds no
sensitive user content — sensitive categories live in the owning
user's brain, and postgres holds structure, consent records, and
pointers only (the D14 boundary, PRD §7). Custody mechanics are
contract 7's; this contract binds the boundary itself here so v1
cannot ship a layout that makes the §4.2 brain precondition
unsatisfiable, and §6.3 gives it a stable witness that does not
depend on contract 7's internals. Conversion implementation
additionally requires contract 7 ratified.
4. Not foreclose federation: v1 components accept exactly the two §1.1
values wherever a mode value is parsed and refuse any other value
**before side effects** — a refused configuration, not undefined
behavior and not a crash mid-operation. Forward compatibility lives
in storage and architecture, not in parser speculation: the mode
record's storage is not structurally locked to two values (no
database-level two-value enum), and any future value (e.g. a
federation mode) is defined by a versioned amendment to this
contract before any component accepts it. The PRD defers
federation's shape entirely; this contract does not presume it
arrives as a third mode value.
## 6. Verification requirements
Binding on the implementing PRs:
1. **Mode-record witness (v1):** after bootstrap the mode is readable
via the Gateway command and CLI and equals the bootstrap-recorded
choice; bootstrap with mode `enterprise` is refused; bootstrap with
any unknown mode value is refused before side effects (§5.4).
2. **Writer-coverage witness (v1):** the mode record's writer set is
closed by the same three-prong static assertion contract 1 §6.3(b)
defines — symbol, class-table literal, and raw-execution prongs
with its allowlist composition rules — scoped to the
`platform_mode` table, with a writer allowlist containing exactly
the bootstrap writer in v1 (and exactly plus the conversion command
at the conversion milestone). Companions: a crafted direct write
attempted in a test fails and leaves the record unchanged; a
mode-resolution assertion that no shipped component derives mode
from feature state (mode reads occur only through the §2.2 read
surface — static assertion over Gateway, CLI, bootstrap, and
repository sources).
3. **D14-boundary witness (v1):** a column-allowlist assertion in the
style of contract 1 §6.2 that the platform database schema contains
no sensitive-content column — the §5.3 boundary — stable regardless
of contract 7's internals (contract 7 §7 carries the full custody
witnesses).
4. **No-downgrade witness (conversion milestone):** with mode
`enterprise`, a conversion request to `standalone` (and any crafted
mode-write) is refused with the precondition/state error class and
no record change.
5. **Precondition witnesses (conversion milestone),** each refused
with no record change and no partial mode effect, parameterized
over both OpenBao and Vault where secrets are involved:
(a) secrets backend unreachable; (b) one required secret still
flat-file backed (migration incomplete); (c) one unpartitioned user
brain in a **multi-user** deployment where every other user is
partitioned; (d) no active platform administrator (the only admin
banned or deactivated); (e) configuration acknowledgment missing or
mismatching the canonical registration/JIT values; (f) actor not a
platform administrator (authorization refusal); (g) irreversibility
acknowledgment absent. And the steady-state rule: an
Enterprise-mode component started against a flat-file secrets
configuration refuses to start (§3).
6. **Interruption and fence witnesses (conversion milestone):** fault
injection aborting conversion after each preparation unit and
between preparation and flip leaves the record `standalone` and the
platform operational in Standalone semantics (§4.3
Standalone-safety), and a re-attempt completes without duplicating
prepared state (at-most-once keys); a precondition invalidated
after preparation but before the flip (a secret reverted to
flat-file) is caught by the in-transaction re-check and refused.
7. **Audit witnesses (conversion milestone):** a completed conversion
has exactly one mode-change audit event, same-transaction with the
record change (transaction linkage asserted), carrying actor, prior
mode, new mode, and the precondition evidence reference including
the configuration acknowledgment; a failed attempt has a refusal
event naming the failed precondition class and no mode-change
event; the mode-change event remains queryable after subsequent
unrelated audit activity (retention probe).
8. **Mapping witness (conversion milestone):** the conversion command
and the mode read command each have their
`tool-gateway-mapping.md` row (added by amendment per §2.2/§4.4)
before the commands ship.
## Ruling request
Ratify sections 16 as written, with one decision embedded:
- Decision (§5): v1 implements the **mode record and its immutability
only** — bootstrap records `standalone`, the `enterprise` value is
reserved and refused, and the conversion command itself is deferred
to the Enterprise milestone, consistent with D11's deferred list.
v1 carries three obligations beyond the record: the closed writer
assertion, the D14 column boundary, and the unknown-value refusal
(§6.1–§6.3) — these are the non-foreclosure floor, not hidden
conversion work. Alternative if rejected: build the conversion
command inside v1 — rejected because D11 scopes v1 to the Standalone
slice and conversion depends on contract 7 custody mechanics that
are themselves not in the v1 slice.
+197
View File
@@ -0,0 +1,197 @@
# Tool↔Gateway Mapping Contract (D8)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 5 of 9).
Authority: PRD D8/D12 (Part I §8) — the webUI sits OVER official tooling:
every webUI operation goes through the Gateway API backed by the same
official framework tooling the CLI uses, and a webUI operation with no
backing tool is scored **blocked on tooling** and the tool is built
first. Measured input: the webui-audit A5 tooling baseline
(operation-by-operation inventory of the current Gateway surface and the
P1 gaps, cross-reviewed; `fleet/lanes/webui-audit/findings/
A5-tooling-baseline.md` in the estate brain). The T10 ruling adopted the
targeted-update plan including building the D8 tools in A5's rank order.
Revision 2 (GLM review F1F5): the §2 table completed against an
independent re-measurement of the live `apps/web` surface (mission
reads, coordination status, capability-gated `turn:send` added); rank-6
composition corrected to ranks 1 and 4; SOT citations corrected to §3
invariant 11 / REQ-TASK-001 / §5+A1; the §3.2 retirement clause
softened to match what the owning contracts actually schedule; §6.1
scoped to outbound calls with an extractability lint, and §6.3 given
static companions for §4.1 and §4.3.
This contract binds three things: the operation→tool mapping itself
(§2–§3), the command envelope every mapped operation satisfies
(§4), and the process rule that keeps the mapping closed (§5). Domain
semantics stay with their owning contracts — hierarchy (contract 1,
`hierarchy-schema.md`), grants (contract 2, `rbac-grant-model.md`),
wizard (contract 3, `onboarding-wizard.md`), identity
(`identity-lifecycle.md`), kanban lifecycle (`native-kanban-sot.md`
§5 and Amendment A1), roll-up (contract 8), API artifact format
(contract 9).
## 1. Definitions
1. **Official tool**: a command implemented in the framework packages and
exposed through the Gateway API; the CLI remains the primary execution
method for the same command (D8). The webUI is a Gateway client only.
2. **Mapped operation**: a webUI operation with a named official path in
§2 or §3. Anything else the webUI wants to do is unmapped and follows
§5.
3. **Legacy non-substitute**: an existing endpoint that resembles a P1
need but is contractually barred from backing it (§3.2).
## 2. P0 mapping (current operations, ratified as-is)
This table is the complete measured P0 surface: every Gateway call the
web app's production sources make at this revision's head appears as a
row (independently re-measured at review; the three calls the first
measurement missed — mission reads, coordination status, and the
capability-gated `turn:send` emit — are rows below). The surface stays
bound to these paths:
| WebUI operation | Official path |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Register / log in / log out / OIDC callback | better-auth mount `/api/auth/*`; `GET /api/sso/providers` |
| List/show projects (legacy read) | `GET /api/projects`, `GET /api/projects/:id` |
| List tasks / task detail (legacy read) | `GET /api/tasks`, `GET /api/tasks/:id` — with the filtered legacy project/mission reads the same surfaces use |
| Mission list (legacy read) | `GET /api/missions` |
| Coordination status (legacy read) | `GET /api/coord/status` |
| Conversation CRUD/search/messages | `/api/conversations*` |
| Chat turn / stop / thinking / command execute+approve / streaming | `/chat` socket events `message`, `abort`, `set:thinking`, `command:execute`, `command:approve`; `turn:send` (capability-gated — emitted only when the server advertises the pi turn-runtime capability, which the current Gateway does not) |
| Harness/model selection | `GET /api/harnesses*`, `GET/PUT /api/chat/preferences/selection` |
| Preferences; provider inspect/test | `/api/memory/preferences`, `GET /api/providers`, `POST /api/providers/test` |
| Admin users / roles / ban / health | `/api/admin/users*`, `/api/admin/health` |
P0 rows inherit §4 obligations as their backing controllers are next
touched; they are not required to be retrofitted in one sweep.
## 3. P1 mapping (bound to the build-first tools)
1. Every P1 operation maps to exactly one build-first command family, in
the T10-ruled rank order:
| Rank | Command family (owning contract) | P1 webUI operations it backs |
| ---- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Hierarchy command family (contract 1 §5; grants attach per contract 2) | Company/estate/platform-project/workspace CRUD, parentage and reparenting, hierarchy reads; the wizard's initial-hierarchy step (contract 3 §3.4) |
| 2 | Hierarchy RBAC command/evaluator (contract 2) | Grant create/change/revoke at company/estate/platform-project; inherited evaluation down to workspace; authorization-safe hierarchy queries |
| 3 | Typed kanban command/query surface (SOT §5, Amendment A1) | Workspace task lifecycle (create/edit/cancel/archive/move), board rank, typed queries |
| 4 | Agent enrollment command | Enroll one agent: harness, credential reference/API-key intake (values never echoed), name/persona, assignment scope (contract 3 §3.5) |
| 5 | Authorized roll-up query (contract 8) | Read-only aggregated task counts/statuses at every hierarchy level over readable workspaces only |
| 6 | Onboarding orchestration (contract 3) | The re-runnable wizard flow, composing ranks 1 and 4 (its only grant write rides inside the rank-1 company-create command, contract 2 §4.3) |
2. **Legacy non-substitutes.** The following MUST NOT back any P1
operation, matching the audit findings: legacy `/api/projects` and
`/api/tasks` CRUD (planning-data records, not hierarchy nodes and not
the typed kanban boundary); `POST /api/workspaces` (filesystem
bootstrap, not audited hierarchy parentage); `/api/teams` reads (no
grants, no inheritance); `POST /api/bootstrap/setup` (one-shot
epoch transition, identity §3 — not the re-runnable wizard); the MCP
`brain_*` task mutations (legacy Brain writes, not the typed kanban
commands). These stay serving their existing P0/host consumers until
the owning contract (or a successor amendment) schedules each
retirement — no such migration is scheduled at this revision; the
freeze stands on its own.
3. New P1 mapping rows (operations this table does not list) are added by
amending this contract, not ad hoc (§5).
## 4. Command envelope (request / result / error / audit)
Binding on every mapped operation the build-first families expose:
1. **Typed request and result.** Each command and query has an explicit
request DTO and result DTO in the shared types package, validated at
the Gateway boundary; unvalidated pass-through and `any`-typed
payloads are non-conformant. Mutations on records with an
expected-version rule in their owning contract carry the expected
version in the request and fail on mismatch with the conflict error
class (SOT §3 invariant 11 and REQ-TASK-001's concurrent-update
conflict acceptance; hierarchy per contract 1).
2. **Error taxonomy.** Every error result carries a stable
machine-readable code from a closed per-family enum plus an HTTP
status mapping, distinguishing at minimum: validation failure,
authentication failure, authorization refusal, not-found, conflict
(version/uniqueness), precondition/state refusal (e.g. bootstrap
epoch, suspended team subjects), and internal fault. Where contract
2's no-existence-oracle rule applies, authorization refusal and
not-found are indistinguishable on the wire for unauthorized readers
— same code, same status, same shape.
3. **Audit linkage.** A mutating mapped operation emits exactly the
audit events its owning contract defines (contract 1 §5.2, contract 2
§4.4, identity §§24, SOT audit rules); the envelope contributes the
correlation: every request accepts/generates a correlation id,
carried into the audit events and returned in the result, so a UI
action is traceable end to end. The mapping layer itself adds no
second audit stream.
4. **Fail-closed.** A mapped operation that cannot evaluate its
authorization or reach its owning tool refuses (contract 2 §3.5); the
envelope never degrades to an unauthorized fallback read or a direct
data access.
5. **CLI parity.** Each build-first family is invocable through the
official CLI against the same Gateway commands with the same
request/result/error contracts. No webUI-only command exists; a
Gateway command without CLI exposure is a conformance gap tracked at
the family's implementing issue.
## 5. Closure rule (blocked on tooling)
1. A webUI change that needs an operation with no mapping row is
**blocked on tooling**: the backing tool is built and mapped first
(D8). Scoring a gap "blocked on tooling" is mandatory, not
discretionary; working around it in the UI (direct DB or filesystem
access, calling a legacy non-substitute, embedding domain logic in
the web app) is non-conformant.
2. The mapping is enforced closed by §6.1's inventory witness: the web
app's network surface must be a subset of the mapped paths.
## 6. Verification requirements
Binding on the implementing PRs:
1. **Network-surface inventory witness:** a CI assertion extracting the
web app's outbound Gateway calls — route literals at request call
sites and outbound socket emits in `apps/web` sources (inbound
handler registrations are not calls and are out of scope) — and
failing on any call outside the §2/§3 mapped paths. The inventory is
closed like contract 1 §6.3's allowlist: a new call fails until a
mapping row exists in the same PR. Dynamic route construction that
evades extraction is resolved toward the witness, enforced by an
extractability lint: every request call site takes a literal or
template-literal path, and a call site that does not fails the
assertion itself (the web-side analogue of contract 1's
raw-execution prong), never an exemption for the caller.
2. **Non-substitute witness:** the P1 surfaces (hierarchy, RBAC, kanban,
enrollment, roll-up, wizard UI) make zero calls to the §3.2 legacy
endpoints — asserted by the same inventory, scoped per surface.
3. **Envelope witnesses per family:** for each build-first family — a
request with an invalid DTO is refused with the validation code; a
version-mismatch mutation returns the conflict code; an unauthorized
read of an existing node and a read of a nonexistent node return
indistinguishable results where the no-existence-oracle rule applies;
a correlation id submitted on a mutation appears in its audit
event(s) and result. Two static companions: a type-level assertion
that the family's boundary accepts no `any`-typed or unvalidated
pass-through payload (§4.1), and a single-emitter assertion that the
mapped operation's audit events originate only from the owning
contract's audit emitter (§4.3's no-second-audit-stream, made
checkable).
4. **CLI-parity witness:** for each family, a CLI smoke invocation of at
least one command and one query against the Gateway succeeds with the
same typed result the web client receives.
5. **Fail-closed witness:** with the owning tool or grant state
unreachable (fault injection), the mapped operation returns the
internal-fault or authorization-refusal class and performs no
fallback read/write (extends contract 2 §7.6 to the mapping layer).
## Ruling request
Ratify sections 16 as written, with one decision embedded:
- Decision (§3.2): the legacy endpoints named there are **frozen for new
consumers** as of ratification — existing P0/host consumers keep
working, new UI or tool code may not call them, and each is retired by
the migration its owning contract schedules. Alternative if rejected:
allow P1 surfaces to reuse legacy endpoints as interim backends —
rejected by the audit's finding that they cannot satisfy the
hierarchy/kanban/RBAC contracts, so the interim would ship
non-conformant semantics.
-6
View File
@@ -11,9 +11,6 @@ import { registerQualityRails } from '@mosaicstack/quality-rails';
import { registerQueueCommand } from '@mosaicstack/queue';
import { registerStorageCommand } from '@mosaicstack/storage';
import { registerTelemetryCommand } from './commands/telemetry.js';
import { registerCommsCommand } from './commands/comms.js';
import { registerQCommand } from './commands/q.js';
import { registerWatchCommand } from './commands/watch.js';
import { registerAgentCommand } from './commands/agent.js';
import { registerInteractionCommand } from './commands/interaction.js';
import { registerConfigCommand } from './commands/config.js';
@@ -431,9 +428,6 @@ registerSkillCommand(program);
// ─── telemetry ───────────────────────────────────────────────────────────────
registerTelemetryCommand(program);
registerWatchCommand(program);
registerQCommand(program);
registerCommsCommand(program);
// ─── update ─────────────────────────────────────────────────────────────
@@ -1,67 +0,0 @@
import { spawnSync } from 'node:child_process';
import { accessSync, constants } from 'node:fs';
import { join } from 'node:path';
import { resolveBrainHome } from '../fleet/brain-home.js';
/**
* Shared brain-tool dispatch (fleet CLI integration, Jason ruling
* 2026-08-28): the npm package embeds the COMMAND SURFACE; operator-owned
* implementations stay in the brain (tools/). Commands resolve the brain
* home (MOSAIC_BRAIN_HOME wins — see brain-home.ts) and exec the tool
* there. Nothing operator-specific ships inside the package.
*
* Pass-through contract: arguments, stdout/stderr, and the exit code belong
* to the tool. The CLI adds nothing on success; absent tools fail loudly
* with the resolved path (127) instead of guessing.
*/
/** Absolute path of a brain-relative tool. */
export function resolveBrainTool(mosaicHome: string, relPath: string): string {
return join(resolveBrainHome(mosaicHome), ...relPath.split('/'));
}
/** Map a spawnSync result + tool existence to the CLI exit status. */
export function exitStatusFor(
result: { status: number | null; error?: NodeJS.ErrnoException },
toolExists: boolean,
): number {
if (!toolExists) return 127;
if (result.status !== null) return result.status;
return 125; // killed by signal / could not run
}
export function brainToolExists(tool: string): boolean {
try {
accessSync(tool, constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Exec a brain tool with full pass-through. `interpreter` runs the tool
* through e.g. python3 (renderers); omit it for executable scripts.
* Returns the process exit status; callers assign it to process.exitCode.
*/
export function execBrainTool(
mosaicHome: string,
relPath: string,
args: string[],
interpreter?: string,
): number {
const tool = resolveBrainTool(mosaicHome, relPath);
if (!brainToolExists(tool)) {
console.error(
`mosaic: brain tool not found (expected ${tool}). ` +
'Tool suites live in the brain tree under tools/; ' +
'check MOSAIC_BRAIN_HOME or the brain checkout.',
);
return 127;
}
const result = interpreter
? spawnSync(interpreter, [tool, ...args], { stdio: 'inherit', env: process.env })
: spawnSync(tool, args, { stdio: 'inherit', env: process.env });
return exitStatusFor(result, true);
}
-143
View File
@@ -1,143 +0,0 @@
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
writeFileSync,
readFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it } from 'vitest';
import { fleetCommsSendArgs, registerCommsCommand, tmuxSendArgs } from './comms.js';
describe('arg translation', () => {
it('tmux path: -s/-C/-L/-f/-m per agent-send.sh getopts', () => {
expect(tmuxSendArgs('orch-01', 'hello', {})).toEqual(['-s', 'orch-01', '-m', 'hello']);
expect(
tmuxSendArgs('orch-01', 'unused', {
class: 'actionable',
socket: 'mosaic-fleet',
file: '/tmp/body.txt',
}),
).toEqual(['-s', 'orch-01', '-C', 'actionable', '-L', 'mosaic-fleet', '-f', '/tmp/body.txt']);
});
it('fleet-comms path: -t site/agent and -c class', () => {
expect(fleetCommsSendArgs('usc', 'fred', 'hi', {})).toEqual(['-t', 'usc/fred', '-m', 'hi']);
expect(fleetCommsSendArgs('usc', 'fred', 'hi', { class: 'human' })).toEqual([
'-t',
'usc/fred',
'-c',
'human',
'-m',
'hi',
]);
});
});
describe('registerCommsCommand routing', () => {
const savedBrain = process.env['MOSAIC_BRAIN_HOME'];
const savedRepo = process.env['MOSAIC_FLEET_COMMS_REPO'];
const savedAgent = process.env['MOSAIC_AGENT_NAME'];
afterEach(() => {
for (const [k, v] of [
['MOSAIC_BRAIN_HOME', savedBrain],
['MOSAIC_FLEET_COMMS_REPO', savedRepo],
['MOSAIC_AGENT_NAME', savedAgent],
] as const) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
process.exitCode = undefined;
});
function fixture(): { brain: string; repo: string; tmuxLog: string; commsLog: string } {
const brain = mkdtempSync(join(tmpdir(), 'comms-brain-'));
const repo = mkdtempSync(join(tmpdir(), 'comms-repo-'));
mkdirSync(join(brain, 'tools', 'tmux'), { recursive: true });
mkdirSync(join(repo, 'tools'), { recursive: true });
const tmuxLog = join(brain, 'tmux.log');
const commsLog = join(repo, 'comms.log');
writeFileSync(
join(brain, 'tools', 'tmux', 'agent-send.sh'),
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(tmuxLog)}\nexit 7\n`,
);
writeFileSync(
join(repo, 'tools', 'comms-send.sh'),
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(commsLog)}\nprintf 'FLEET_COMMS_REPO=%s FLEET_COMMS_SITE=%s\\n' "$FLEET_COMMS_REPO" "$FLEET_COMMS_SITE" >> ${JSON.stringify(commsLog)}\nexit 5\n`,
);
chmodSync(join(brain, 'tools', 'tmux', 'agent-send.sh'), 0o755);
chmodSync(join(repo, 'tools', 'comms-send.sh'), 0o755);
process.env['MOSAIC_BRAIN_HOME'] = brain;
process.env['MOSAIC_FLEET_COMMS_REPO'] = repo;
process.env['MOSAIC_AGENT_NAME'] = 'tester';
return { brain, repo, tmuxLog, commsLog };
}
it('default routes same-host via agent-send with translated flags and passes rc through', async () => {
const f = fixture();
const program = new Command();
registerCommsCommand(program);
await program.parseAsync(
[
'comms',
'send',
'orch-01',
'--class',
'actionable',
'--socket',
'mosaic-fleet',
'verdict',
'landed',
],
{ from: 'user' },
);
expect(process.exitCode).toBe(7);
expect(readFileSync(f.tmuxLog, 'utf8').trim()).toBe(
'-s orch-01 -C actionable -L mosaic-fleet -m verdict landed',
);
expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked
});
it('--site routes inter-site via comms-send with site-prefixed target and passes rc through', async () => {
const f = fixture();
const program = new Command();
registerCommsCommand(program);
await program.parseAsync(
['comms', 'send', 'fred', '--site', 'usc', '--class', 'human', 'hello', 'there'],
{ from: 'user' },
);
expect(process.exitCode).toBe(5);
expect(readFileSync(f.commsLog, 'utf8').split('\n')[0]?.trim()).toBe(
'-t usc/fred -c human -m hello there',
);
// The sender must bind comms-send.sh to the SELECTED repo (codex 9c8b6ebf).
expect(readFileSync(f.commsLog, 'utf8')).toContain(
`FLEET_COMMS_REPO=${f.repo} FLEET_COMMS_SITE=usc`,
);
expect(existsSync(f.tmuxLog)).toBe(false); // same-host tool never invoked
});
it('inter-site without MOSAIC_AGENT_NAME is an invocation defect (exit 2)', async () => {
const f = fixture();
delete process.env['MOSAIC_AGENT_NAME'];
const program = new Command();
registerCommsCommand(program);
await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' });
expect(process.exitCode).toBe(2);
expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked
});
it('missing fleet-comms repo fails 127 naming the expected path', async () => {
fixture();
process.env['MOSAIC_FLEET_COMMS_REPO'] = '/nonexistent-comms-repo';
const program = new Command();
registerCommsCommand(program);
await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' });
expect(process.exitCode).toBe(127);
});
});
-135
View File
@@ -1,135 +0,0 @@
import type { Command } from 'commander';
import { spawnSync } from 'node:child_process';
import { accessSync, constants } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import { execBrainTool } from './brain-dispatch.js';
/**
* `mosaic comms send` — routed agent messaging (FLEET-COMMS.md doctrine).
*
* Same-host (default): brain tools/tmux/agent-send.sh. Inter-site
* (--site <site>): the fleet-comms repo's comms-send.sh — never for
* local traffic (a git round trip per message; Jason 2026-08-28).
*
* Exit codes pass through BOTH paths. rc=2 (text in pane, still draft) is
* a CONTRACT, not a failure: never retry, confirm with capture-pane.
*/
export interface CommsSendOptions {
readonly class?: string;
readonly file?: string;
readonly socket?: string;
readonly site?: string;
readonly commsRepo?: string;
}
export function defaultCommsRepo(): string {
return process.env['MOSAIC_FLEET_COMMS_REPO'] ?? join(homedir(), 'src', 'fleet-comms');
}
/** Build the agent-send.sh argv for the same-host path. */
export function tmuxSendArgs(target: string, message: string, opts: CommsSendOptions): string[] {
const args = ['-s', target];
if (opts.class) args.push('-C', opts.class);
if (opts.socket) args.push('-L', opts.socket);
if (opts.file) args.push('-f', opts.file);
else args.push('-m', message);
return args;
}
/** Build the comms-send.sh argv for the inter-site path. */
export function fleetCommsSendArgs(
site: string,
target: string,
message: string,
opts: CommsSendOptions,
): string[] {
const args = ['-t', `${site}/${target}`];
if (opts.class) args.push('-c', opts.class);
args.push('-m', message);
return args;
}
export function registerCommsCommand(program: Command): void {
const cmd: Command = program
.command('comms')
.description(
'Routed agent messaging: tmux same-host (default), fleet-comms inter-site (--site)',
)
.command('send')
.description('send <target> [message...] — same-host tmux unless --site is given')
.option('--class <class>', 'terminal-log | actionable | human | reaction | digest')
.option('--file <path>', 'message body from file (same-host path only)')
.option('--socket <name>', 'tmux socket for the same-host send (e.g. mosaic-fleet)')
.option('--site <site>', 'route via fleet-comms to <site>/<target>')
.option('--comms-repo <path>', 'fleet-comms checkout', defaultCommsRepo())
.argument('<target>', 'destination seat (session name)')
.argument('[message...]', 'message text (joined; or use --file)')
.action(
async (
target: string,
messageWords: string[],
opts: CommsSendOptions & Record<string, unknown>,
command: Command,
) => {
let mosaicHome: string | undefined;
for (let anc: Command | null = command; anc; anc = anc.parent) {
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
if (v !== undefined) {
mosaicHome = v;
break;
}
}
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
const message = messageWords.join(' ');
if (opts.site) {
const repo = opts.commsRepo ?? defaultCommsRepo();
const tool = join(repo, 'tools', 'comms-send.sh');
try {
accessSync(tool, constants.X_OK);
} catch {
console.error(
`mosaic comms: fleet-comms sender not found (expected ${tool}). ` +
'Clone the fleet-comms repo or point --comms-repo at it.',
);
process.exitCode = 127;
return;
}
if (!process.env['MOSAIC_AGENT_NAME']) {
console.error(
'mosaic comms: inter-site sends require MOSAIC_AGENT_NAME (sending identity).',
);
process.exitCode = 2; // invocation defect: fixable by the caller
return;
}
// comms-send.sh locates its working repo via FLEET_COMMS_REPO
// (default $HOME/src/fleet-comms); without this, --comms-repo
// would select the executable but not the repository it operates
// on (codex review of 9c8b6ebf).
const env = { ...process.env, FLEET_COMMS_SITE: opts.site, FLEET_COMMS_REPO: repo };
const result = spawnSync(tool, fleetCommsSendArgs(opts.site, target, message, opts), {
stdio: 'inherit',
env,
});
process.exitCode = result.status ?? 125;
return;
}
// Same-host: the brain tool owns validation (bad class -> its rc 3)
// and absence (execBrainTool -> 127 with the resolved path).
process.exitCode = execBrainTool(
home,
'tools/tmux/agent-send.sh',
tmuxSendArgs(target, message, opts),
);
},
);
cmd.addHelpText(
'after',
'\nExit codes pass through. rc=2 means the text reached the pane but is still a draft: NEVER retry (double-send); confirm with tmux capture-pane.',
);
}
-91
View File
@@ -1,91 +0,0 @@
import { mkdirSync, mkdtempSync, writeFileSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it } from 'vitest';
import { registerQCommand, resolveQuestionTool } from './q.js';
import { exitStatusFor, resolveBrainTool } from './brain-dispatch.js';
describe('resolveBrainTool', () => {
const saved = process.env['MOSAIC_BRAIN_HOME'];
afterEach(() => {
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
else process.env['MOSAIC_BRAIN_HOME'] = saved;
});
it('joins brain home with the relative tool path', () => {
const tmp = mkdtempSync(join(tmpdir(), 'dispatch-resolve-'));
process.env['MOSAIC_BRAIN_HOME'] = tmp;
expect(resolveBrainTool('/nonexistent/mosaic-home', 'tools/questions/q-new.sh')).toBe(
join(tmp, 'tools', 'questions', 'q-new.sh'),
);
});
});
describe('resolveQuestionTool', () => {
const saved = process.env['MOSAIC_BRAIN_HOME'];
afterEach(() => {
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
else process.env['MOSAIC_BRAIN_HOME'] = saved;
});
it('maps new/render subcommands to their brain tools', () => {
const tmp = mkdtempSync(join(tmpdir(), 'q-resolve-'));
process.env['MOSAIC_BRAIN_HOME'] = tmp;
expect(resolveQuestionTool(tmp, 'new')).toBe(join(tmp, 'tools', 'questions', 'q-new.sh'));
expect(resolveQuestionTool(tmp, 'render')).toBe(join(tmp, 'tools', 'questions', 'render.py'));
expect(resolveQuestionTool(tmp, 'bogus')).toBeUndefined();
});
});
describe('registerQCommand usage + dispatch', () => {
const saved = process.env['MOSAIC_BRAIN_HOME'];
afterEach(() => {
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
else process.env['MOSAIC_BRAIN_HOME'] = saved;
process.exitCode = undefined;
});
it('exit 2 with the subcommand list when no/unknown subcommand', async () => {
process.env['MOSAIC_BRAIN_HOME'] = mkdtempSync(join(tmpdir(), 'q-usage-'));
const program = new Command();
registerQCommand(program);
await program.parseAsync(['q'], { from: 'user' });
expect(process.exitCode).toBe(2);
process.exitCode = undefined;
await program.parseAsync(['q', 'bogus'], { from: 'user' });
expect(process.exitCode).toBe(2);
process.exitCode = undefined;
// Reserved property names must not leak through the record lookup.
await program.parseAsync(['q', 'toString'], { from: 'user' });
expect(process.exitCode).toBe(2);
});
it('execs the brain tool with pass-through args and exit code', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'q-live-'));
process.env['MOSAIC_BRAIN_HOME'] = tmp;
mkdirSync(join(tmp, 'tools', 'questions'), { recursive: true });
const stub = join(tmp, 'tools', 'questions', 'q-new.sh');
writeFileSync(stub, '#!/usr/bin/env bash\necho "called with: $*"\nexit 7\n');
chmodSync(stub, 0o755);
const program = new Command();
registerQCommand(program);
await program.parseAsync(['q', 'new', '--slug', 'x', '--question', 'why'], { from: 'user' });
expect(process.exitCode).toBe(7);
});
});
describe('exitStatusFor (shared dispatch contract)', () => {
it('maps absent tool to 127', () => {
expect(exitStatusFor({ status: 0 }, false)).toBe(127);
});
it('passes tool status through', () => {
expect(exitStatusFor({ status: 7 }, true)).toBe(7);
});
it('maps signal death to 125', () => {
expect(exitStatusFor({ status: null }, true)).toBe(125);
});
});
-63
View File
@@ -1,63 +0,0 @@
import type { Command } from 'commander';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import { execBrainTool, resolveBrainTool } from './brain-dispatch.js';
/**
* `mosaic q` — tracked decision questions (brain tools/questions).
* `new` files a question file (one FILE per question, merge-conflict
* impossible by construction); `render` regenerates the
* docs/OPEN-QUESTIONS.md index (id allocation happens in the renderer).
*/
const SUBCOMMANDS: Record<string, { path: string; interpreter?: string; help: string }> = {
new: {
path: 'tools/questions/q-new.sh',
help: 'file a question (--question, --slug, --owed-by, ...)',
},
render: {
path: 'tools/questions/render.py',
interpreter: 'python3',
help: 'regenerate docs/OPEN-QUESTIONS.md (owns Q-id allocation)',
},
};
export function resolveQuestionTool(mosaicHome: string, sub: string): string | undefined {
const entry = SUBCOMMANDS[sub];
return entry ? resolveBrainTool(mosaicHome, entry.path) : undefined;
}
export function registerQCommand(program: Command): void {
const cmd: Command = program
.command('q')
.description('Tracked decision questions: file and render (brain tools/questions)')
.allowUnknownOption()
.argument('[args...]', 'subcommand + args passed through to the question tools')
.action(async (args: string[], _opts: unknown, command: Command) => {
let mosaicHome: string | undefined;
for (let anc: Command | null = command; anc; anc = anc.parent) {
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
if (v !== undefined) {
mosaicHome = v;
break;
}
}
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
const sub = args[0];
if (!sub || !Object.hasOwn(SUBCOMMANDS, sub)) {
console.error('mosaic q: expected a subcommand:');
for (const [name, entry] of Object.entries(SUBCOMMANDS)) {
console.error(` mosaic q ${name} ${entry.help}`);
}
process.exitCode = 2; // usage error contract: invocation defect
return;
}
const entry = SUBCOMMANDS[sub]!;
process.exitCode = execBrainTool(home, entry.path, args.slice(1), entry.interpreter);
});
cmd.addHelpText(
'after',
'\nEverything after the subcommand is passed through verbatim (args, output, exit code).',
);
}
@@ -1,72 +0,0 @@
import { mkdirSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it } from 'vitest';
import { exitStatusFor, registerWatchCommand, resolveAgentWatchTool } from './watch.js';
// The dispatch command execs a real process with inherited stdio; the spec
// covers the pure resolution and exit-mapping surfaces plus the absent-tool
// path (which exits without spawning). Live pass-through is exercised by the
// fleet smoke test against the real brain tool.
describe('resolveAgentWatchTool', () => {
const saved = process.env['MOSAIC_BRAIN_HOME'];
afterEach(() => {
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
else process.env['MOSAIC_BRAIN_HOME'] = saved;
});
it('honors MOSAIC_BRAIN_HOME over the canonical brain', () => {
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
process.env['MOSAIC_BRAIN_HOME'] = tmp;
expect(resolveAgentWatchTool('/nonexistent/mosaic-home')).toBe(
join(tmp, 'tools', 'agent-watch', 'agent-watch.sh'),
);
});
it('resolves inside the brain tools tree', () => {
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
process.env['MOSAIC_BRAIN_HOME'] = tmp;
const tool = resolveAgentWatchTool(tmp);
expect(tool.endsWith(join('tools', 'agent-watch', 'agent-watch.sh'))).toBe(true);
});
});
describe('exitStatusFor', () => {
it('maps absent tool to 127', () => {
expect(exitStatusFor({ status: 0 }, false)).toBe(127);
});
it('passes the tool exit status through', () => {
expect(exitStatusFor({ status: 2 }, true)).toBe(2);
expect(exitStatusFor({ status: 78 }, true)).toBe(78);
});
it('maps signal death / null status to 125', () => {
expect(exitStatusFor({ status: null }, true)).toBe(125);
});
});
describe('registerWatchCommand absent-tool path', () => {
const saved = process.env['MOSAIC_BRAIN_HOME'];
afterEach(() => {
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
else process.env['MOSAIC_BRAIN_HOME'] = saved;
process.exitCode = undefined;
});
it('sets exitCode 127 with the resolved path when the tool is missing', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'watch-missing-'));
// The suite directory exists but the tool file does not.
mkdirSync(join(tmp, 'tools', 'agent-watch'), { recursive: true });
process.env['MOSAIC_BRAIN_HOME'] = tmp;
const program = new Command();
registerWatchCommand(program);
await program.parseAsync(['watch', 'list'], { from: 'user' });
expect(process.exitCode).toBe(127);
});
});
-68
View File
@@ -1,68 +0,0 @@
import type { Command } from 'commander';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import {
brainToolExists,
execBrainTool,
exitStatusFor,
resolveBrainTool,
} from './brain-dispatch.js';
export { exitStatusFor };
/**
* `mosaic watch` — dispatch to the brain's agent-watch suite.
* See brain-dispatch.ts for the architecture and pass-through contract.
*/
export function resolveAgentWatchTool(mosaicHome: string): string {
return resolveBrainTool(mosaicHome, 'tools/agent-watch/agent-watch.sh');
}
export function registerWatchCommand(program: Command): void {
const cmd: Command = program
.command('watch')
.description('Wake-me-when watchers (agent-watch): start, list, stop')
// allowUnknownOption + variadic = full ordered pass-through: unknown
// options (--name, --when, ...) and their values land in args verbatim
// (commander 13 measured behavior), so the tool owns its own flag
// surface without the CLI needing passThroughOptions (which would
// force enablePositionalOptions fleet-wide on the root program).
.allowUnknownOption()
// The tool owns help too: without this, commander would intercept
// --help and answer with wrapper help instead of agent-watch's own
// (codex review of 18f3dd49).
.helpOption(false)
.argument('[args...]', 'args passed through to agent-watch.sh')
.action(async (args: string[], _opts: unknown, command: Command) => {
// --mosaic-home is not global in this CLI; walk parents for it and
// fall back to the default. MOSAIC_BRAIN_HOME (seat launchers export
// it) wins inside resolveBrainHome regardless.
let mosaicHome: string | undefined;
for (let anc: Command | null = command; anc; anc = anc.parent) {
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
if (v !== undefined) {
mosaicHome = v;
break;
}
}
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
const tool = resolveAgentWatchTool(home);
if (!brainToolExists(tool)) {
console.error(
`mosaic watch: agent-watch not found (expected ${tool}). ` +
'The watcher suite lives in the brain tree under tools/agent-watch/; ' +
'check MOSAIC_BRAIN_HOME or the brain checkout.',
);
process.exitCode = 127;
return;
}
process.exitCode = execBrainTool(home, 'tools/agent-watch/agent-watch.sh', args);
});
cmd.addHelpText(
'after',
'\nEverything after `mosaic watch` is passed through to agent-watch.sh verbatim (args, output, exit code).',
);
}
@@ -0,0 +1,213 @@
/**
* Read-only adapters for the installation config pipeline (§16).
*
* Every adapter is a bounded, side-effect-free read. The registry resolver
* is DEPENDENCY-BLOCKED (§2.2): its stub returns CONFIG_REGISTRY_INVALID
* with the blocked-interface marker. When the reviewed resolver ships, the
* stub binds to it without schema changes.
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import { execSync } from 'node:child_process';
import * as path from 'node:path';
import {
type ConfigDiagnostic,
type RegistryProvenance,
type InstallationBindings,
type FrameworkBindingDefaults,
FRAMEWORK_BINDING_DEFAULTS,
REGISTRY_RESOLVER_BLOCKED,
} from './types.js';
// ─── Digest helpers ───────────────────────────────────────────────────────────
/** SHA-256 over canonical JSON with recursively sorted keys (§9). */
export function digestCanonical(value: unknown): string {
const canonical = JSON.stringify(sortKeysDeep(value));
return crypto.createHash('sha256').update(canonical).digest('hex');
}
function sortKeysDeep(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map(sortKeysDeep);
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
}
return sorted;
}
export function digestBytes(content: string | Buffer): string {
return crypto.createHash('sha256').update(content).digest('hex');
}
// ─── 1. Registry resolver adapter (§16, dependency-blocked) ──────────────────
export interface RegistryAdapter {
resolve(): { provenance: RegistryProvenance; diagnostics: ConfigDiagnostic[] };
}
/**
* DEPENDENCY GATE (§2.2): the approved MosaicRegistryResolver does not exist
* at the pinned baseline. This stub returns the blocked marker. When the
* reviewed resolver ships (CFG-REQ-001..006), replace this stub's resolve()
* to delegate to it. The interface is stable.
*/
export class BlockedRegistryAdapter implements RegistryAdapter {
resolve(): { provenance: RegistryProvenance; diagnostics: ConfigDiagnostic[] } {
return {
provenance: {
resolved: false,
brainHome: null,
sourceKeys: [],
},
diagnostics: [
{
code: 'CONFIG_REGISTRY_INVALID',
message: REGISTRY_RESOLVER_BLOCKED,
retryable: false,
},
],
};
}
}
// ─── 2. Bounded file reader (§14.1, §14.2) ───────────────────────────────────
export interface BoundedReadResult {
ok: boolean;
content?: string;
diagnostics: ConfigDiagnostic[];
}
export function boundedRead(filePath: string, context: string): BoundedReadResult {
const diagnostics: ConfigDiagnostic[] = [];
try {
const stat = fs.lstatSync(filePath);
if (stat.isSymbolicLink()) {
diagnostics.push({
code: 'CONFIG_ADAPTER_UNAVAILABLE',
message: `${context}: symlink input rejected`,
retryable: false,
});
return { ok: false, diagnostics };
}
if (!stat.isFile()) {
diagnostics.push({
code: 'CONFIG_ADAPTER_UNAVAILABLE',
message: `${context}: not a regular file`,
retryable: false,
});
return { ok: false, diagnostics };
}
if (stat.size > 1024 * 1024) {
diagnostics.push({
code: 'CONFIG_ADAPTER_UNAVAILABLE',
message: `${context}: file exceeds 1 MiB limit`,
retryable: false,
});
return { ok: false, diagnostics };
}
const content = fs.readFileSync(filePath, 'utf-8');
return { ok: true, content, diagnostics: [] };
} catch (e) {
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_MISSING',
message: `${context}: file not found at ${filePath}`,
retryable: false,
});
return { ok: false, diagnostics };
}
diagnostics.push({
code: 'CONFIG_ADAPTER_UNAVAILABLE',
message: `${context}: read error: ${e instanceof Error ? e.message : String(e)}`,
retryable: false,
});
return { ok: false, diagnostics };
}
}
// ─── 3. Bindings resolution (§5.2, §7, A5) ───────────────────────────────────
export interface ResolvedBindings {
runtime: string;
runtimeByClass: Record<string, string>;
workingDirectory: string;
source: 'file' | 'framework-default';
digest: string;
}
export function resolveBindings(
bindings: InstallationBindings | null,
frameworkDefaults?: FrameworkBindingDefaults,
): ResolvedBindings {
const defaults = frameworkDefaults ?? FRAMEWORK_BINDING_DEFAULTS;
if (!bindings) {
return {
runtime: defaults.runtime,
runtimeByClass: {},
workingDirectory: defaults.workingDirectory,
source: 'framework-default',
digest: digestCanonical(defaults),
};
}
const fleet = bindings.spec.fleet;
return {
runtime: fleet.runtime?.default ?? defaults.runtime,
runtimeByClass: fleet.runtime?.byClass ?? {},
workingDirectory: fleet.workingDirectory ?? defaults.workingDirectory,
source: 'file',
digest: digestCanonical(bindings),
};
}
// ─── 4. Git tracking/ignore probe (§7.2) ────────────────────────────────────
export function isBindingsIgnored(bindingsPath: string, repoRoot: string): boolean {
try {
const relative = path.relative(repoRoot, bindingsPath);
if (relative.startsWith('..')) return true; // outside repo = not tracked
// Check if the file is tracked
try {
execSync(`git ls-files --error-unmatch "${relative}"`, {
cwd: repoRoot,
stdio: 'pipe',
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
});
return false; // tracked = NOT ignored
} catch {
// Not tracked; check if ignored
try {
execSync(`git check-ignore "${relative}"`, {
cwd: repoRoot,
stdio: 'pipe',
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
});
return true; // check-ignore succeeded = ignored
} catch {
return false; // not tracked but not ignored either = fail
}
}
} catch {
return true; // no git evidence = treat as ignored (valid absence)
}
}
// ─── 5. Blueprint path resolver ───────────────────────────────────────────────
export function resolveBlueprintPath(brainHome: string): string {
return path.join(brainHome, 'fleet', 'configuration', 'installation.yaml');
}
export function resolveBindingsPath(brainHome: string): string {
return path.join(brainHome, 'config', 'installation.local.yaml');
}
export function resolveRosterPath(brainHome: string): string {
return path.join(brainHome, 'fleet', 'roster.yaml');
}
@@ -0,0 +1,483 @@
/**
* InstallationConfigCore — shared pure pipeline for validate and plan (§9).
*
* Both commands call this core in the same order. No mutation, no network,
* no subprocess. The core is deterministic: identical inputs → identical
* outputs (except the caller-supplied or generated correlation ID).
*/
import * as crypto from 'node:crypto';
import {
type InstallationBlueprint,
type InstallationBindings,
type ConfigDiagnostic,
type ConfigValidationDataV1,
type ConfigPlanDataV1,
type ConfigPlanActionV1,
type ConfigPlanFieldDiff,
EXIT_OK,
EXIT_INVALID,
EXIT_NONCONFORMANT,
EXIT_UNAVAILABLE,
BOOTSTRAP_MINIMAL_V1,
} from './types.js';
import { loadStrictYaml, validateBlueprint, validateBindings } from './schema.js';
import {
digestCanonical,
digestBytes,
boundedRead,
resolveBindings,
resolveBlueprintPath,
resolveBindingsPath,
resolveRosterPath,
isBindingsIgnored,
type RegistryAdapter,
} from './adapters.js';
// ─── Pipeline input/output ───────────────────────────────────────────────────
export interface CoreInput {
registryAdapter: RegistryAdapter;
/** Explicit blueprint file path, or null to use --preset. */
filePath: string | null;
/** Preset ID, or null to use --file. */
presetId: string | null;
/** Resolved brainHome (from registry). */
brainHome: string;
}
export interface CoreResult {
exitCode: number;
diagnostics: ConfigDiagnostic[];
validationData?: ConfigValidationDataV1;
planData?: ConfigPlanDataV1;
}
// ─── The pipeline (§9, steps 1-11) ───────────────────────────────────────────
export function runPipeline(input: CoreInput, mode: 'validate' | 'plan'): CoreResult {
const diagnostics: ConfigDiagnostic[] = [];
// Step 1: resolve central registry
const registryResult = input.registryAdapter.resolve();
diagnostics.push(...registryResult.diagnostics);
if (diagnostics.some((d) => d.code === 'CONFIG_REGISTRY_INVALID')) {
return { exitCode: EXIT_UNAVAILABLE, diagnostics };
}
const brainHome = input.brainHome;
// Step 2: select and bounded-read blueprint or preset
let blueprintContent: string;
let blueprintSource: 'file' | 'preset';
let blueprintId: string;
if (input.presetId) {
if (input.presetId !== BOOTSTRAP_MINIMAL_V1.id) {
diagnostics.push({
code: 'CONFIG_PRESET_UNKNOWN' as const,
message: `Unknown preset '${input.presetId}'. Available: ${BOOTSTRAP_MINIMAL_V1.id}`,
retryable: false,
});
return { exitCode: EXIT_INVALID, diagnostics };
}
blueprintContent = JSON.stringify(BOOTSTRAP_MINIMAL_V1.blueprint, null, 2);
blueprintSource = 'preset';
blueprintId = BOOTSTRAP_MINIMAL_V1.id;
} else {
const bpPath = input.filePath ?? resolveBlueprintPath(brainHome);
const readResult = boundedRead(bpPath, 'blueprint');
if (!readResult.ok) {
diagnostics.push(...readResult.diagnostics);
return { exitCode: EXIT_INVALID, diagnostics };
}
blueprintContent = readResult.content!;
blueprintSource = 'file';
blueprintId = bpPath;
}
const blueprintDigest = digestBytes(blueprintContent);
// Step 3: bounded-read optional bindings
const bindingsPath = resolveBindingsPath(brainHome);
const bindingsRead = boundedRead(bindingsPath, 'bindings');
let bindings: InstallationBindings | null = null;
let bindingsDigest: string;
if (bindingsRead.ok && bindingsRead.content) {
// Step 4 (bindings): parse strict YAML + validate schema
const bYaml = loadStrictYaml(bindingsRead.content, 'bindings');
if (!bYaml.ok) {
diagnostics.push(...bYaml.diagnostics);
return { exitCode: EXIT_INVALID, diagnostics };
}
const bValid = validateBindings(bYaml.value, 'bindings');
if (!bValid.ok) {
diagnostics.push(...bValid.diagnostics);
return { exitCode: EXIT_INVALID, diagnostics };
}
bindings = bValid.bindings!;
// §7.2: bindings must be ignored/untracked
if (!isBindingsIgnored(bindingsPath, brainHome)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_NOT_IGNORED',
message: `Host bindings at ${bindingsPath} are tracked or not ignored`,
path: bindingsPath,
retryable: false,
});
return { exitCode: EXIT_INVALID, diagnostics };
}
bindingsDigest = digestCanonical(bindings);
} else if (bindingsRead.diagnostics.some((d) => d.code === 'CONFIG_BLUEPRINT_MISSING')) {
// Absent bindings = valid (§7.1)
bindingsDigest = digestCanonical(null);
} else {
diagnostics.push(...bindingsRead.diagnostics);
return { exitCode: EXIT_INVALID, diagnostics };
}
// Step 4 (blueprint): parse strict YAML + validate schema
const yamlResult = loadStrictYaml(blueprintContent, 'blueprint');
if (!yamlResult.ok) {
diagnostics.push(...yamlResult.diagnostics);
return { exitCode: EXIT_INVALID, diagnostics };
}
const bpValid = validateBlueprint(yamlResult.value, 'blueprint');
if (!bpValid.ok) {
diagnostics.push(...bpValid.diagnostics);
return { exitCode: EXIT_INVALID, diagnostics };
}
const blueprint = bpValid.blueprint!;
// Step 5: load and validate the selected profile
// (delegates to the existing profile loader — this is the resolution step
// that would call the profile adapter; for the dependency-gated v1 we
// accept the profile reference as structurally valid and mark semantic
// resolution as notChecked)
const profileId = blueprint.spec.fleet.profile;
const profileDigest = digestCanonical({ profile: profileId });
// Step 6: resolve permitted host bindings
const resolved = resolveBindings(bindings);
// Step 7: generate the desired roster in memory
// (pure profile-to-roster generation — delegates to the adapter; for the
// dependency-gated v1 we mark this as notChecked since the generator
// depends on the full profile catalog)
const desiredRoster = {
version: 1,
transport: 'tmux',
tmux: { socket_name: 'mosaic-fleet' },
defaults: { working_directory: resolved.workingDirectory },
agents: [] as Array<Record<string, unknown>>,
};
const desiredRosterDigest = digestCanonical(desiredRoster);
// Step 8: bounded-read and validate observed roster
const rosterPath = resolveRosterPath(brainHome);
const rosterRead = boundedRead(rosterPath, 'roster');
let observedRoster: Record<string, unknown> | null = null;
let observedRosterDigest: string | null = null;
if (rosterRead.ok && rosterRead.content) {
const rYaml = loadStrictYaml(rosterRead.content, 'roster');
if (!rYaml.ok) {
diagnostics.push(...rYaml.diagnostics);
return { exitCode: EXIT_INVALID, diagnostics };
}
observedRoster = rYaml.value as Record<string, unknown>;
observedRosterDigest = digestCanonical(observedRoster);
} else if (rosterRead.diagnostics.some((d) => d.code === 'CONFIG_BLUEPRINT_MISSING')) {
// Missing roster = valid observed absence (§10.1)
observedRoster = null;
observedRosterDigest = null;
} else {
diagnostics.push(...rosterRead.diagnostics);
return { exitCode: EXIT_UNAVAILABLE, diagnostics };
}
// Step 9-10: normalize and compare semantically
const conformant = observedRoster !== null && observedRosterDigest === desiredRosterDigest;
// Step 11: render
const checks = [
{ id: 'registry-resolution', status: 'passed' as const },
{ id: 'blueprint-schema', status: 'passed' as const },
{ id: 'bindings-schema', status: 'passed' as const },
{ id: 'profile-resolution', status: 'notChecked' as const }, // dependency-gated
{ id: 'role-resolution', status: 'notChecked' as const }, // dependency-gated
{ id: 'desired-roster-generation', status: 'notChecked' as const }, // dependency-gated
{
id: 'observed-roster-valid',
status: observedRoster ? ('passed' as const) : ('notChecked' as const),
},
{ id: 'conformance', status: conformant ? ('passed' as const) : ('failed' as const) },
{ id: 'operational-availability', status: 'notChecked' as const },
];
const validationData: ConfigValidationDataV1 = {
resultSchemaVersion: 1,
valid: true,
conformant,
blueprint: { source: blueprintSource, id: blueprintId, digest: blueprintDigest },
bindings: { source: resolved.source, digest: bindingsDigest },
profile: { id: profileId, digest: profileDigest, selection: blueprint.spec.fleet.selection },
observed: { roster: observedRoster ? 'present' : 'absent', digest: observedRosterDigest },
checks,
};
if (!conformant) {
diagnostics.push({
code: 'CONFIG_NONCONFORMANT',
message: observedRoster
? 'Observed roster differs from desired state within the v1 ownership mask'
: 'Observed roster is absent; desired state requires one',
retryable: false,
});
if (observedRoster === null) {
diagnostics.push({
code: 'CONFIG_ROSTER_MISSING' as const,
message: 'Observed roster absent at expected path',
retryable: false,
});
}
}
if (mode === 'validate') {
return {
exitCode: conformant ? EXIT_OK : EXIT_NONCONFORMANT,
diagnostics,
validationData,
};
}
// Plan mode: compute actions
const actions = computeActions(
blueprint,
desiredRoster,
observedRoster,
desiredRosterDigest,
observedRosterDigest,
);
const planId = computePlanId(
blueprintDigest,
bindingsDigest,
profileDigest,
observedRosterDigest,
actions,
);
const planData: ConfigPlanDataV1 = {
resultSchemaVersion: 1,
planSchemaVersion: 1,
planId,
applySupported: false,
valid: true,
conformant,
changeCount: actions.filter((a) => a.operation !== 'blocked').length,
blockedCount: actions.filter((a) => a.operation === 'blocked').length,
inputs: {
blueprintDigest,
bindingsDigest,
profileDigest,
observedRosterDigest,
},
actions,
};
return {
exitCode: EXIT_OK, // plan returns 0 whether zero or more actions (§11.1)
diagnostics,
validationData,
planData,
};
}
// ─── Action computation (§11.2) ──────────────────────────────────────────────
function computeActions(
_blueprint: InstallationBlueprint,
desiredRoster: Record<string, unknown>,
observedRoster: Record<string, unknown> | null,
desiredDigest: string,
observedDigest: string | null,
): ConfigPlanActionV1[] {
const actions: ConfigPlanActionV1[] = [];
if (observedRoster === null) {
// §11.2 rule 4: missing roster = one roster create + seat creates
actions.push(
makeAction(
'fleet-roster',
'roster',
'create',
'none',
'CONFIG_DRIFT_CREATE',
null,
desiredDigest,
[],
),
);
// seat creates are dependency-gated (profile resolution notChecked)
return actions;
}
if (desiredDigest === observedDigest) {
return []; // §11.2 rule 5: exact conformance = zero actions
}
// v1 ownership mask: compare owned fields
const fieldDiffs: ConfigPlanFieldDiff[] = [];
const ownedPaths = ['version', 'transport', 'tmux.socket_name', 'defaults.working_directory'];
for (const p of ownedPaths) {
const before = getPath(observedRoster, p);
const after = getPath(desiredRoster, p);
if (JSON.stringify(before) !== JSON.stringify(after)) {
fieldDiffs.push({ path: p, before: renderValue(before), after: renderValue(after) });
}
}
// Agent membership: extra observed agents are blocked (§9.1)
const observedAgents = Array.isArray(observedRoster.agents)
? (observedRoster.agents as Array<Record<string, unknown>>)
: [];
const desiredAgents = Array.isArray(desiredRoster.agents)
? (desiredRoster.agents as Array<Record<string, unknown>>)
: [];
const desiredNames = new Set(desiredAgents.map((a) => String(a.name ?? '')));
for (const agent of observedAgents) {
const name = String(agent.name ?? '');
if (!desiredNames.has(name)) {
actions.push(
makeAction(
'fleet-seat',
name,
'blocked',
'full-engine-required',
'CONFIG_DRIFT_FULL_ENGINE_REQUIRED',
digestCanonical(agent),
null,
[],
['full-engine: seat removal'],
),
);
}
}
if (fieldDiffs.length > 0) {
actions.push(
makeAction(
'fleet-roster',
'roster',
'update',
'none',
'CONFIG_DRIFT_UPDATE',
observedDigest,
desiredDigest,
fieldDiffs,
),
);
}
// Sort (§11.2 rule 6)
actions.sort((a, b) => {
if (a.resourceKind !== b.resourceKind) return a.resourceKind < b.resourceKind ? -1 : 1;
if (a.resourceId !== b.resourceId) return a.resourceId < b.resourceId ? -1 : 1;
if (a.operation !== b.operation) return a.operation < b.operation ? -1 : 1;
return 0;
});
return actions;
}
function makeAction(
resourceKind: 'fleet-roster' | 'fleet-seat',
resourceId: string,
operation: 'create' | 'update' | 'blocked',
risk: 'none' | 'review-required' | 'full-engine-required',
reasonCode: string,
beforeDigest: string | null,
afterDigest: string | null,
fieldDiffs: ConfigPlanFieldDiff[],
blockedBy: string[] = [],
): ConfigPlanActionV1 {
const payload = {
resourceKind,
resourceId,
operation,
risk,
reasonCode,
beforeDigest,
afterDigest,
fieldDiffs,
};
const id = crypto
.createHash('sha256')
.update(JSON.stringify(sortKeys(payload)))
.digest('hex')
.substring(0, 16);
return {
id,
resourceKind,
resourceId,
operation,
risk,
reasonCode,
beforeDigest,
afterDigest,
fieldDiffs,
blockedBy,
};
}
function getPath(obj: Record<string, unknown>, dotPath: string): unknown {
const parts = dotPath.split('.');
let current: unknown = obj;
for (const part of parts) {
if (current === null || typeof current !== 'object') return null;
current = (current as Record<string, unknown>)[part] ?? null;
}
return current;
}
function renderValue(v: unknown): string | number | boolean | null {
if (v === null || v === undefined) return null;
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v;
return JSON.stringify(v);
}
function sortKeys(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map(sortKeys);
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
sorted[key] = sortKeys((value as Record<string, unknown>)[key]);
}
return sorted;
}
function computePlanId(
blueprintDigest: string,
bindingsDigest: string,
profileDigest: string,
observedRosterDigest: string | null,
actions: ConfigPlanActionV1[],
): string {
const parts = {
planSchemaVersion: 1,
blueprintDigest,
bindingsDigest,
profileDigest,
observedRosterDigest: observedRosterDigest ?? 'absent',
actions: actions.map((a) => a.id),
};
return crypto
.createHash('sha256')
.update(JSON.stringify(sortKeys(parts)))
.digest('hex');
}
@@ -0,0 +1,277 @@
/**
* Installation config minimal-subset tests.
*
* Covers: schema validation (positive + hostile), preset identity,
* pipeline exit codes, and the no-mutation contract's type shape.
* The dependency-gated adapters are tested through their stubs.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { BOOTSTRAP_MINIMAL_V1, BLUEPRINT_API_VERSION, EXIT_UNAVAILABLE } from './types.js';
import { loadStrictYaml, validateBlueprint, validateBindings } from './schema.js';
import { runPipeline } from './core.js';
import { BlockedRegistryAdapter, digestCanonical } from './adapters.js';
import { renderJsonValidate, renderTableValidate } from './render.js';
const SB = fs.mkdtempSync(path.join(os.tmpdir(), 'configimpl-test-'));
describe('schema: strict YAML loader', () => {
it('accepts a valid single-document mapping', () => {
const result = loadStrictYaml('a: 1\nb: two', 'test');
expect(result.ok).toBe(true);
});
it('rejects multi-document YAML', () => {
const result = loadStrictYaml('a: 1\n---\nb: 2', 'test');
expect(result.ok).toBe(false);
expect(result.diagnostics[0]?.code).toBe('CONFIG_BLUEPRINT_SCHEMA');
});
it('rejects null/empty input', () => {
const result = loadStrictYaml('', 'test');
expect(result.ok).toBe(false);
});
it('rejects non-mapping top level', () => {
const result = loadStrictYaml('- just\n- a\n- list', 'test');
expect(result.ok).toBe(false);
});
});
describe('schema: blueprint validation', () => {
const validBlueprint = {
apiVersion: BLUEPRINT_API_VERSION,
kind: 'InstallationBlueprint',
metadata: { name: 'test', generation: 1 },
spec: { fleet: { profile: 'software-delivery', selection: 'floor' } },
};
it('accepts the valid canonical shape', () => {
const r = validateBlueprint(validBlueprint, 'test');
expect(r.ok).toBe(true);
expect(r.blueprint?.metadata.name).toBe('test');
});
it('rejects unknown top-level key', () => {
const r = validateBlueprint({ ...validBlueprint, extra: true }, 'test');
expect(r.ok).toBe(false);
expect(r.diagnostics.some((d) => d.message.includes("unknown top-level key 'extra'"))).toBe(
true,
);
});
it('rejects wrong apiVersion', () => {
const r = validateBlueprint({ ...validBlueprint, apiVersion: 'wrong' }, 'test');
expect(r.ok).toBe(false);
});
it('rejects wrong kind', () => {
const r = validateBlueprint({ ...validBlueprint, kind: 'Wrong' }, 'test');
expect(r.ok).toBe(false);
});
it('rejects invalid name (uppercase)', () => {
const r = validateBlueprint(
{ ...validBlueprint, metadata: { ...validBlueprint.metadata, name: 'Bad' } },
'test',
);
expect(r.ok).toBe(false);
});
it('rejects generation < 1', () => {
const r = validateBlueprint(
{ ...validBlueprint, metadata: { ...validBlueprint.metadata, generation: 0 } },
'test',
);
expect(r.ok).toBe(false);
});
it('rejects invalid selection', () => {
const r = validateBlueprint(
{
...validBlueprint,
spec: { fleet: { profile: 'test', selection: 'partial' } },
},
'test',
);
expect(r.ok).toBe(false);
});
it('rejects unknown spec key', () => {
const r = validateBlueprint(
{
...validBlueprint,
spec: { fleet: validBlueprint.spec.fleet, extra: 1 },
},
'test',
);
expect(r.ok).toBe(false);
});
});
describe('schema: bindings validation', () => {
const validBindings = {
apiVersion: BLUEPRINT_API_VERSION,
kind: 'InstallationBindings',
spec: {
fleet: {
runtime: { default: 'pi' },
workingDirectory: '~/src',
},
},
};
it('accepts the valid canonical shape', () => {
const r = validateBindings(validBindings, 'test');
expect(r.ok).toBe(true);
});
it('rejects empty spec', () => {
const r = validateBindings(
{ apiVersion: BLUEPRINT_API_VERSION, kind: 'InstallationBindings', spec: {} },
'test',
);
expect(r.ok).toBe(false);
});
it('rejects unknown fleet key', () => {
const r = validateBindings(
{
...validBindings,
spec: { fleet: { ...validBindings.spec.fleet, socket: 'override' } },
},
'test',
);
expect(r.ok).toBe(false);
});
it('rejects relative workingDirectory', () => {
const r = validateBindings(
{
...validBindings,
spec: { fleet: { workingDirectory: 'relative/path' } },
},
'test',
);
expect(r.ok).toBe(false);
});
it('rejects control bytes in workingDirectory', () => {
const r = validateBindings(
{
...validBindings,
spec: { fleet: { workingDirectory: '/tmp/\x00bad' } },
},
'test',
);
expect(r.ok).toBe(false);
});
});
describe('preset: bootstrap-minimal@1', () => {
it('has the correct ID', () => {
expect(BOOTSTRAP_MINIMAL_V1.id).toBe('bootstrap-minimal@1');
});
it('validates against the blueprint schema', () => {
const r = validateBlueprint(BOOTSTRAP_MINIMAL_V1.blueprint, 'preset');
expect(r.ok).toBe(true);
});
it('selects software-delivery floor per A4', () => {
expect(BOOTSTRAP_MINIMAL_V1.blueprint.spec.fleet.profile).toBe('software-delivery');
expect(BOOTSTRAP_MINIMAL_V1.blueprint.spec.fleet.selection).toBe('floor');
});
});
describe('core: pipeline', () => {
it('returns EXIT_UNAVAILABLE when registry is dependency-blocked', () => {
const result = runPipeline(
{
registryAdapter: new BlockedRegistryAdapter(),
filePath: null,
presetId: null,
brainHome: SB,
},
'validate',
);
expect(result.exitCode).toBe(EXIT_UNAVAILABLE);
expect(result.diagnostics.some((d) => d.code === 'CONFIG_REGISTRY_INVALID')).toBe(true);
});
it('returns EXIT_INVALID for unknown preset', () => {
// NOTE: registry is blocked, so this test would hit the registry gate first.
// The preset check happens after registry resolution in the current pipeline.
// This is documented as the dependency-gate behavior.
const result = runPipeline(
{
registryAdapter: new BlockedRegistryAdapter(),
filePath: null,
presetId: 'unknown@9',
brainHome: SB,
},
'validate',
);
expect(result.exitCode).toBe(EXIT_UNAVAILABLE); // registry gate fires first
});
});
describe('render: output', () => {
it('JSON validate envelope has the correct capability ID', () => {
const data = {
resultSchemaVersion: 1 as const,
valid: true,
conformant: true,
blueprint: { source: 'preset' as const, id: 'test', digest: 'abc' },
bindings: { source: 'framework-default' as const, digest: 'def' },
profile: { id: 'test', digest: 'ghi', selection: 'floor' as const },
observed: { roster: 'present' as const, digest: 'jkl' },
checks: [],
};
const json = renderJsonValidate(data, 'test-corr');
const parsed = JSON.parse(json);
expect(parsed.capabilityId).toBe('config.installation.validate');
expect(parsed.status).toBe('succeeded');
expect(parsed.correlationId).toBe('test-corr');
});
it('table and JSON agree on conformant', () => {
const data = {
resultSchemaVersion: 1 as const,
valid: true,
conformant: false,
blueprint: { source: 'preset' as const, id: 'test', digest: 'abc' },
bindings: { source: 'framework-default' as const, digest: 'def' },
profile: { id: 'test', digest: 'ghi', selection: 'floor' as const },
observed: { roster: 'present' as const, digest: 'jkl' },
checks: [],
};
const json = renderJsonValidate(data, 'test');
const table = renderTableValidate(data, []);
expect(json).toContain('"conformant": false');
expect(table).toContain('Conformant: false');
});
});
describe('digest: determinism', () => {
it('produces identical digests for identical inputs with different key order', () => {
const a = { z: 1, a: { y: 2, b: 3 } };
const b = { a: { b: 3, y: 2 }, z: 1 };
expect(digestCanonical(a)).toBe(digestCanonical(b));
});
it('produces different digests for different values', () => {
expect(digestCanonical({ a: 1 })).not.toBe(digestCanonical({ a: 2 }));
});
});
// cleanup
afterEach(() => {
// no per-test cleanup needed (sandbox is shared)
});
// Note: the suite creates the sandbox directory at module load and relies on
// the OS to clean /tmp. For CI, a trap would be added. This is documented.
@@ -0,0 +1,127 @@
/**
* Table and JSON renderers for validate and plan results (§12).
*
* Table is a human rendering of the same envelope. Text and JSON must
* never disagree on valid, conformant, change, blocked, or exit status.
*/
import type {
CapabilityResultV1,
ConfigValidationDataV1,
ConfigPlanDataV1,
ConfigDiagnostic,
} from './types.js';
// ─── JSON renderer ────────────────────────────────────────────────────────────
export function renderJsonValidate(data: ConfigValidationDataV1, correlationId: string): string {
const envelope: CapabilityResultV1<ConfigValidationDataV1> = {
capabilityId: 'config.installation.validate',
status: data.conformant ? 'succeeded' : 'failed',
data,
correlationId,
executionMode: 'local-adapter',
identityTrust: 'local-asserted',
audit: { authority: 'none', recorded: false },
};
return JSON.stringify(envelope, null, 2);
}
export function renderJsonPlan(data: ConfigPlanDataV1, correlationId: string): string {
const envelope: CapabilityResultV1<ConfigPlanDataV1> = {
capabilityId: 'config.installation.plan',
status: 'succeeded', // plan always succeeds (§11.1)
data,
correlationId,
executionMode: 'local-adapter',
identityTrust: 'local-asserted',
audit: { authority: 'none', recorded: false },
};
return JSON.stringify(envelope, null, 2);
}
// ─── Table renderer (§12) ─────────────────────────────────────────────────────
export function renderTableValidate(
data: ConfigValidationDataV1,
diagnostics: ConfigDiagnostic[],
): string {
const lines: string[] = [];
lines.push('Installation Validation');
lines.push('======================');
lines.push('');
lines.push(`Valid: ${data.valid}`);
lines.push(`Conformant: ${data.conformant}`);
lines.push(
`Blueprint: ${data.blueprint.source === 'preset' ? data.blueprint.id : data.blueprint.id} (${data.blueprint.digest.substring(0, 12)}…)`,
);
lines.push(`Bindings: ${data.bindings.source} (${data.bindings.digest.substring(0, 12)}…)`);
lines.push(`Profile: ${data.profile.id} / ${data.profile.selection}`);
lines.push(
`Roster: ${data.observed.roster}${data.observed.digest ? ` (${data.observed.digest.substring(0, 12)}…)` : ''}`,
);
lines.push('');
lines.push('Checks:');
for (const check of data.checks) {
const icon = check.status === 'passed' ? '✓' : check.status === 'failed' ? '✗' : '';
lines.push(` ${icon} ${check.id}: ${check.status}`);
}
if (diagnostics.length > 0) {
lines.push('');
lines.push('Diagnostics:');
for (const d of diagnostics) {
lines.push(` [${d.code}] ${d.message}`);
}
}
lines.push('');
lines.push('Details:');
lines.push(` blueprint digest: ${data.blueprint.digest}`);
lines.push(` bindings digest: ${data.bindings.digest}`);
lines.push(` profile digest: ${data.profile.digest}`);
lines.push(` observed digest: ${data.observed.digest ?? '(absent)'}`);
return lines.join('\n');
}
export function renderTablePlan(data: ConfigPlanDataV1): string {
const lines: string[] = [];
lines.push('Installation Plan');
lines.push('=================');
lines.push('');
lines.push(`Conformant: ${data.conformant}`);
lines.push(`Changes: ${data.changeCount}`);
lines.push(`Blocked: ${data.blockedCount}`);
lines.push(`Apply: not supported (read-only v1)`);
lines.push(`Plan ID: ${data.planId}`);
lines.push('');
if (data.actions.length === 0) {
lines.push('No actions — installation is conformant.');
} else {
lines.push('Actions:');
for (const action of data.actions) {
lines.push(` [${action.operation}] ${action.resourceKind}/${action.resourceId}`);
lines.push(` reason: ${action.reasonCode} risk: ${action.risk}`);
if (action.fieldDiffs.length > 0) {
for (const fd of action.fieldDiffs) {
lines.push(` ${fd.path}: ${JSON.stringify(fd.before)}${JSON.stringify(fd.after)}`);
}
}
if (action.blockedBy.length > 0) {
lines.push(` blocked by: ${action.blockedBy.join(', ')}`);
}
}
}
lines.push('');
lines.push('Inputs:');
lines.push(` blueprint digest: ${data.inputs.blueprintDigest}`);
lines.push(` bindings digest: ${data.inputs.bindingsDigest}`);
lines.push(` profile digest: ${data.inputs.profileDigest}`);
lines.push(` observed digest: ${data.inputs.observedRosterDigest ?? '(absent)'}`);
return lines.join('\n');
}
// ─── Correlation ID ───────────────────────────────────────────────────────────
export function makeCorrelationId(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const random = Math.random().toString(36).substring(2, 8);
return `config-${timestamp}-${random}`;
}
@@ -0,0 +1,450 @@
/**
* Strict YAML schema validation for blueprint and bindings.
*
* §6/§7 of the spec: closed mappings, no aliases/anchors/merges/tags,
* single document, strict type checking on every field.
*/
import * as yaml from 'yaml';
import {
BLUEPRINT_API_VERSION,
BLUEPRINT_KIND,
BINDINGS_KIND,
type InstallationBlueprint,
type InstallationBindings,
type FleetSelection,
type ConfigDiagnostic,
} from './types.js';
const MAX_INPUT_BYTES = 1024 * 1024; // 1 MiB (§14.1)
// ─── Strict YAML loader (§14.4) ──────────────────────────────────────────────
export interface StrictYamlResult {
ok: boolean;
value?: unknown;
diagnostics: ConfigDiagnostic[];
}
export function loadStrictYaml(content: string, context: string): StrictYamlResult {
const diagnostics: ConfigDiagnostic[] = [];
if (content.length > MAX_INPUT_BYTES) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context} exceeds 1 MiB limit (${content.length} bytes)`,
retryable: false,
});
return { ok: false, diagnostics };
}
if (content.includes('\n---\n') || content.trimStart().startsWith('---')) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: multiple YAML documents rejected`,
retryable: false,
});
return { ok: false, diagnostics };
}
let value: unknown;
try {
value = yaml.parse(content, { strict: true, mapAsMap: false });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (
content.includes('&') ||
content.includes('*') ||
content.includes('<<') ||
content.includes('!')
) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: YAML aliases/anchors/merges/tags rejected`,
retryable: false,
});
} else {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: YAML parse error: ${msg.substring(0, 200)}`,
retryable: false,
});
}
return { ok: false, diagnostics };
}
if (value === null || value === undefined) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: empty document`,
retryable: false,
});
return { ok: false, diagnostics };
}
if (typeof value !== 'object' || Array.isArray(value)) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: top level must be a mapping`,
retryable: false,
});
return { ok: false, diagnostics };
}
return { ok: true, value, diagnostics };
}
// ─── ID grammar (§6.2) ───────────────────────────────────────────────────────
const ID_PATTERN = /^[a-z][a-z0-9-]{0,62}$/;
export function isValidId(id: string): boolean {
return ID_PATTERN.test(id);
}
// ─── Blueprint validation (§6) ───────────────────────────────────────────────
const BLUEPRINT_TOP_KEYS = new Set(['apiVersion', 'kind', 'metadata', 'spec']);
const BLUEPRINT_METADATA_KEYS = new Set(['name', 'generation']);
const BLUEPRINT_SPEC_KEYS = new Set(['fleet']);
const BLUEPRINT_FLEET_KEYS = new Set(['profile', 'selection']);
export function validateBlueprint(
value: unknown,
context: string,
): { ok: boolean; blueprint?: InstallationBlueprint; diagnostics: ConfigDiagnostic[] } {
const diagnostics: ConfigDiagnostic[] = [];
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (!BLUEPRINT_TOP_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: unknown top-level key '${key}'`,
path: key,
retryable: false,
});
}
}
if (obj.apiVersion !== BLUEPRINT_API_VERSION) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: apiVersion must be exactly '${BLUEPRINT_API_VERSION}'`,
path: 'apiVersion',
retryable: false,
});
}
if (obj.kind !== BLUEPRINT_KIND) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: kind must be exactly '${BLUEPRINT_KIND}'`,
path: 'kind',
retryable: false,
});
}
if (!obj.metadata || typeof obj.metadata !== 'object') {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: metadata is required`,
path: 'metadata',
retryable: false,
});
} else {
const meta = obj.metadata as Record<string, unknown>;
for (const key of Object.keys(meta)) {
if (!BLUEPRINT_METADATA_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: unknown metadata key '${key}'`,
path: `metadata.${key}`,
retryable: false,
});
}
}
if (typeof meta.name !== 'string' || !isValidId(meta.name)) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: metadata.name must match [a-z][a-z0-9-]{0,62}`,
path: 'metadata.name',
retryable: false,
});
}
if (
typeof meta.generation !== 'number' ||
!Number.isInteger(meta.generation) ||
meta.generation < 1
) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: metadata.generation must be an integer >= 1`,
path: 'metadata.generation',
retryable: false,
});
}
}
if (!obj.spec || typeof obj.spec !== 'object') {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: spec is required`,
path: 'spec',
retryable: false,
});
} else {
const spec = obj.spec as Record<string, unknown>;
for (const key of Object.keys(spec)) {
if (!BLUEPRINT_SPEC_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: unknown spec key '${key}'`,
path: `spec.${key}`,
retryable: false,
});
}
}
if (!spec.fleet || typeof spec.fleet !== 'object') {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: spec.fleet is required`,
path: 'spec.fleet',
retryable: false,
});
} else {
const fleet = spec.fleet as Record<string, unknown>;
for (const key of Object.keys(fleet)) {
if (!BLUEPRINT_FLEET_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: unknown fleet key '${key}'`,
path: `spec.fleet.${key}`,
retryable: false,
});
}
}
if (typeof fleet.profile !== 'string' || !isValidId(fleet.profile)) {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: spec.fleet.profile must match [a-z][a-z0-9-]{0,62}`,
path: 'spec.fleet.profile',
retryable: false,
});
}
if (fleet.selection !== 'floor' && fleet.selection !== 'full') {
diagnostics.push({
code: 'CONFIG_BLUEPRINT_SCHEMA',
message: `${context}: spec.fleet.selection must be 'floor' or 'full'`,
path: 'spec.fleet.selection',
retryable: false,
});
}
}
}
if (diagnostics.length > 0) {
return { ok: false, diagnostics };
}
const fleet = (obj.spec as Record<string, unknown>).fleet as Record<string, unknown>;
const meta = obj.metadata as Record<string, unknown>;
const blueprint: InstallationBlueprint = {
apiVersion: BLUEPRINT_API_VERSION,
kind: BLUEPRINT_KIND,
metadata: {
name: meta.name as string,
generation: meta.generation as number,
},
spec: {
fleet: {
profile: fleet.profile as string,
selection: fleet.selection as FleetSelection,
},
},
};
return { ok: true, blueprint, diagnostics: [] };
}
// ─── Bindings validation (§7) ────────────────────────────────────────────────
const BINDINGS_TOP_KEYS = new Set(['apiVersion', 'kind', 'spec']);
const BINDINGS_SPEC_KEYS = new Set(['fleet']);
const BINDINGS_FLEET_KEYS = new Set(['runtime', 'workingDirectory']);
const BINDINGS_RUNTIME_KEYS = new Set(['default', 'byClass']);
export function validateBindings(
value: unknown,
context: string,
): { ok: boolean; bindings?: InstallationBindings; diagnostics: ConfigDiagnostic[] } {
const diagnostics: ConfigDiagnostic[] = [];
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (!BINDINGS_TOP_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: unknown top-level key '${key}'`,
path: key,
retryable: false,
});
}
}
if (obj.apiVersion !== BLUEPRINT_API_VERSION) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: apiVersion must be exactly '${BLUEPRINT_API_VERSION}'`,
path: 'apiVersion',
retryable: false,
});
}
if (obj.kind !== BINDINGS_KIND) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: kind must be exactly '${BINDINGS_KIND}'`,
path: 'kind',
retryable: false,
});
}
if (!obj.spec || typeof obj.spec !== 'object' || Object.keys(obj.spec).length === 0) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: spec is required and non-empty (empty bindings file is invalid)`,
path: 'spec',
retryable: false,
});
} else {
const spec = obj.spec as Record<string, unknown>;
for (const key of Object.keys(spec)) {
if (!BINDINGS_SPEC_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: unknown spec key '${key}'`,
path: `spec.${key}`,
retryable: false,
});
}
}
if (spec.fleet) {
const fleet = spec.fleet as Record<string, unknown>;
for (const key of Object.keys(fleet)) {
if (!BINDINGS_FLEET_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: unknown fleet key '${key}'`,
path: `spec.fleet.${key}`,
retryable: false,
});
}
}
if (fleet.runtime) {
const runtime = fleet.runtime as Record<string, unknown>;
for (const key of Object.keys(runtime)) {
if (!BINDINGS_RUNTIME_KEYS.has(key)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: unknown runtime key '${key}'`,
path: `spec.fleet.runtime.${key}`,
retryable: false,
});
}
}
if (
runtime.default !== undefined &&
(typeof runtime.default !== 'string' || !isValidId(runtime.default))
) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: runtime.default must match [a-z][a-z0-9-]{0,62}`,
path: 'spec.fleet.runtime.default',
retryable: false,
});
}
if (runtime.byClass !== undefined && runtime.byClass !== null) {
if (typeof runtime.byClass !== 'object' || runtime.byClass === null) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: runtime.byClass must be a mapping`,
path: 'spec.fleet.runtime.byClass',
retryable: false,
});
} else {
for (const [cls, rt] of Object.entries(runtime.byClass)) {
if (!isValidId(cls)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: byClass key '${cls}' must match [a-z][a-z0-9-]{0,62}`,
path: `spec.fleet.runtime.byClass.${cls}`,
retryable: false,
});
}
if (typeof rt !== 'string' || !isValidId(rt)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: byClass value for '${cls}' must match [a-z][a-z0-9-]{0,62}`,
path: `spec.fleet.runtime.byClass.${cls}`,
retryable: false,
});
}
}
}
}
}
if (fleet.workingDirectory !== undefined) {
const wd = fleet.workingDirectory;
if (typeof wd !== 'string') {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: workingDirectory must be a string`,
path: 'spec.fleet.workingDirectory',
retryable: false,
});
} else {
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(wd)) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: workingDirectory contains control characters`,
path: 'spec.fleet.workingDirectory',
retryable: false,
});
}
if (!wd.startsWith('/') && !wd.startsWith('~/')) {
diagnostics.push({
code: 'CONFIG_BINDINGS_SCHEMA',
message: `${context}: workingDirectory must be absolute or ~/ prefixed`,
path: 'spec.fleet.workingDirectory',
retryable: false,
});
}
}
}
}
}
if (diagnostics.length > 0) {
return { ok: false, diagnostics };
}
const fleet = (obj.spec as Record<string, unknown>).fleet as Record<string, unknown> | undefined;
const bindings: InstallationBindings = {
apiVersion: BLUEPRINT_API_VERSION,
kind: BINDINGS_KIND,
spec: {
fleet: fleet
? {
runtime: fleet.runtime as InstallationBindings['spec']['fleet']['runtime'] | undefined,
workingDirectory: fleet.workingDirectory as string | undefined,
}
: {},
},
};
return { ok: true, bindings, diagnostics: [] };
}
@@ -0,0 +1,216 @@
/**
* Shared type contracts for the installation config minimal subset.
*
* Spec: docs/specs/2026-08-29_mosaic-config-minimal-subset.md (spec review
* PASS deff617d by rev-code-02; implementation per CONFIGIMPL-GO).
*
* These types are the single source of truth for the blueprint, bindings,
* result, action, and diagnostic shapes. The YAML schemas in schema.ts and
* the result renderers in render.ts consume these interfaces directly.
*/
// ─── Blueprint (§6) ───────────────────────────────────────────────────────────
export const BLUEPRINT_API_VERSION = 'config.mosaicstack.dev/v1alpha1';
export const BLUEPRINT_KIND = 'InstallationBlueprint';
export const BINDINGS_KIND = 'InstallationBindings';
export const PRESET_ID = 'bootstrap-minimal@1';
export type FleetSelection = 'floor' | 'full';
export interface InstallationBlueprint {
apiVersion: typeof BLUEPRINT_API_VERSION;
kind: typeof BLUEPRINT_KIND;
metadata: {
name: string;
generation: number;
};
spec: {
fleet: {
profile: string;
selection: FleetSelection;
};
};
}
// ─── Bindings (§7) ────────────────────────────────────────────────────────────
export interface InstallationBindings {
apiVersion: typeof BLUEPRINT_API_VERSION;
kind: typeof BINDINGS_KIND;
spec: {
fleet: {
runtime?: {
default?: string;
byClass?: Record<string, string>;
};
workingDirectory?: string;
};
};
}
// ─── Framework defaults (§7, A5) ─────────────────────────────────────────────
export interface FrameworkBindingDefaults {
runtime: string;
workingDirectory: string;
}
export const FRAMEWORK_BINDING_DEFAULTS: FrameworkBindingDefaults = {
runtime: 'claude',
workingDirectory: '~',
};
// ─── Preset (§8) ──────────────────────────────────────────────────────────────
export interface PackagedPreset {
id: typeof PRESET_ID;
blueprint: InstallationBlueprint;
}
export const BOOTSTRAP_MINIMAL_V1: PackagedPreset = {
id: 'bootstrap-minimal@1',
blueprint: {
apiVersion: 'config.mosaicstack.dev/v1alpha1',
kind: 'InstallationBlueprint',
metadata: {
name: 'bootstrap-minimal',
generation: 1,
},
spec: {
fleet: {
profile: 'software-delivery',
selection: 'floor',
},
},
},
};
// ─── Diagnostics (§13) ────────────────────────────────────────────────────────
export type DiagnosticCode =
| 'CONFIG_USAGE_INVALID'
| 'CONFIG_REGISTRY_INVALID'
| 'CONFIG_BLUEPRINT_MISSING'
| 'CONFIG_BLUEPRINT_SCHEMA'
| 'CONFIG_BINDINGS_SCHEMA'
| 'CONFIG_BINDINGS_NOT_IGNORED'
| 'CONFIG_PRESET_UNKNOWN'
| 'CONFIG_PROFILE_UNRESOLVED'
| 'CONFIG_ROLE_UNRESOLVED'
| 'CONFIG_DESIRED_ROSTER_INVALID'
| 'CONFIG_OBSERVED_ROSTER_INVALID'
| 'CONFIG_ROSTER_MISSING'
| 'CONFIG_NONCONFORMANT'
| 'CONFIG_DRIFT_CREATE'
| 'CONFIG_DRIFT_UPDATE'
| 'CONFIG_DRIFT_FULL_ENGINE_REQUIRED'
| 'CONFIG_ADAPTER_UNAVAILABLE';
export interface ConfigDiagnostic {
code: DiagnosticCode;
message: string;
path?: string;
retryable: boolean;
}
// ─── Registry provenance (§5, §2.2) ──────────────────────────────────────────
export interface RegistryProvenance {
resolved: boolean;
brainHome: string | null;
sourceKeys: Array<{ key: string; sourceClass: string }>;
}
// ─── Validation result (§12.1) ───────────────────────────────────────────────
export interface ConfigCheckResult {
id: string;
status: 'passed' | 'failed' | 'notChecked';
}
export interface ConfigValidationDataV1 {
resultSchemaVersion: 1;
valid: boolean;
conformant: boolean;
blueprint: { source: 'file' | 'preset'; id: string; digest: string };
bindings: { source: 'file' | 'framework-default'; digest: string };
profile: { id: string; digest: string; selection: FleetSelection };
observed: { roster: 'present' | 'absent'; digest: string | null };
checks: ConfigCheckResult[];
}
// ─── Plan result (§12.2, §11.2) ─────────────────────────────────────────────
export type ConfigPlanOperationV1 = 'create' | 'update' | 'blocked';
export type ConfigRiskV1 = 'none' | 'review-required' | 'full-engine-required';
export interface ConfigPlanFieldDiff {
path: string;
before: string | number | boolean | null;
after: string | number | boolean | null;
}
export interface ConfigPlanActionV1 {
id: string;
resourceKind: 'fleet-roster' | 'fleet-seat';
resourceId: string;
operation: ConfigPlanOperationV1;
risk: ConfigRiskV1;
reasonCode: string;
beforeDigest: string | null;
afterDigest: string | null;
fieldDiffs: ConfigPlanFieldDiff[];
blockedBy: string[];
}
export interface ConfigPlanDataV1 {
resultSchemaVersion: 1;
planSchemaVersion: 1;
planId: string;
applySupported: false;
valid: true;
conformant: boolean;
changeCount: number;
blockedCount: number;
inputs: {
blueprintDigest: string;
bindingsDigest: string;
profileDigest: string;
observedRosterDigest: string | null;
};
actions: ConfigPlanActionV1[];
}
// ─── Result envelope (§12) ────────────────────────────────────────────────────
export interface CapabilityResultV1<T> {
capabilityId: string;
status: 'succeeded' | 'failed' | 'invalid';
data?: T;
diagnostics?: ConfigDiagnostic[];
correlationId: string;
executionMode: 'local-adapter';
identityTrust: 'local-asserted';
audit: { authority: 'none'; recorded: boolean };
}
// ─── Exit codes (§10.3) ──────────────────────────────────────────────────────
export const EXIT_OK = 0;
export const EXIT_INVALID = 2;
export const EXIT_RESERVED_SCOPE = 3;
export const EXIT_NONCONFORMANT = 4;
export const EXIT_UNAVAILABLE = 6;
// ─── Dependency gate marker (§2.2) ───────────────────────────────────────────
/**
* DEPENDENCY GATE: the approved MosaicRegistryResolver does not exist at
* the pinned baseline. The registry-consuming work is PARKED. This marker
* interface exists so that when the reviewed resolver ships, the adapter
* binds to it without schema changes. Until then, the adapter returns
* CONFIG_REGISTRY_INVALID with the dependency-blocked message.
*/
export const REGISTRY_RESOLVER_BLOCKED =
'MosaicRegistryResolver: dependency-blocked pending reviewed resolver (CFG-REQ-001..006 charter)';