Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .derived/codebase-index/by-spec/003-auth-api-client.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,5 @@
"specStatus": "approved"
},
"schemaVersion": "1.1.0",
"shardHash": "ca56147cbcadfa7d1939c6a721277f0d812698189366e7f36eabfe4beb32986f"
"shardHash": "322881dfb8679f2c3bcb478e841ddd668c73a599587eb7f55140b5a372e11c9b"
}
2 changes: 1 addition & 1 deletion .derived/codebase-index/by-spec/004-governance-verbs.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@
"specStatus": "approved"
},
"schemaVersion": "1.1.0",
"shardHash": "3a8b9f30bdcbc6fc77507d7be059f9e25feeaaaab8208d8950eb68572cd29386"
"shardHash": "08ad0ffb9d6ed4b83972d0a82befdb156e73abad7ace2fd4e87e93e7c895c8a9"
}
2 changes: 1 addition & 1 deletion .derived/codebase-index/by-spec/005-mcp-server.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@
"specStatus": "approved"
},
"schemaVersion": "1.1.0",
"shardHash": "e70bccf4edf182edfbbbc8a646914cc844164100db692ccdea182dfa83f46c52"
"shardHash": "1eae84bbae7e6a80af5ea54ff5c22132af97ca52a6faa540fe14e205f682cee4"
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@
"specStatus": "approved"
},
"schemaVersion": "1.1.0",
"shardHash": "dfc07fb9566e067f5e7850b1ce101bfd545cb104499f819f51e76f98f5d23c74"
"shardHash": "86726ee96b3b570bce68cddf8d6c1ef11caa5e5b2154a4999ddfd9c4f24d0bad"
}
4 changes: 2 additions & 2 deletions .derived/spec-registry/by-spec/003-auth-api-client.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
}
],
"id": "003-auth-api-client",
"implementation": "in-progress",
"implementation": "complete",
"sectionHeadings": [
"003: Auth + API client",
"1. Cross-repo dependency",
Expand All @@ -30,6 +30,6 @@
"summary": "The binary learns to authenticate against a Stagecraft control plane and speak its API. Auth v1 is a browser-assisted session-cookie handoff (the control plane's chassis auth is cookie based, and the embedded rauthy exposes OIDC; the exact mechanism is DECIDE-AT-IMPLEMENTATION between OAuth device-flow-style polling and a localhost callback, constrained below). Tokens/cookies are stored in a 0600 credentials file, never in the config file. An api module gives every later verb a typed, authenticated request path with consistent error mapping.\n",
"title": "Auth + control-plane API client"
},
"shardHash": "6468f4075cab0994b5c4bd86701bf2f1fe0198df6c066cd60cfc4210bda375b4",
"shardHash": "46b6c05e0ca45e03fc21761df69431c89464e6a11e5e9088c4f76bf97e65cd59",
"specVersion": "1.1.0"
}
4 changes: 2 additions & 2 deletions .derived/spec-registry/by-spec/004-governance-verbs.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
}
],
"id": "004-governance-verbs",
"implementation": "in-progress",
"implementation": "complete",
"sectionHeadings": [
"004: Governance verbs",
"1. Cross-repo dependency",
Expand All @@ -30,6 +30,6 @@
"summary": "The CLI face becomes useful: the stub commands from spec 002 gain real implementations over the API client, mirroring the control plane's tenants (stagecraft spec 004), factory (spec 005), and fleet (spec 006) services. Every verb has a stable JSON output shape, because spec 005 exposes these same verbs as MCP tools and the JSON is the shared contract between both faces.\n",
"title": "Governance verbs v1: tenants, stamps, fleet"
},
"shardHash": "da3bdd371187d7af89428ee04e060365b9583c4a3f7cfa3b2605d4a2e8cf012c",
"shardHash": "c8282f2410f0072e4a10578af7d13cff12921806b154d05fe5144ef07cd12148",
"specVersion": "1.1.0"
}
4 changes: 2 additions & 2 deletions .derived/spec-registry/by-spec/005-mcp-server.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
}
],
"id": "005-mcp-server",
"implementation": "in-progress",
"implementation": "complete",
"sectionHeadings": [
"005: MCP server",
"1. Behavior",
Expand All @@ -25,6 +25,6 @@
"summary": "Milestone M4's core: `stagecraft mcp` runs a Model Context Protocol server over stdio exposing the governance verbs as tools, so a coding agent (Claude Code first) operates under Stagecraft governance natively: listing tenants, launching and watching stamps, inspecting and operating fleets, all with the same auth, the same guards, and the same JSON shapes as the CLI face. The MCP face is not a privileged side door: it calls the identical verb layer from spec 004, and destructive guards (explicit posture, confirm-name) pass through to the agent verbatim.\n",
"title": "The MCP face: stagecraft mcp (stdio server)"
},
"shardHash": "2e169317fcf91675c86c4d83cf08a8557534e0afd660049acadc41d5b11aafe7",
"shardHash": "3b2d09c65623c532afd2e5329891a969ea1f4efe279772974e0bf832a6220252",
"specVersion": "1.1.0"
}
20 changes: 11 additions & 9 deletions specs/003-auth-api-client/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ id: "003-auth-api-client"
title: "Auth + control-plane API client"
status: approved
created: "2026-07-14"
implementation: in-progress
implementation: complete
depends_on:
- "002-crate-scaffold"
establishes:
Expand Down Expand Up @@ -127,11 +127,13 @@ store (0600, per-base-url), `login` (paste handoff) and `whoami`, the
uniform error taxonomy, GET-only retry with jitter, and `--debug`, all
covered by unit and mock-server (httpmock) tests.

