spec: fleet architecture diagram — provider-neutral inventory + /fleet/architecture
Live cloud inventory behind a provider seam (internal/infraview + internal/awsinv), hourly pass to infra/inventory.json, and an operator-only graph page joining DNS -> IP -> machine -> box -> apps with rate-card cost and drift detection.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+358
−0
docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md+358 −0
diff --git a/docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md b/docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md| new file mode 100644 |
| index 000000000..0db071256 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md |
| @@ -0,0 +1,358 @@ |
| +# Fleet architecture diagram — `/fleet/architecture` |
| + |
| +**Status:** approved in conversation 2026-08-19 (Paul). |
| +**Scope:** one new operator page on the console, one new provider-neutral |
| +infrastructure-inventory layer behind it, and the IAM grant that lets a |
| +deployment see its own cloud estate. |
| + |
| +## 1. Why |
| + |
| +The console can already answer every question about the platform's own |
| +model of itself — which boxes report, what they run, what they cost under |
| +the active scenario — and cannot answer a single question about the |
| +machine the model sits on. Nothing in CARLOS knows that a box is an EC2 |
| +instance, that a hostname resolves through an Elastic IP, that six |
| +buckets exist, or that an auto-recover alarm is armed. That knowledge |
| +lives in an OpenTofu checkout in another repo and in the AWS console, |
| +which is exactly where CLAUDE.md says day-to-day operation must NOT go. |
| + |
| +The result is that no page anywhere answers "what is actually stood up, |
| +and how does a request get from DNS to a process?". This spec builds that |
| +page: DNS at the entry, routers, boxes with their size, cost, apps and |
| +instance counts, and every other resource the deployment's cloud account |
| +is carrying — including resources CARLOS itself knows nothing about, |
| +which are precisely the ones worth seeing. |
| + |
| +Four rulings shape it (Paul, 2026-08-19): |
| + |
| +- **Live inventory plus CARLOS overlay.** Not a hand-drawn diagram, not a |
| + picture assembled only from what CARLOS already models. The cloud |
| + estate is discovered, and the platform's own knowledge is layered onto |
| + it. |
| +- **Whichever account the console runs in.** No cross-account roles. The |
| + flagship console draws the flagship estate; tito is a separate deploy |
| + and gets its own version of this page by running the same code, as any |
| + third-party hoster would. |
| +- **Rate card prices the boxes.** The discovered instance shape is shown |
| + beside the price and used to flag drift, never to invent a second cost |
| + model that can disagree with `/fleet/economics`. |
| +- **Do not couple the page to AWS.** AWS is where we are, not what the |
| + page is about. One provider interface, one implementation, no plugin |
| + system built speculatively. |
| + |
| +## 2. Shape of the answer |
| + |
| +``` |
| +GET /fleet/architecture operator-only page: graph + resource cards |
| +POST /fleet/architecture/refresh run the inventory pass once, now |
| + |
| +infra/inventory.json bucket object, written hourly by the pass |
| +internal/infraview provider-neutral inventory types + Provider |
| +internal/awsinv the AWS implementation (only AWS SDK importer) |
| +internal/console/archgraph.go join + layout, emits SVG |
| +``` |
| + |
| +## 3. The provider seam |
| + |
| +`internal/infraview` defines what an inventory IS, in terms no cloud owns: |
| + |
| +```go |
| +type Resource struct { |
| + Provider string // "aws" | "hetzner" | "self-hosted" |
| + Kind string // see the kind vocabulary below |
| + NativeType string // "ec2:instance", "hetzner:server" — display only |
| + ID string // provider-stable identity (instance id, ARN, bucket name) |
| + Name string // human name (Name tag, bucket name, zone name) |
| + Region string // "" for a global resource |
| + Attrs map[string]string // size, state, ip, bytes, … stringly and additive |
| + Tags map[string]string |
| +} |
| + |
| +type Inventory struct { |
| + Provider string |
| + Account string // account id / project id, for display |
| + TakenAt time.Time |
| + Resources []Resource |
| + Partial []string // human-readable list of reads that failed |
| +} |
| + |
| +type Provider interface { |
| + Inventory(ctx context.Context) (Inventory, error) |
| +} |
| +``` |
| + |
| +**Kind vocabulary** — deliberately small and neutral: `machine`, `ip`, |
| +`firewall`, `network`, `disk`, `bucket`, `dns_zone`, `dns_record`, |
| +`identity`, `alarm`, `certificate`, `other`. A provider maps its own |
| +types into these; `NativeType` carries the truth for display. `other` is |
| +not a failure — it is the honest home for anything discovered that the |
| +graph has no opinion about, and it must render, not be dropped. |
| + |
| +**`Attrs` is stringly on purpose.** A typed struct per kind would need a |
| +migration every time a provider exposes a new field, and this object is |
| +written to a bucket that must stay parseable by older readers (the |
| +repo's wire-shape rule). Keys the renderer knows about are documented |
| +where it reads them; keys it does not know are shown verbatim in the |
| +resource card. Attr keys used by the joins and the cards: |
| + |
| +| Kind | Attrs | |
| +|---|---| |
| +| `machine` | `type`, `state`, `az`, `public_ip`, `private_ip`, `launched` | |
| +| `ip` | `address`, `attached_to` | |
| +| `disk` | `size_gb`, `volume_type`, `attached_to` | |
| +| `bucket` | `versioning`, `encryption`, `public_access_block` | |
| +| `dns_zone` | `record_count`, `private` | |
| +| `dns_record` | `zone`, `type`, `value` | |
| +| `firewall` | `ingress`, `egress` (rendered summaries, never raw JSON) | |
| +| `alarm` | `state`, `metric`, `target` | |
| +| `identity` | `kind` (`role`/`user`), `attached_policies` (count) | |
| +| `certificate` | `domain`, `status`, `expires` | |
| + |
| +**`internal/awsinv` is the only package in the tree that imports an AWS |
| +SDK.** `archgraph.go`, the handler and the template consume `infraview` |
| +types and never see an AWS type. That is the whole seam: a second |
| +provider is a new file implementing `Provider`, not a rewrite of the |
| +page. |
| + |
| +**No abstraction beyond that.** No provider registry, no config-driven |
| +selection, no speculative second implementation. The console constructs |
| +`awsinv.New()` directly. The cost of adding a provider later is one |
| +package; the cost of a plugin system now is paid every day it is unused. |
| + |
| +## 4. AWS discovery (`internal/awsinv`) |
| + |
| +Two tiers, both strictly read-only. **No call in this package mutates |
| +anything**, and the IAM grant in §8 contains no write action, so that |
| +property is enforced by the credential and not only by the code. |
| + |
| +**Tier 1 — catch-all.** `resourcegroupstaggingapi:GetResources` per |
| +region returns every taggable resource in the account. This is what makes |
| +"any and all resources" tractable instead of a service-by-service |
| +checklist that silently misses whatever was stood up last week. Regions |
| +come from `ec2:DescribeRegions` (opted-in regions only). Everything it |
| +returns that no typed handler claims becomes a `Kind: "other"` Resource, |
| +grouped by ARN service in the page's "other resources" section. |
| + |
| +**Tier 2 — typed detail** for what the graph draws: |
| + |
| +| Call | Produces | |
| +|---|---| |
| +| `ec2:DescribeInstances` | `machine` (+ type, state, AZ, IPs, tags) | |
| +| `ec2:DescribeVolumes` | `disk` | |
| +| `ec2:DescribeAddresses` | `ip` | |
| +| `ec2:DescribeSecurityGroups` | `firewall` | |
| +| `ec2:DescribeVpcs`, `DescribeSubnets` | `network` | |
| +| `s3:ListBuckets` + `GetBucketVersioning`/`GetBucketEncryption`/`GetPublicAccessBlock`/`GetBucketLocation` | `bucket` | |
| +| `route53:ListHostedZones` + `ListResourceRecordSets` | `dns_zone`, `dns_record` | |
| +| `iam:ListRoles`, `ListUsers`, `ListAttachedRolePolicies` | `identity` | |
| +| `cloudwatch:DescribeAlarms` | `alarm` | |
| +| `acm:ListCertificates` | `certificate` | |
| + |
| +**What is never read and never rendered:** access key material, IAM |
| +policy documents, bucket contents, instance user-data, SSM parameters, |
| +secrets of any kind. Identities appear as name, kind and a count of |
| +attached policies. An operator who wants a policy document goes to AWS; |
| +this page is a map, not a credential viewer. |
| + |
| +**Per-call soft failure.** Each read that fails appends one line to |
| +`Inventory.Partial` and the rest continue. A page that can show eleven |
| +of twelve resource classes shows eleven and says which one is missing — |
| +it never renders "no security groups" because a single call was |
| +throttled. This is `accountfleet.go`'s known-versus-none rule applied to |
| +a dozen reads instead of three. |
| + |
| +**Record-set volume.** `ListResourceRecordSets` is paginated per zone and |
| +a zone can be large. `dns_record` Resources are materialised only for |
| +records that participate in a join (A/AAAA/ALIAS and the apex/wildcard |
| +records); every other record is counted into the zone's `record_count` |
| +and not carried individually. The inventory object stays small enough to |
| +read on every page load. |
| + |
| +## 5. The pass |
| + |
| +`StartInfraPass(ctx)` in `internal/console/infrapass.go`, modelled line |
| +for line on `StartEconomicsPass` (`economicspass.go:38`): run once at |
| +start, then hourly, log and continue on error. Started from |
| +`cmd/console/main.go` beside the economics pass. |
| + |
| +Each tick calls the provider, marshals the `Inventory`, and CAS-writes |
| +`infra/inventory.json` to `s.cfg.Releases` using the same |
| +read-etag-then-put helper `upsertBill` uses. A console with no bucket |
| +handle configured starts no pass. |
| + |
| +**Failure posture is `cepull.go`'s, verbatim and for the same reasons.** |
| +Construction-class failures — AWS config never resolves, no usable |
| +credential in the chain, an `AccessDenied`-shaped error from the first |
| +call — latch the pass off for the process after a single log line |
| +(`isAuthShapedError` is reused, not reimplemented). There is no "the IAM |
| +grant might appear later" retry worth making, and a deployment with no |
| +grant must show "not granted", never a recurring error. Everything else |
| +— throttling, a 5xx, a network blip — logs and the next tick tries |
| +again. |
| + |
| +`POST /fleet/architecture/refresh` runs one pass synchronously (bounded |
| +by a context timeout), then redirects back to the page with a flash. It |
| +is CSRF-protected, operator-gated, and single-flighted so a leaned-on |
| +button cannot stack passes. It deliberately does NOT clear the latch: a |
| +deployment without the grant gets the same honest answer however many |
| +times the button is pressed. |
| + |
| +## 6. The graph (`internal/console/archgraph.go`) |
| + |
| +### 6.1 Nodes and edges |
| + |
| +Typed nodes — `zone`, `record`, `ip`, `router`, `box`, `app`, |
| +`instances`, `bucket`, `alarm`, `identity`, `other` — and typed edges |
| +(`resolves`, `routes`, `runs`, `stores`, `watches`). The builder takes |
| +three inputs and owns every join: |
| + |
| +1. `infraview.Inventory` (the cloud estate), |
| +2. `FleetView` + `HostRows` (`view.go` — boxes, staleness, per-box |
| + `Metrics.RouteVersions` and `RouteCounts`), |
| +3. the registry's routes and the active economics scenario. |
| + |
| +### 6.2 The joins |
| + |
| +- **DNS → machine**: `dns_record` value → `ip.address` → |
| + `machine.public_ip`. This needs no tags and no naming convention, and |
| + it is the join that makes the picture true rather than decorative. |
| +- **machine ↔ CARLOS box**: `machine.Name` (the `Name` tag, which |
| + `infra/modules/region-host/main.tf:188` sets to `var.name`) matched |
| + against the box label, which is the box's hostname |
| + (`internal/ops/ops.go:319`). Case-insensitive, exact. |
| +- **box → apps and instances**: the box's own |
| + `Metrics.RouteVersions` keys are its hostnames; each is looked up in |
| + the registry for app, channel, version and status. Counts come from the |
| + box's `RouteCounts` (`static`/`resident`/`hibernating`, plus |
| + `awake`/`asleep` which are `*int` and render "unknown" when null). |
| +- **box → cost and size**: the economics rate card's `BoxSpec` for that |
| + box label (falling back to the `""` default spec, exactly as |
| + `AllocateBox` does), priced `HourUSD × 730 + EBSGB × EBSGBMonthUSD`. |
| + The discovered `machine.type` and summed `disk.size_gb` are shown |
| + beside it. |
| +- **box → bucket**: every box that reports edges to the deployment bucket |
| + the console itself is configured with (`s.cfg.Releases`) — the one |
| + bucket relationship the console can state as fact. Per-box replication |
| + targets are not drawn: the console cannot read a box's litestream |
| + config, and a guessed edge on this page would be worse than no edge. |
| +- **alarm → machine**: alarm dimensions naming an instance id. |
| + |
| +### 6.3 Unjoined is shown, never hidden |
| + |
| +This is the rule that makes the page worth building: |
| + |
| +- A `machine` no box reports is **drift** — an instance nobody is running |
| + CARLOS on, or a box that has gone dark. Drawn, marked "no box |
| + reporting". |
| +- A box with no `machine` is a **customer or BYO box** (the Pi, a fleet |
| + box on someone else's hardware). Drawn outside the provider estate, and |
| + correct — not an error. This is also why the non-cloud path is |
| + exercised on flagship from day one and cannot rot: it is on the page |
| + today. |
| +- A `dns_record` pointing at an address the estate does not own is drawn |
| + as leaving the diagram, not dropped. |
| +- Anything in `other` is listed with its native type and ARN. |
| + |
| +### 6.4 Layout |
| + |
| +A deterministic layered pass in Go, no JavaScript library, no CDN |
| +(the vendored-icons rule): |
| + |
| +1. **Layer by kind** — `zone`/`record` at layer 0, `router` at 1, `box` |
| + at 2, `app`/`instances` at 3, with `bucket`, `alarm` and `identity` |
| + as satellites anchored to their owner. |
| +2. **Order within a layer** to reduce crossings: one barycentre sweep |
| + down and one up, then a stable sort by name so the same fleet always |
| + produces the same picture. A diagram that reshuffles between refreshes |
| + cannot be read for change. |
| +3. **Fixed node sizes**, orthogonal edges, emitted as inline SVG with a |
| + `viewBox` so it scales without script. |
| + |
| +Legibility as the fleet grows: instances are drawn as one `instances` |
| +node per box carrying a count, never one node per instance. Nodes link |
| +by anchor to detail cards below the SVG, so the page needs no JS to be |
| +useful and no click handler to be complete. |
| + |
| +## 7. The page |
| + |
| +`GET /fleet/architecture`, gated by `handleFleet`'s exact shape |
| +(`server.go:1720`): session → `IsOperator` → bare `http.NotFound` on |
| +every failure including the store error, so the route's existence stays |
| +unprobeable. Operator-only, not member-visible: it names hostnames, |
| +instance ids and the estate's shape. Nav entry beside Status, Metrics and |
| +Economics. |
| + |
| +Body, in order: |
| + |
| +1. **The graph**, full width. |
| +2. **Boxes**, one card each: label, region, instance type, cores/memory, |
| + disk, monthly cost, apps, instance counts, release, load, staleness, |
| + and a **drift chip** when the discovered shape contradicts the rate |
| + card entry (or when no rate card entry exists for a reporting box) — |
| + which makes this page a rate-card drift detector as a side effect. |
| +3. **Resource groups** — compute, network, storage, DNS, identity, |
| + alarms, certificates, other — as tables of `Name / NativeType / |
| + Region / key attrs`. |
| +4. **Footer**: the snapshot's `TakenAt` as an age, the account id, every |
| + line in `Partial`, and the refresh button. |
| + |
| +**Every section soft-fails independently.** A missing inventory renders |
| +the CARLOS half of the page with an "inventory not available" panel, |
| +naming which of the three states applies: never taken, not granted |
| +(latched), or stale. A failed registry read costs the overlay, not the |
| +page. Nothing renders a confident "none" for a read that did not happen. |
| + |
| +## 8. The IAM grant (precondition) |
| + |
| +The inventory needs read-only grants the box role does not have: |
| +`ec2:Describe*`, `tag:GetResources`, `s3:ListAllMyBuckets` and the |
| +per-bucket `Get*Configuration`/`GetBucketLocation` reads, |
| +`route53:List*`, `iam:List*`, `cloudwatch:DescribeAlarms`, |
| +`acm:ListCertificates`. No write action, no `iam:Get*Policy*` (policy |
| +documents are out of scope by §4), no `s3:GetObject`. |
| + |
| +Two places, because the module in this repo is not what provisioned the |
| +flagship: |
| + |
| +1. `infra/modules/region-host/main.tf` — the module's own |
| + `aws_iam_role_policy`, so any future box gets it. |
| +2. A matching pinfra change and apply on the flagship box, which is |
| + Paul's to land. |
| + |
| +Until (2) lands, the page renders the CARLOS half and the panel says the |
| +inventory is not granted. **The page must be built and merged without |
| +depending on the grant existing** — the ungranted state is a first-class |
| +rendering, not a broken one. |
| + |
| +## 9. Testing |
| + |
| +- `internal/infraview`: shape round-trip, and the additive rule — an |
| + inventory object written before a field existed still parses, and an |
| + unknown `Kind` renders as `other` rather than being dropped. |
| +- `internal/awsinv`: the AWS calls sit behind narrow interfaces so the |
| + mapping is tested against fakes — instance/EIP/record fixtures in, |
| + `Resource`s out. One test per typed mapping, one for the catch-all |
| + producing `other`, one asserting a failing call lands in `Partial` and |
| + costs only its own resources. No test touches a real AWS endpoint. |
| +- `archgraph`: table-driven join tests — the DNS→IP→machine chain, the |
| + name↔label match, the unmatched-machine and box-without-machine cases, |
| + and the cost/drift computation against a fixture rate card. Layout is |
| + tested for determinism: the same input twice yields byte-identical SVG. |
| +- Handler: the four-way gate (signed out, signed in non-operator, |
| + store error, operator) with the first three byte-identical to a missing |
| + route — the `economicspage_test.go` pattern. Refresh door: CSRF |
| + required, operator required, single-flight. |
| +- Pass: an auth-shaped failure latches after one attempt; a transient |
| + failure does not. |
| + |
| +## 10. Out of scope |
| + |
| +- Cross-account inventory (tito, or any future estate). Each deploy sees |
| + its own account; that was ruled on. |
| +- A second provider implementation. |
| +- Any write, tag, or remediation action from this page. |
| +- A `carlos` CLI view of the inventory. `infra/inventory.json` is |
| + deliberately a durable bucket object rather than a page-local cache so |
| + that a CLI door is a small follow-up, but it is not this spec. |
| +- Cost Explorer actuals on this page. `/fleet/economics` owns money; |
| + this page owns shape, and shows the modelled per-box figure only. |