diff --git a/.derived/codebase-index/by-spec/003-auth-api-client.json b/.derived/codebase-index/by-spec/003-auth-api-client.json index ae1daf8..bf98387 100644 --- a/.derived/codebase-index/by-spec/003-auth-api-client.json +++ b/.derived/codebase-index/by-spec/003-auth-api-client.json @@ -49,5 +49,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "ca56147cbcadfa7d1939c6a721277f0d812698189366e7f36eabfe4beb32986f" + "shardHash": "322881dfb8679f2c3bcb478e841ddd668c73a599587eb7f55140b5a372e11c9b" } diff --git a/.derived/codebase-index/by-spec/004-governance-verbs.json b/.derived/codebase-index/by-spec/004-governance-verbs.json index 851ac6c..e93b10e 100644 --- a/.derived/codebase-index/by-spec/004-governance-verbs.json +++ b/.derived/codebase-index/by-spec/004-governance-verbs.json @@ -32,5 +32,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "3a8b9f30bdcbc6fc77507d7be059f9e25feeaaaab8208d8950eb68572cd29386" + "shardHash": "08ad0ffb9d6ed4b83972d0a82befdb156e73abad7ace2fd4e87e93e7c895c8a9" } diff --git a/.derived/codebase-index/by-spec/005-mcp-server.json b/.derived/codebase-index/by-spec/005-mcp-server.json index 01fd320..b6d77cc 100644 --- a/.derived/codebase-index/by-spec/005-mcp-server.json +++ b/.derived/codebase-index/by-spec/005-mcp-server.json @@ -32,5 +32,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "e70bccf4edf182edfbbbc8a646914cc844164100db692ccdea182dfa83f46c52" + "shardHash": "1eae84bbae7e6a80af5ea54ff5c22132af97ca52a6faa540fe14e205f682cee4" } diff --git a/.derived/codebase-index/by-spec/006-template-upgrade-verb.json b/.derived/codebase-index/by-spec/006-template-upgrade-verb.json index 6e2af61..e5f6639 100644 --- a/.derived/codebase-index/by-spec/006-template-upgrade-verb.json +++ b/.derived/codebase-index/by-spec/006-template-upgrade-verb.json @@ -32,5 +32,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "dfc07fb9566e067f5e7850b1ce101bfd545cb104499f819f51e76f98f5d23c74" + "shardHash": "86726ee96b3b570bce68cddf8d6c1ef11caa5e5b2154a4999ddfd9c4f24d0bad" } diff --git a/.derived/spec-registry/by-spec/003-auth-api-client.json b/.derived/spec-registry/by-spec/003-auth-api-client.json index b719548..abc3feb 100644 --- a/.derived/spec-registry/by-spec/003-auth-api-client.json +++ b/.derived/spec-registry/by-spec/003-auth-api-client.json @@ -15,7 +15,7 @@ } ], "id": "003-auth-api-client", - "implementation": "in-progress", + "implementation": "complete", "sectionHeadings": [ "003: Auth + API client", "1. Cross-repo dependency", @@ -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" } diff --git a/.derived/spec-registry/by-spec/004-governance-verbs.json b/.derived/spec-registry/by-spec/004-governance-verbs.json index e24d008..b76ad87 100644 --- a/.derived/spec-registry/by-spec/004-governance-verbs.json +++ b/.derived/spec-registry/by-spec/004-governance-verbs.json @@ -11,7 +11,7 @@ } ], "id": "004-governance-verbs", - "implementation": "in-progress", + "implementation": "complete", "sectionHeadings": [ "004: Governance verbs", "1. Cross-repo dependency", @@ -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" } diff --git a/.derived/spec-registry/by-spec/005-mcp-server.json b/.derived/spec-registry/by-spec/005-mcp-server.json index 94b4094..0e84808 100644 --- a/.derived/spec-registry/by-spec/005-mcp-server.json +++ b/.derived/spec-registry/by-spec/005-mcp-server.json @@ -11,7 +11,7 @@ } ], "id": "005-mcp-server", - "implementation": "in-progress", + "implementation": "complete", "sectionHeadings": [ "005: MCP server", "1. Behavior", @@ -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" } diff --git a/specs/003-auth-api-client/spec.md b/specs/003-auth-api-client/spec.md index 41b7d63..12500a0 100644 --- a/specs/003-auth-api-client/spec.md +++ b/specs/003-auth-api-client/spec.md @@ -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: @@ -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`. diff --git a/specs/004-governance-verbs/spec.md b/specs/004-governance-verbs/spec.md index af3eb72..06e0129 100644 --- a/specs/004-governance-verbs/spec.md +++ b/specs/004-governance-verbs/spec.md @@ -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: @@ -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`) @@ -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`. diff --git a/specs/005-mcp-server/spec.md b/specs/005-mcp-server/spec.md index d07012d..99cd0bc 100644 --- a/specs/005-mcp-server/spec.md +++ b/specs/005-mcp-server/spec.md @@ -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: @@ -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`. diff --git a/src/mcp.rs b/src/mcp.rs index 43c6f04..f63302c 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -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()), @@ -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"); } diff --git a/src/verbs/fleet.rs b/src/verbs/fleet.rs index c3f6747..570b163 100644 --- a/src/verbs/fleet.rs +++ b/src/verbs/fleet.rs @@ -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; @@ -130,7 +130,9 @@ pub fn remove( } fn render_list(v: &Value) -> AppResult { - 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()); } @@ -173,22 +175,24 @@ fn render_app(v: &Value) -> AppResult { 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 { 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) } @@ -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(); @@ -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(); @@ -276,7 +302,7 @@ mod tests { .unwrap(); mock.assert(); - assert_eq!(value[0]["status"], "running"); + assert_eq!(value["apps"][0]["status"], "running"); } #[test] @@ -284,8 +310,9 @@ mod tests { 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(); @@ -294,7 +321,7 @@ mod tests { .unwrap(); mock.assert(); - assert_eq!(value["kind"], "backup"); + assert_eq!(value["repository"], "s3:bucket/app"); } #[test] diff --git a/src/verbs/mod.rs b/src/verbs/mod.rs index 40d29f6..a68f47c 100644 --- a/src/verbs/mod.rs +++ b/src/verbs/mod.rs @@ -130,16 +130,24 @@ fn to_pretty(value: &T) -> String { serde_json::to_string_pretty(value).expect("serializing an owned envelope cannot fail") } -/// Interpret a passthrough value as a JSON array (list endpoints), or a decode -/// error naming what came back instead. Human path only; JSON passthrough never -/// calls this. -fn as_array(v: &Value) -> AppResult<&Vec> { - v.as_array().ok_or_else(|| { - AppError::Operational(anyhow::anyhow!( - "expected a JSON array from the control plane, got {}", +/// Interpret a passthrough value as a wrapped list: an object whose `key` holds +/// a JSON array. Every platform collection is wrapped (`{tenants:[…]}` from +/// stagecraft `ListTenantsResponse`, `{apps:[…]}` from `ListFleetResponse`; spec +/// 004 §5.3), never a bare array. A decode error names what came back instead. +/// Human path only; JSON passthrough never calls this. +fn array_field<'a>(v: &'a Value, key: &str) -> AppResult<&'a Vec> { + match v.get(key) { + Some(inner) => inner.as_array().ok_or_else(|| { + AppError::Operational(anyhow::anyhow!( + "expected `{key}` to be a JSON array from the control plane, got {}", + kind_of(inner) + )) + }), + None => Err(AppError::Operational(anyhow::anyhow!( + "expected an object carrying `{key}` from the control plane, got {}", kind_of(v) - )) - }) + ))), + } } /// A human name for a JSON value's shape, for decode-error messages. diff --git a/src/verbs/tenants.rs b/src/verbs/tenants.rs index 144da0e..f52debc 100644 --- a/src/verbs/tenants.rs +++ b/src/verbs/tenants.rs @@ -4,7 +4,7 @@ use std::fmt::Write; use serde_json::Value; -use super::{as_array, client_for, emit_err, emit_ok, field, render, table}; +use super::{array_field, client_for, emit_err, emit_ok, field, render, table}; use crate::api::{block_on, ApiClient, ApiError}; use crate::config::ResolvedConfig; use crate::error::AppResult; @@ -70,7 +70,9 @@ pub fn install_url( } fn render_list(v: &Value) -> AppResult { - let tenants = as_array(v)?; + // GET /tenants is `{tenants:[…]}` (stagecraft `ListTenantsResponse`), not a + // bare array; unwrap the collection key (spec 004 §5.3). + let tenants = array_field(v, "tenants")?; if tenants.is_empty() { return Ok("no tenants".to_string()); } @@ -82,14 +84,23 @@ fn render_list(v: &Value) -> AppResult { } fn render_detail(v: &Value) -> AppResult { + // GET /tenants/:id is `{tenant:{…}, installations:[…]}` (stagecraft + // `TenantDetailResponse`): the record lives under `tenant`, and + // `installations` is a sibling array, not nested in it (spec 004 §5.3). + let tenant = v.get("tenant").ok_or_else(|| { + crate::error::AppError::Operational(anyhow::anyhow!( + "expected an object carrying a `tenant` record from the control plane, got {}", + super::kind_of(v) + )) + })?; let mut out = String::new(); - let _ = write!(out, "id: {}", field(v, "id")); - let _ = write!(out, "\nname: {}", field(v, "name")); - let owner = field(v, "ownerUserId"); + let _ = write!(out, "id: {}", field(tenant, "id")); + let _ = write!(out, "\nname: {}", field(tenant, "name")); + let owner = field(tenant, "ownerUserId"); if !owner.is_empty() { let _ = write!(out, "\nowner: {owner}"); } - let created = field(v, "createdAt"); + let created = field(tenant, "createdAt"); if !created.is_empty() { let _ = write!(out, "\ncreated: {created}"); } @@ -137,10 +148,11 @@ mod tests { #[test] fn list_renders_a_table() { - let value = json!([ + // The plane wraps the collection under `tenants` (spec 004 §5.3). + let value = json!({"tenants": [ {"id": "t_1", "name": "Acme", "createdAt": "2026-07-01"}, {"id": "t_2", "name": "Beta", "createdAt": "2026-07-02"} - ]); + ]}); let out = render_list(&value).unwrap(); assert!(out.contains("ID")); assert!(out.contains("t_1")); @@ -149,18 +161,29 @@ mod tests { #[test] fn list_empty_states_when_no_tenants() { - assert_eq!(render_list(&json!([])).unwrap(), "no tenants"); + assert_eq!(render_list(&json!({"tenants": []})).unwrap(), "no tenants"); + } + + #[test] + fn list_rejects_a_bare_array() { + // A bare array is the pre-live-check assumption the plane never returned; + // it must now surface as a decode error, not render. + assert!(render_list(&json!([{"id": "t_1"}])).is_err()); } #[test] fn detail_shows_installations() { + // `{tenant:{…}, installations:[…]}`: record under `tenant`, installations + // a sibling (stagecraft `TenantDetailResponse`, spec 004 §5.3). let value = json!({ - "id": "t_1", - "name": "Acme", - "ownerUserId": "u_1", - "createdAt": "2026-07-01", + "tenant": { + "id": "t_1", + "name": "Acme", + "ownerUserId": "u_1", + "createdAt": "2026-07-01" + }, "installations": [ - {"id": "i_1", "githubOrg": "acme-inc", "installationId": 125344051, "status": "active"} + {"id": "i_1", "githubOrg": "acme-inc", "installationId": "125344051", "status": "active"} ] }); let out = render_detail(&value).unwrap(); @@ -169,6 +192,13 @@ mod tests { assert!(out.contains("125344051")); } + #[test] + fn detail_rejects_a_response_without_a_tenant_record() { + // No `tenant` key is drift the human renderer cannot read: a decode + // error (exit 1), never a blank header. + assert!(render_detail(&json!({"installations": []})).is_err()); + } + #[test] fn install_url_renders_bare_url_for_humans() { let value = json!({"url": "https://github.com/apps/x/installations/new?state=abc"}); @@ -184,7 +214,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 client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); @@ -193,7 +223,7 @@ mod tests { .unwrap(); mock.assert(); - assert_eq!(value[0]["id"], "t_1"); + assert_eq!(value["tenants"][0]["id"], "t_1"); } #[test] @@ -235,7 +265,7 @@ mod tests { #[test] fn list_envelope_snapshot() { - let data = json!([{"id": "t_1", "name": "Acme"}]); + let data = json!({"tenants": [{"id": "t_1", "name": "Acme"}]}); let env = crate::verbs::success_envelope_value(&data); assert_eq!(env, json!({ "ok": true, "data": data })); }