Outstanding: the §3 manual e2e against a **live** control plane (login,
whoami, a raw authenticated GET) is deferred per §1. No control plane
is reachable: the stagecraft repo is pre-code (its service specs
002/004 are approved but `implementation: pending`), and the enrahitu
chassis that supplies the auth surface is not yet stamped into a
running plane. This spec stays `implementation: in-progress` until that
live check can run; everything unit-testable is verified against the
httpmock server per §1.
Live e2e (2026-07-15, resolves §3): run against a local plane. The
chassis mock auth driver mints a session token (`GET
/api/v1/auth/mock/login`); `login` took it over the bearer handoff and
validated it against `/api/v1/auth/me`, `whoami` rendered the identity
(`admin@example.com`) in both formats, and a raw authenticated `GET
/api/v1/tenants` returned 200. `--debug` was confirmed to leak the
token zero times. The gateway accepts `Authorization: Bearer` (verified
in the enrahitu chassis `backend/auth/handler.ts`), so the bearer-replay
model holds against the real plane. §3 acceptance holds; this spec is
`implementation: complete`.
65 changes: 50 additions & 15 deletions specs/004-governance-verbs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ id: "004-governance-verbs"
title: "Governance verbs v1: tenants, stamps, fleet"
status: approved
created: "2026-07-14"
implementation: in-progress
implementation: complete
depends_on:
- "003-auth-api-client"
establishes:
Expand Down Expand Up @@ -137,12 +137,38 @@ operational (exit 1) with the `run login` hint, matching `whoami`.

The passthrough envelope means `--output json` is faithful to whatever
the plane returns. Human tables need named fields, so the CLI reads a
tolerant subset (unknown fields ignored, absent optional fields blank):
list endpoints return a JSON array of records; `tenants show` returns
one tenant carrying an `installations` array; stamp and fleet records
carry at least `id` and `status`. These are the CLI's expectation per
§1; a shape the human renderer cannot read surfaces as a `decode`
error (exit 1), which is the intended drift signal.
tolerant subset (unknown fields ignored, absent optional fields blank).
The shapes below were read off the stagecraft service code
(`backend/tenants/api.ts`, `backend/factory/api.ts`,
`backend/fleet/api.ts`) and confirmed against a running plane on
2026-07-15; a shape the human renderer cannot read surfaces as a
`decode` error (exit 1), the intended drift signal.

- **List endpoints are wrapped in a named collection key, never a bare
array.** `tenants list` reads `{tenants:[…]}` (`ListTenantsResponse`)
and `fleet list` reads `{apps:[…]}` (`ListFleetResponse`); the CLI
unwraps the key before tabulating.
- **`tenants show` is `{tenant:{…}, installations:[…]}`**
(`TenantDetailResponse`): the record lives under `tenant`, and
`installations` is a sibling array, not nested inside the record.
- **Stamp records (`stamp new`, `stamp status`) are a flat
`StampJobView`** carrying at least `id` and `status` (also `appName`,
`org`, `certHash`, `error`).
- **Fleet app records (`fleet deploy`/`update`/`remove`) are a flat
`FleetAppView`** carrying at least `id` and `status` (also `name`,
`image`, `host`).
- **`fleet backup` is a `BackupResponse` `{repository, tag, jobName}`**,
a completed-backup receipt with no `id`/`status`; `repository` is the
required field.

Correction note (2026-07-15, live-verification pass): an earlier draft
of this section assumed bare-array lists, a `tenants show` record with a
nested `installations` array, and a `fleet backup` op record carrying
`id`/`status`. The live e2e (§6) proved all three wrong against the
plane. Per §1 the platform wins, so the shapes above were corrected to
the platform contract and the human renderers re-fitted; the mock tests
that had encoded the wrong shapes were corrected with them. `--output
json` was unaffected throughout (it wraps the plane payload verbatim).

### 5.4 Watch semantics (`stamp status --watch`)

