Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15a6969688 | ||
|
|
41e8046371 | ||
|
|
6e16675ea2 | ||
|
|
19ebc422aa |
@@ -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 F1–F7): 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 1–6 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.
|
||||
@@ -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 F1–F5): 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 §§2–4, 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 1–6 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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)';
|
||||
Reference in New Issue
Block a user