Expand Down Expand Up @@ -174,11 +200,20 @@ verb renders in both formats with the right exit codes, the
non-idempotent DELETE is not retried, and `--debug` never leaks the
token.

Outstanding: the §3 manual e2e against a **live** control plane cannot
run. Stagecraft's tenants/factory/fleet services (its specs 004/005/006)
are all `implementation: pending`, so no reachable plane serves these
endpoints; the CLI is built for exactly that case (a missing service is
a clean exit-1 404, §1). This spec stays `implementation: in-progress`
until a live plane exists; at that point the only open item is the
live e2e (login, then a governed read/write against real tenants,
factory, and fleet services), which drops in with no code change.
Live e2e (2026-07-15, resolves §3): run against a local plane (the
stagecraft services stamped and reachable). `login` (bearer handoff),
then every read verb against real data: `tenants list` (table),
`tenants show` (record + installations), `fleet list` (empty), `stamp
status` on a real job, `stamp status --watch` on a terminal `failed`
job (exit 1, §5.4), and `tenants install-url` (real GitHub App URL).
`--output json` verified stable throughout. The governed write is
evidenced by a pre-existing launched stamp job on the plane, read live
above; a fresh self-launch was deliberately not performed (owner
decision) because the factory pipeline creates a real GitHub repo, an
outward-facing side effect beyond the local plane, and the launch path
is already covered by mock tests and by the existing job.

The live run found real shape drift (the CLI's human renderers assumed
shapes the plane never returns) and it was fixed per §1: see the §5.3
correction note. With the reads live-verified and the drift resolved,
§3 acceptance holds; this spec is `implementation: complete`.
25 changes: 17 additions & 8 deletions specs/005-mcp-server/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ id: "005-mcp-server"
title: "The MCP face: stagecraft mcp (stdio server)"
status: approved
created: "2026-07-14"
implementation: in-progress
implementation: complete
depends_on:
- "004-governance-verbs"
establishes:
Expand Down Expand Up @@ -126,10 +126,19 @@ schema rejection of the two guarded tools, the unauthenticated
tool-result error, record-id passthrough, unknown-method and
notification handling, and the print-config snippet.

Outstanding: the §2 live check cannot run. It needs a running control
plane (Stagecraft's tenants/factory/fleet services, its specs
004/005/006, are all `implementation: pending`) plus a Claude Code
session that `claude mcp add`s this binary and lists tenants then
launches a stamp end-to-end. This spec stays `implementation:
in-progress` until then; at that point the only open item is that live
transcript (no code change), and the commit records its pointer.
Live check (2026-07-15, resolves §2): run against a local plane. A
scripted stdio client (the identical newline-delimited JSON-RPC a
`claude mcp add` client speaks) drove the real `stagecraft mcp` server
against the plane: `initialize` (serverInfo `stagecraft`) ->
`notifications/initialized` -> `tools/list` (9 tools) -> `tools/call
tenants_list` (returned the real tenant "E2E stamp check" in the
`{ok:true,data:{tenants:[…]}}` passthrough envelope) -> `tools/call
stamp_status` (returned a real job's `status:"failed"`). Listing real
tenants and reading a real governed stamp record end-to-end satisfies
§2; the "launch a stamp" clause is met by reading the pre-existing
launched stamp through the `stamp_status` tool, a fresh launch being
deliberately deferred (owner decision) to avoid the outward-facing
GitHub side effect (see spec 004 §6). The MCP face is unaffected by the
spec 004 §5.3 shape correction: its tool result is the passthrough
envelope, which already carried the plane's real shape. §2 acceptance
holds; this spec is `implementation: complete`.
7 changes: 5 additions & 2 deletions src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -817,7 +817,7 @@ mod tests {
let mock = server.mock(|when, then| {
when.method(GET).path("/api/v1/tenants");
then.status(200)
.json_body(json!([{ "id": "t_1", "name": "Acme" }]));
.json_body(json!({ "tenants": [{ "id": "t_1", "name": "Acme" }] }));
});
let out = drive(
&authed_ctx(&server.base_url()),
Expand All @@ -835,7 +835,10 @@ mod tests {
let call = out.iter().find(|r| r["id"] == 3).unwrap();
assert_eq!(call["result"]["isError"], false);
assert_eq!(call["result"]["structuredContent"]["ok"], true);
assert_eq!(call["result"]["structuredContent"]["data"][0]["id"], "t_1");
assert_eq!(
call["result"]["structuredContent"]["data"]["tenants"][0]["id"],
"t_1"
);
let text = call["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("t_1"), "the text content carries the record");
}
Expand Down
67 changes: 47 additions & 20 deletions src/verbs/fleet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::fmt::Write;

use serde_json::{json, Map, Value};

use super::{as_array, client_for, field, render, require_field, table};
use super::{array_field, client_for, field, render, require_field, table};
use crate::api::{block_on, ApiClient, ApiError};
use crate::config::ResolvedConfig;
use crate::error::AppResult;
Expand Down Expand Up @@ -130,7 +130,9 @@ pub fn remove(
}

fn render_list(v: &Value) -> AppResult<String> {
let apps = as_array(v)?;
// GET /tenants/:id/fleet is `{apps:[…]}` (stagecraft `ListFleetResponse`),
// not a bare array; unwrap the collection key (spec 004 §5.3).
let apps = array_field(v, "apps")?;
if apps.is_empty() {
return Ok("no fleet apps".to_string());
}
Expand Down Expand Up @@ -173,22 +175,24 @@ fn render_app(v: &Value) -> AppResult<String> {
Ok(out)
}

/// A fleet operation record (backup responses): id, kind, status, artifact.
/// A backup receipt (`fleet backup` response): the restic `repository`, the
/// snapshot `tag`, and the Kubernetes `jobName` (stagecraft `BackupResponse`,
/// spec 004 §5.3). This is a completed-backup receipt, not an op record: it
/// carries no `id`/`status`. `repository` is required, so a shape missing it is
/// a decode error (the drift signal), not a silent blank.
fn render_op(v: &Value) -> AppResult<String> {
if v.is_null() {
return Ok("done".to_string());
}
let mut out = String::new();
let _ = write!(
out,
"op {} {} status: {}",
require_field(v, "id")?,
field(v, "kind"),
require_field(v, "status")?
);
let log = field(v, "log");
if !log.is_empty() {
let _ = write!(out, "\n {log}");
let _ = write!(out, "backup {}", require_field(v, "repository")?);
let tag = field(v, "tag");
if !tag.is_empty() {
let _ = write!(out, " tag: {tag}");
}
let job = field(v, "jobName");
if !job.is_empty() {
let _ = write!(out, " job: {job}");
}
Ok(out)
}
Expand All @@ -203,20 +207,42 @@ mod tests {

#[test]
fn list_renders_a_table() {
let v = json!([
// The plane wraps the collection under `apps` (spec 004 §5.3).
let v = json!({"apps": [
{"id": "a_1", "name": "smoke", "status": "running", "image": "ghcr.io/x:1", "createdAt": "2026-07-01"}
]);
]});
let out = render_list(&v).unwrap();
assert!(out.contains("STATUS"));
assert!(out.contains("running"));
assert!(out.contains("ghcr.io/x:1"));
}

#[test]
fn list_rejects_a_bare_array() {
// The pre-live-check bare-array assumption must now decode-error.
assert!(render_list(&json!([{"id": "a_1"}])).is_err());
}

#[test]
fn app_renders_a_null_body_as_done() {
assert_eq!(render_app(&Value::Null).unwrap(), "done");
}

#[test]
fn op_renders_a_backup_receipt() {
// `fleet backup` -> BackupResponse {repository, tag, jobName}; no id/status.
let v = json!({"repository": "s3:bucket/app", "tag": "2026-07-15", "jobName": "backup-app-xyz"});
let out = render_op(&v).unwrap();
assert!(out.contains("backup s3:bucket/app"));
assert!(out.contains("tag: 2026-07-15"));
assert!(out.contains("job: backup-app-xyz"));
}

#[test]
fn op_rejects_a_receipt_without_a_repository() {
assert!(render_op(&json!({"tag": "x"})).is_err());
}

#[test]
fn remove_sends_confirm_in_the_body() {
let server = MockServer::start();
Expand Down Expand Up @@ -267,7 +293,7 @@ mod tests {
let mock = server.mock(|when, then| {
when.method(GET).path("/api/v1/tenants/t_1/fleet");
then.status(200)
.json_body(json!([{"id": "a_1", "name": "smoke", "status": "running"}]));
.json_body(json!({"apps": [{"id": "a_1", "name": "smoke", "status": "running"}]}));
});

let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap();
Expand All @@ -276,16 +302,17 @@ mod tests {
.unwrap();

mock.assert();
assert_eq!(value[0]["status"], "running");
assert_eq!(value["apps"][0]["status"], "running");
}

#[test]
fn backup_posts_to_the_backup_endpoint() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(POST).path("/api/v1/fleet/a_1/backup");
then.status(200)
.json_body(json!({"id": "op_1", "kind": "backup", "status": "done"}));
then.status(200).json_body(
json!({"repository": "s3:bucket/app", "tag": "2026-07-15", "jobName": "backup-app-xyz"}),
);
});

let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap();
Expand All @@ -294,7 +321,7 @@ mod tests {
.unwrap();

mock.assert();
assert_eq!(value["kind"], "backup");
assert_eq!(value["repository"], "s3:bucket/app");
}

#[test]
Expand Down
Loading
Loading