carlos / platform Public

plan: fleet architecture diagram — 9 tasks, infraview seam to PR

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 70241ab707376dd4c54cdb777afffc9796592757 parent b07ab09
1 file changed, +2794 −0
  • docs/superpowers/plans/2026-08-19-fleet-architecture-diagram.md +2794 −0
diff --git a/docs/superpowers/plans/2026-08-19-fleet-architecture-diagram.md b/docs/superpowers/plans/2026-08-19-fleet-architecture-diagram.md
new file mode 100644
index 000000000..cbb4b1901
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-19-fleet-architecture-diagram.md
@@ -0,0 +1,2794 @@
+# Fleet Architecture Diagram Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Give operators one page — `/fleet/architecture` — that draws how a request reaches a process (DNS → IP → box → app) over a live, provider-neutral inventory of everything the deployment's cloud account has stood up, with each box's size, cost, apps and instance counts.
+
+**Architecture:** A provider seam (`internal/infraview`) defines what an inventory is in cloud-neutral terms; `internal/awsinv` is the only package that imports an AWS SDK and implements it. An hourly console pass writes `infra/inventory.json` to the deployment bucket. `internal/console/archgraph.go` joins that snapshot with the fleet aggregate, the per-box snapshots and the registry, lays the result out deterministically, and emits inline SVG. The page is operator-gated with `handleFleet`'s exact 404 shape.
+
+**Tech Stack:** Go 1.x, `aws-sdk-go-v2` (ec2, s3, route53, iam, cloudwatch, acm, resourcegroupstaggingapi — the first four already vendored), `html/template`, `net/http` ServeMux, SQLite registry, S3-backed `release.Store`.
+
+**Spec:** `docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md`
+
+## Global Constraints
+
+- **Gate before any commit:** `go build ./...`, `go vet ./...`, `go test ./... -count=1` all clean, every package. `gofmt -l` clean for files you touch (three pre-existing unformatted files elsewhere are left alone).
+- **Sandbox env for go commands:** `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`. A "read-only file system" error from the toolchain means the sandbox needs disabling for that command.
+- **Cross-compile matters:** CI also runs `GOOS=darwin go build ./...`. Nothing in these packages may assume Linux.
+- **Wire shapes are additive.** `infra/inventory.json` is written to a production bucket. Every field is additive, unknown fields must parse, and an unknown `Kind` renders as `other` rather than being dropped.
+- **No AWS mutation, ever.** No call in `internal/awsinv` may create, modify, tag or delete anything. The IAM policy in Task 8 contains no write action.
+- **Never read or render secret material:** no access keys, no IAM policy documents, no bucket contents, no instance user-data, no SSM parameters.
+- **Operator gate shape is fixed:** session → `IsOperator` → bare `http.NotFound` on *every* failure path including the store error (`internal/console/server.go:1720`). Never `renderMessage` for a gate failure — a styled page confirms the route exists.
+- **Every change lands through a PR.** No local merge to `main`, no direct commit to `main`.
+- **Commit at the end of every task**, message body ending with:
+ `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|---|---|
+| `internal/infraview/infraview.go` | `Resource`, `Inventory`, `Provider`, kind constants, `InventoryKey`. No cloud SDK. |
+| `internal/infraview/infraview_test.go` | Round-trip, additive-parse, unknown-kind tests. |
+| `internal/awsinv/awsinv.go` | `Provider` implementation: assembly, region loop, `Partial`, auth classification. |
+| `internal/awsinv/clients.go` | Narrow per-service interfaces + the real client factory. The only AWS SDK imports. |
+| `internal/awsinv/ec2.go` | EC2 → `machine`/`disk`/`ip`/`firewall`/`network` mappings. |
+| `internal/awsinv/rest.go` | S3, Route53, IAM, CloudWatch, ACM mappings + the tagging catch-all. |
+| `internal/awsinv/*_test.go` | Fake clients, one test per mapping, failure-to-`Partial` tests. |
+| `internal/console/infrapass.go` | Hourly pass, CAS write of `infra/inventory.json`, auth latch, refresh single-flight. |
+| `internal/console/infrapass_test.go` | Latch, transient-retry, CAS-write tests. |
+| `internal/console/archgraph.go` | Joins + view model (`ArchView`, `BoxCard`, `ResourceGroup`, `Graph`). |
+| `internal/console/archlayout.go` | Layering, ordering, SVG emission. |
+| `internal/console/archgraph_test.go`, `archlayout_test.go` | Join, cost/drift, determinism tests. |
+| `internal/console/archpage.go` | `handleArchitecture`, `handleArchitectureRefresh`. |
+| `internal/console/archpage_test.go` | Gate, content, refresh-door tests. |
+| `internal/console/templates/architecture.html` | The page. |
+| `internal/console/server.go` | Route registration, `page` fields, `Server` seam field. |
+| `internal/console/templates/fleet.html` | One link to the new page. |
+| `cmd/console/main.go` | Start the pass. |
+| `infra/modules/region-host/main.tf` | Read-only inventory IAM grants. |
+
+---
+
+### Task 1: `internal/infraview` — the provider-neutral inventory
+
+**Files:**
+- Create: `internal/infraview/infraview.go`
+- Test: `internal/infraview/infraview_test.go`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `infraview.Resource`, `infraview.Inventory`, `infraview.Provider`, the `Kind*` constants, `infraview.InventoryKey` (= `"infra/inventory.json"`), `Inventory.ByKind(kind string) []Resource`, `Inventory.Find(kind, attr, value string) *Resource`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package infraview
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+)
+
+// An inventory written before a field existed must still parse, and a kind
+// no reader knows must survive as itself rather than being dropped — the
+// repo's additive wire-shape rule applied to a bucket object.
+func TestInventoryParsesAdditively(t *testing.T) {
+ old := []byte(`{
+ "provider":"aws","account":"1234","taken_at":"2026-08-19T10:00:00Z",
+ "resources":[
+ {"provider":"aws","kind":"machine","native_type":"ec2:instance",
+ "id":"i-123","name":"carlos-eu-west-1","region":"eu-west-1",
+ "attrs":{"type":"t4g.medium","public_ip":"52.0.0.1"}},
+ {"provider":"aws","kind":"quantum_widget","native_type":"qc:widget","id":"q-1"}
+ ],
+ "future_field":"ignored"
+ }`)
+ var inv Inventory
+ if err := json.Unmarshal(old, &inv); err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if inv.Provider != "aws" || inv.Account != "1234" {
+ t.Fatalf("header: %+v", inv)
+ }
+ if !inv.TakenAt.Equal(time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)) {
+ t.Fatalf("taken_at: %v", inv.TakenAt)
+ }
+ if len(inv.Resources) != 2 {
+ t.Fatalf("resources: %d, want 2 (an unknown kind must not be dropped)", len(inv.Resources))
+ }
+ if got := inv.Resources[1].Kind; got != "quantum_widget" {
+ t.Fatalf("unknown kind = %q, want it carried verbatim", got)
+ }
+}
+
+func TestByKindAndFind(t *testing.T) {
+ inv := Inventory{Resources: []Resource{
+ {Kind: KindMachine, ID: "i-1", Attrs: map[string]string{"public_ip": "52.0.0.1"}},
+ {Kind: KindMachine, ID: "i-2", Attrs: map[string]string{"public_ip": "52.0.0.2"}},
+ {Kind: KindBucket, ID: "carlos-data-ie"},
+ }}
+ if got := len(inv.ByKind(KindMachine)); got != 2 {
+ t.Fatalf("ByKind(machine) = %d, want 2", got)
+ }
+ if got := inv.Find(KindMachine, "public_ip", "52.0.0.2"); got == nil || got.ID != "i-2" {
+ t.Fatalf("Find = %+v, want i-2", got)
+ }
+ if got := inv.Find(KindMachine, "public_ip", "10.0.0.9"); got != nil {
+ t.Fatalf("Find(miss) = %+v, want nil", got)
+ }
+}
+
+// A resource with no Attrs map must not panic any accessor — an inventory
+// read from an older writer is full of them.
+func TestFindNilAttrs(t *testing.T) {
+ inv := Inventory{Resources: []Resource{{Kind: KindMachine, ID: "i-1"}}}
+ if got := inv.Find(KindMachine, "public_ip", "52.0.0.1"); got != nil {
+ t.Fatalf("Find over nil Attrs = %+v, want nil", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/infraview/ -count=1`
+Expected: FAIL — the package does not compile (`undefined: Inventory`).
+
+- [ ] **Step 3: Write minimal implementation**
+
+```go
+// Package infraview is what an infrastructure inventory IS, in terms no
+// cloud provider owns. It is deliberately free of every SDK: the AWS
+// implementation (internal/awsinv) imports this package, never the other
+// way round, so the console page that renders an inventory can be pointed
+// at a second provider by writing one file rather than by rewriting the
+// page.
+//
+// The wire shape here is written to a production bucket
+// (InventoryKey), so it obeys the repo's additive rule: every field is
+// optional, unknown fields parse, and an unknown Kind is carried through
+// as itself. A reader that drops what it does not recognise would make the
+// page LESS useful exactly where it is most useful — the resource nobody
+// wrote code for is the one worth seeing.
+package infraview
+
+import (
+ "context"
+ "time"
+)
+
+// InventoryKey is where a deployment's inventory snapshot lives in its own
+// bucket. One object, overwritten in place: this is a current-state
+// snapshot, not a history, and a history would need a retention policy
+// nobody has asked for.
+const InventoryKey = "infra/inventory.json"
+
+// The kind vocabulary. Small and neutral on purpose — a provider maps its
+// own types into these and carries the truth in NativeType, so the page's
+// layout rules ("machines sit in the box layer") never have to learn a new
+// cloud's nouns. KindOther is not a failure: it is the honest home for a
+// discovered resource this vocabulary has no word for.
+const (
+ KindMachine = "machine"
+ KindIP = "ip"
+ KindDisk = "disk"
+ KindFirewall = "firewall"
+ KindNetwork = "network"
+ KindBucket = "bucket"
+ KindDNSZone = "dns_zone"
+ KindDNSRecord = "dns_record"
+ KindIdentity = "identity"
+ KindAlarm = "alarm"
+ KindCertificate = "certificate"
+ KindOther = "other"
+)
+
+// Resource is one thing a provider is carrying. 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 must stay parseable by
+// readers written before that field existed. Keys the console renders are
+// documented where it reads them; keys it does not know are shown verbatim.
+type Resource struct {
+ Provider string `json:"provider"`
+ Kind string `json:"kind"`
+ NativeType string `json:"native_type,omitempty"`
+ ID string `json:"id"`
+ Name string `json:"name,omitempty"`
+ Region string `json:"region,omitempty"`
+ Attrs map[string]string `json:"attrs,omitempty"`
+ Tags map[string]string `json:"tags,omitempty"`
+}
+
+// Attr reads one attribute without the caller having to nil-check the map.
+func (r Resource) Attr(key string) string {
+ if r.Attrs == nil {
+ return ""
+ }
+ return r.Attrs[key]
+}
+
+// Inventory is one provider's estate at one moment. Partial names the reads
+// that FAILED, in words a person can act on ("security groups in eu-west-1:
+// throttled"). It exists because "no security groups" and "the security
+// group read failed" are different sentences and only one of them may be
+// rendered as a fact.
+type Inventory struct {
+ Provider string `json:"provider"`
+ Account string `json:"account,omitempty"`
+ TakenAt time.Time `json:"taken_at"`
+ Resources []Resource `json:"resources"`
+ Partial []string `json:"partial,omitempty"`
+}
+
+// ByKind returns every resource of one kind, in inventory order.
+func (i Inventory) ByKind(kind string) []Resource {
+ var out []Resource
+ for _, r := range i.Resources {
+ if r.Kind == kind {
+ out = append(out, r)
+ }
+ }
+ return out
+}
+
+// Find returns the first resource of kind whose attr equals value, or nil.
+// value == "" never matches: an empty attribute is absence, and matching on
+// it would join every machine with no public IP to every record with no
+// value.
+func (i Inventory) Find(kind, attr, value string) *Resource {
+ if value == "" {
+ return nil
+ }
+ for idx := range i.Resources {
+ if i.Resources[idx].Kind == kind && i.Resources[idx].Attr(attr) == value {
+ return &i.Resources[idx]
+ }
+ }
+ return nil
+}
+
+// Provider is one cloud (or one bare-metal estate) that can describe
+// itself. Implementations return construction-class failures as an error —
+// no credential, no reachable API — and everything else through
+// Inventory.Partial, because a page that can show eleven resource classes
+// must show eleven.
+type Provider interface {
+ Inventory(ctx context.Context) (Inventory, error)
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/infraview/ -count=1`
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: Gate and commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
+gofmt -l internal/infraview
+git add internal/infraview
+git commit -m "infraview: provider-neutral infrastructure inventory shape
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: `internal/awsinv` — client seam and EC2 mappings
+
+**Files:**
+- Create: `internal/awsinv/clients.go`, `internal/awsinv/ec2.go`
+- Test: `internal/awsinv/ec2_test.go`
+- Modify: `go.mod` (add `service/ec2`, `service/cloudwatch`, `service/acm`, `service/resourcegroupstaggingapi`)
+
+**Interfaces:**
+- Consumes: `infraview.Resource`, the `Kind*` constants.
+- Produces: `awsinv.Clients` struct, `awsinv.Factory` func type, `awsinv.ec2Resources(ctx, EC2API, region string) ([]infraview.Resource, []string)` returning resources and failure lines.
+
+- [ ] **Step 1: Add the SDK dependencies**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod go get \
+ github.com/aws/aws-sdk-go-v2/service/ec2 \
+ github.com/aws/aws-sdk-go-v2/service/cloudwatch \
+ github.com/aws/aws-sdk-go-v2/service/acm \
+ github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi
+```
+
+(`GOPROXY=off` must NOT be set for this one step; it is the only step in the plan that fetches.)
+
+- [ ] **Step 2: Write the failing test**
+
+```go
+package awsinv
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/ec2"
+ ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
+ "github.com/carlosframework/platform/internal/infraview"
+)
+
+// fakeEC2 answers whatever the test hands it; a non-nil err field for a
+// call makes that ONE call fail, which is how the partial-failure rule
+// below is proven.
+type fakeEC2 struct {
+ instances *ec2.DescribeInstancesOutput
+ volumes *ec2.DescribeVolumesOutput
+ addresses *ec2.DescribeAddressesOutput
+ groups *ec2.DescribeSecurityGroupsOutput
+ vpcs *ec2.DescribeVpcsOutput
+ subnets *ec2.DescribeSubnetsOutput
+ regions *ec2.DescribeRegionsOutput
+ errVols error
+}
+
+func (f *fakeEC2) DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) {
+ return f.instances, nil
+}
+func (f *fakeEC2) DescribeVolumes(context.Context, *ec2.DescribeVolumesInput, ...func(*ec2.Options)) (*ec2.DescribeVolumesOutput, error) {
+ if f.errVols != nil {
+ return nil, f.errVols
+ }
+ return f.volumes, nil
+}
+func (f *fakeEC2) DescribeAddresses(context.Context, *ec2.DescribeAddressesInput, ...func(*ec2.Options)) (*ec2.DescribeAddressesOutput, error) {
+ return f.addresses, nil
+}
+func (f *fakeEC2) DescribeSecurityGroups(context.Context, *ec2.DescribeSecurityGroupsInput, ...func(*ec2.Options)) (*ec2.DescribeSecurityGroupsOutput, error) {
+ return f.groups, nil
+}
+func (f *fakeEC2) DescribeVpcs(context.Context, *ec2.DescribeVpcsInput, ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error) {
+ return f.vpcs, nil
+}
+func (f *fakeEC2) DescribeSubnets(context.Context, *ec2.DescribeSubnetsInput, ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error) {
+ return f.subnets, nil
+}
+func (f *fakeEC2) DescribeRegions(context.Context, *ec2.DescribeRegionsInput, ...func(*ec2.Options)) (*ec2.DescribeRegionsOutput, error) {
+ return f.regions, nil
+}
+
+func find(rs []infraview.Resource, kind, id string) *infraview.Resource {
+ for i := range rs {
+ if rs[i].Kind == kind && rs[i].ID == id {
+ return &rs[i]
+ }
+ }
+ return nil
+}
+
+func TestEC2InstanceMapping(t *testing.T) {
+ f := &fakeEC2{instances: &ec2.DescribeInstancesOutput{
+ Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{
+ InstanceId: aws.String("i-123"),
+ InstanceType: ec2types.InstanceTypeT4gMedium,
+ State: &ec2types.InstanceState{Name: ec2types.InstanceStateNameRunning},
+ Placement: &ec2types.Placement{AvailabilityZone: aws.String("eu-west-1a")},
+ PublicIpAddress: aws.String("52.0.0.1"),
+ PrivateIpAddress: aws.String("10.0.0.5"),
+ Tags: []ec2types.Tag{{Key: aws.String("Name"), Value: aws.String("carlos-eu-west-1")}},
+ }}}},
+ }}
+
+ rs, partial := ec2Resources(context.Background(), f, "eu-west-1")
+ if len(partial) != 0 {
+ t.Fatalf("partial = %v, want none", partial)
+ }
+ m := find(rs, infraview.KindMachine, "i-123")
+ if m == nil {
+ t.Fatalf("no machine in %+v", rs)
+ }
+ if m.Name != "carlos-eu-west-1" {
+ t.Errorf("Name = %q, want the Name tag", m.Name)
+ }
+ if m.Region != "eu-west-1" || m.NativeType != "ec2:instance" {
+ t.Errorf("region/type = %q/%q", m.Region, m.NativeType)
+ }
+ for k, want := range map[string]string{
+ "type": "t4g.medium", "state": "running", "az": "eu-west-1a",
+ "public_ip": "52.0.0.1", "private_ip": "10.0.0.5",
+ } {
+ if got := m.Attr(k); got != want {
+ t.Errorf("attr %s = %q, want %q", k, got, want)
+ }
+ }
+}
+
+// One failing call costs its own resources and nothing else — the rule the
+// whole inventory depends on, since a page that shows eleven of twelve
+// classes is worth far more than one that shows none.
+func TestEC2PartialFailureCostsOnlyItsOwnCall(t *testing.T) {
+ f := &fakeEC2{
+ instances: &ec2.DescribeInstancesOutput{Reservations: []ec2types.Reservation{{
+ Instances: []ec2types.Instance{{InstanceId: aws.String("i-123")}},
+ }}},
+ errVols: errors.New("Throttling: rate exceeded"),
+ }
+ rs, partial := ec2Resources(context.Background(), f, "eu-west-1")
+ if find(rs, infraview.KindMachine, "i-123") == nil {
+ t.Fatalf("a failed volume read must not cost the instances")
+ }
+ if len(partial) != 1 {
+ t.Fatalf("partial = %v, want exactly the volumes line", partial)
+ }
+ if !contains(partial[0], "volumes") || !contains(partial[0], "eu-west-1") {
+ t.Errorf("partial line %q must name what failed and where", partial[0])
+ }
+}
+
+func contains(s, sub string) bool { return len(s) >= len(sub) && (stringIndex(s, sub) >= 0) }
+func stringIndex(s, sub string) int {
+ for i := 0; i+len(sub) <= len(s); i++ {
+ if s[i:i+len(sub)] == sub {
+ return i
+ }
+ }
+ return -1
+}
+```
+
+- [ ] **Step 3: Run test to verify it fails**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/awsinv/ -count=1`
+Expected: FAIL — `undefined: ec2Resources`.
+
+- [ ] **Step 4: Write `clients.go`**
+
+```go
+// Package awsinv discovers what an AWS account is carrying and reports it
+// as an internal/infraview.Inventory. It is the ONLY package in this repo
+// that imports an AWS SDK for inventory purposes, which is the whole point
+// of the seam: the console page that renders an estate never sees an AWS
+// type, so a second provider is a new package rather than a rewrite.
+//
+// Every call here is read-only. The IAM policy this package needs
+// (infra/modules/region-host/main.tf) contains no write action, so that
+// property is enforced by the credential and not by this comment.
+package awsinv
+
+import (
+ "context"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/acm"
+ "github.com/aws/aws-sdk-go-v2/service/cloudwatch"
+ "github.com/aws/aws-sdk-go-v2/service/ec2"
+ "github.com/aws/aws-sdk-go-v2/service/iam"
+ "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi"
+ "github.com/aws/aws-sdk-go-v2/service/route53"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+)
+
+// The narrow interfaces exist so the mappings can be tested against fakes
+// with no network and no credential. Each lists exactly the calls this
+// package makes and nothing else — an interface that named the whole
+// client would be an invitation to reach for a write.
+type EC2API interface {
+ DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error)
+ DescribeVolumes(context.Context, *ec2.DescribeVolumesInput, ...func(*ec2.Options)) (*ec2.DescribeVolumesOutput, error)
+ DescribeAddresses(context.Context, *ec2.DescribeAddressesInput, ...func(*ec2.Options)) (*ec2.DescribeAddressesOutput, error)
+ DescribeSecurityGroups(context.Context, *ec2.DescribeSecurityGroupsInput, ...func(*ec2.Options)) (*ec2.DescribeSecurityGroupsOutput, error)
+ DescribeVpcs(context.Context, *ec2.DescribeVpcsInput, ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error)
+ DescribeSubnets(context.Context, *ec2.DescribeSubnetsInput, ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error)
+ DescribeRegions(context.Context, *ec2.DescribeRegionsInput, ...func(*ec2.Options)) (*ec2.DescribeRegionsOutput, error)
+}
+
+type S3API interface {
+ ListBuckets(context.Context, *s3.ListBucketsInput, ...func(*s3.Options)) (*s3.ListBucketsOutput, error)
+ GetBucketLocation(context.Context, *s3.GetBucketLocationInput, ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error)
+ GetBucketVersioning(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error)
+ GetBucketEncryption(context.Context, *s3.GetBucketEncryptionInput, ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error)
+ GetPublicAccessBlock(context.Context, *s3.GetPublicAccessBlockInput, ...func(*s3.Options)) (*s3.GetPublicAccessBlockOutput, error)
+}
+
+type Route53API interface {
+ ListHostedZones(context.Context, *route53.ListHostedZonesInput, ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error)
+ ListResourceRecordSets(context.Context, *route53.ListResourceRecordSetsInput, ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error)
+}
+
+type IAMAPI interface {
+ ListRoles(context.Context, *iam.ListRolesInput, ...func(*iam.Options)) (*iam.ListRolesOutput, error)
+ ListUsers(context.Context, *iam.ListUsersInput, ...func(*iam.Options)) (*iam.ListUsersOutput, error)
+ ListAttachedRolePolicies(context.Context, *iam.ListAttachedRolePoliciesInput, ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error)
+}
+
+type CloudWatchAPI interface {
+ DescribeAlarms(context.Context, *cloudwatch.DescribeAlarmsInput, ...func(*cloudwatch.Options)) (*cloudwatch.DescribeAlarmsOutput, error)
+}
+
+type ACMAPI interface {
+ ListCertificates(context.Context, *acm.ListCertificatesInput, ...func(*acm.Options)) (*acm.ListCertificatesOutput, error)
+}
+
+type TaggingAPI interface {
+ GetResources(context.Context, *resourcegroupstaggingapi.GetResourcesInput, ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error)
+}
+
+// Clients is one region's worth of read-only clients. S3/Route53/IAM are
+// global services; the assembly calls them once, from the home region's
+// Clients, rather than once per region.
+type Clients struct {
+ EC2 EC2API
+ S3 S3API
+ Route53 Route53API
+ IAM IAMAPI
+ CloudWatch CloudWatchAPI
+ ACM ACMAPI
+ Tagging TaggingAPI
+}
+
+// Factory builds the clients for one region. Tests inject a fake; the real
+// one below rebases a single resolved aws.Config onto each region so the
+// credential chain resolves exactly once.
+type Factory func(region string) Clients
+
+func factoryFor(cfg aws.Config) Factory {
+ return func(region string) Clients {
+ c := cfg.Copy()
+ if region != "" {
+ c.Region = region
+ }
+ return Clients{
+ EC2: ec2.NewFromConfig(c),
+ S3: s3.NewFromConfig(c),
+ Route53: route53.NewFromConfig(c),
+ IAM: iam.NewFromConfig(c),
+ CloudWatch: cloudwatch.NewFromConfig(c),
+ ACM: acm.NewFromConfig(c),
+ Tagging: resourcegroupstaggingapi.NewFromConfig(c),
+ }
+ }
+}
+```
+
+- [ ] **Step 5: Write `ec2.go`**
+
+```go
+package awsinv
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/ec2"
+ ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
+ "github.com/carlosframework/platform/internal/infraview"
+)
+
+// res builds one Resource with the fields every mapping sets the same way,
+// so a new mapping cannot forget the provider stamp.
+func res(kind, nativeType, id, name, region string) infraview.Resource {
+ return infraview.Resource{
+ Provider: "aws", Kind: kind, NativeType: nativeType,
+ ID: id, Name: name, Region: region, Attrs: map[string]string{},
+ }
+}
+
+// set writes an attribute only when it has a value: an absent key and an
+// empty string are different claims, and the joins treat "" as absence.
+func set(r *infraview.Resource, key, value string) {
+ if value != "" {
+ r.Attrs[key] = value
+ }
+}
+
+func tagsOf(tags []ec2types.Tag) map[string]string {
+ if len(tags) == 0 {
+ return nil
+ }
+ out := map[string]string{}
+ for _, t := range tags {
+ out[aws.ToString(t.Key)] = aws.ToString(t.Value)
+ }
+ return out
+}
+
+// ec2Resources maps one region's EC2 surface. Each call contributes its own
+// resources and, on failure, one line to partial — never an error: this
+// function's whole contract is that a throttled volumes call costs the
+// volumes and nothing else.
+func ec2Resources(ctx context.Context, api EC2API, region string) (out []infraview.Resource, partial []string) {
+ fail := func(what string, err error) {
+ partial = append(partial, fmt.Sprintf("%s in %s: %v", what, region, err))
+ }
+
+ if o, err := api.DescribeInstances(ctx, &ec2.DescribeInstancesInput{}); err != nil {
+ fail("instances", err)
+ } else {
+ for _, rv := range o.Reservations {
+ for _, in := range rv.Instances {
+ tags := tagsOf(in.Tags)
+ r := res(infraview.KindMachine, "ec2:instance", aws.ToString(in.InstanceId), tags["Name"], region)
+ r.Tags = tags
+ set(&r, "type", string(in.InstanceType))
+ if in.State != nil {
+ set(&r, "state", string(in.State.Name))
+ }
+ if in.Placement != nil {
+ set(&r, "az", aws.ToString(in.Placement.AvailabilityZone))
+ }
+ set(&r, "public_ip", aws.ToString(in.PublicIpAddress))
+ set(&r, "private_ip", aws.ToString(in.PrivateIpAddress))
+ if in.LaunchTime != nil {
+ set(&r, "launched", in.LaunchTime.UTC().Format("2006-01-02"))
+ }
+ out = append(out, r)
+ }
+ }
+ }
+
+ if o, err := api.DescribeVolumes(ctx, &ec2.DescribeVolumesInput{}); err != nil {
+ fail("volumes", err)
+ } else {
+ for _, v := range o.Volumes {
+ r := res(infraview.KindDisk, "ec2:volume", aws.ToString(v.VolumeId), tagsOf(v.Tags)["Name"], region)
+ r.Tags = tagsOf(v.Tags)
+ if v.Size != nil {
+ set(&r, "size_gb", strconv.Itoa(int(*v.Size)))
+ }
+ set(&r, "volume_type", string(v.VolumeType))
+ for _, a := range v.Attachments {
+ set(&r, "attached_to", aws.ToString(a.InstanceId))
+ }
+ out = append(out, r)
+ }
+ }
+
+ if o, err := api.DescribeAddresses(ctx, &ec2.DescribeAddressesInput{}); err != nil {
+ fail("elastic IPs", err)
+ } else {
+ for _, a := range o.Addresses {
+ r := res(infraview.KindIP, "ec2:eip", aws.ToString(a.AllocationId), tagsOf(a.Tags)["Name"], region)
+ r.Tags = tagsOf(a.Tags)
+ set(&r, "address", aws.ToString(a.PublicIp))
+ set(&r, "attached_to", aws.ToString(a.InstanceId))
+ out = append(out, r)
+ }
+ }
+
+ if o, err := api.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{}); err != nil {
+ fail("security groups", err)
+ } else {
+ for _, g := range o.SecurityGroups {
+ r := res(infraview.KindFirewall, "ec2:security-group", aws.ToString(g.GroupId), aws.ToString(g.GroupName), region)
+ r.Tags = tagsOf(g.Tags)
+ set(&r, "ingress", summarizePerms(g.IpPermissions))
+ set(&r, "egress", summarizePerms(g.IpPermissionsEgress))
+ out = append(out, r)
+ }
+ }
+
+ if o, err := api.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{}); err != nil {
+ fail("VPCs", err)
+ } else {
+ for _, v := range o.Vpcs {
+ r := res(infraview.KindNetwork, "ec2:vpc", aws.ToString(v.VpcId), tagsOf(v.Tags)["Name"], region)
+ r.Tags = tagsOf(v.Tags)
+ set(&r, "cidr", aws.ToString(v.CidrBlock))
+ out = append(out, r)
+ }
+ }
+
+ if o, err := api.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{}); err != nil {
+ fail("subnets", err)
+ } else {
+ for _, s := range o.Subnets {
+ r := res(infraview.KindNetwork, "ec2:subnet", aws.ToString(s.SubnetId), tagsOf(s.Tags)["Name"], region)
+ r.Tags = tagsOf(s.Tags)
+ set(&r, "cidr", aws.ToString(s.CidrBlock))
+ set(&r, "az", aws.ToString(s.AvailabilityZone))
+ set(&r, "vpc", aws.ToString(s.VpcId))
+ out = append(out, r)
+ }
+ }
+ return out, partial
+}
+
+// summarizePerms renders a rule set as "tcp/22 from 10.0.0.0/8 · tcp/443
+// from 0.0.0.0/0" — a sentence an operator reads, never the raw JSON, which
+// would be both unreadable and a wall of detail the page does not want.
+func summarizePerms(perms []ec2types.IpPermission) string {
+ out := ""
+ for _, p := range perms {
+ proto := aws.ToString(p.IpProtocol)
+ if proto == "-1" {
+ proto = "all"
+ }
+ port := "all"
+ if p.FromPort != nil {
+ port = strconv.Itoa(int(*p.FromPort))
+ if p.ToPort != nil && *p.ToPort != *p.FromPort {
+ port += "-" + strconv.Itoa(int(*p.ToPort))
+ }
+ }
+ for _, rng := range p.IpRanges {
+ if out != "" {
+ out += " · "
+ }
+ out += fmt.Sprintf("%s/%s from %s", proto, port, aws.ToString(rng.CidrIp))
+ }
+ }
+ return out
+}
+```
+
+- [ ] **Step 6: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/awsinv/ -count=1 -v`
+Expected: PASS — `TestEC2InstanceMapping`, `TestEC2PartialFailureCostsOnlyItsOwnCall`.
+
+- [ ] **Step 7: Gate and commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
+gofmt -l internal/awsinv
+git add go.mod go.sum internal/awsinv
+git commit -m "awsinv: read-only client seam and EC2 mappings
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: `internal/awsinv` — remaining services, catch-all, assembly
+
+**Files:**
+- Create: `internal/awsinv/rest.go`, `internal/awsinv/awsinv.go`
+- Test: `internal/awsinv/rest_test.go`, `internal/awsinv/awsinv_test.go`
+
+**Interfaces:**
+- Consumes: `Clients`, `Factory`, `ec2Resources` (Task 2), `infraview.Inventory`.
+- Produces: `awsinv.New(ctx context.Context) (*Provider, error)`, `(*Provider).Inventory(ctx) (infraview.Inventory, error)`, and for tests `newWithFactory(f Factory, home string, regions []string, account string) *Provider`.
+
+- [ ] **Step 1: Write the failing assembly test**
+
+```go
+package awsinv
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/ec2"
+ ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
+ "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi"
+ rgtypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types"
+ "github.com/carlosframework/platform/internal/infraview"
+)
+
+type fakeTagging struct {
+ out *resourcegroupstaggingapi.GetResourcesOutput
+ err error
+}
+
+func (f *fakeTagging) GetResources(context.Context, *resourcegroupstaggingapi.GetResourcesInput, ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error) {
+ return f.out, f.err
+}
+
+// A resource the typed mappings already produced must NOT appear twice
+// because the catch-all also saw it; anything the catch-all alone knows
+// about must appear as "other" rather than vanishing.
+func TestCatchAllDedupesAgainstTypedResources(t *testing.T) {
+ factory := func(region string) Clients {
+ return Clients{
+ EC2: &fakeEC2{instances: &ec2.DescribeInstancesOutput{
+ Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{
+ InstanceId: aws.String("i-123"),
+ }}}},
+ }},
+ Tagging: &fakeTagging{out: &resourcegroupstaggingapi.GetResourcesOutput{
+ ResourceTagMappingList: []rgtypes.ResourceTagMapping{
+ {ResourceARN: aws.String("arn:aws:ec2:eu-west-1:1234:instance/i-123")},
+ {ResourceARN: aws.String("arn:aws:sqs:eu-west-1:1234:some-queue")},
+ },
+ }},
+ }
+ }
+ p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "1234")
+
+ inv, err := p.Inventory(context.Background())
+ if err != nil {
+ t.Fatalf("Inventory: %v", err)
+ }
+ machines := inv.ByKind(infraview.KindMachine)
+ if len(machines) != 1 {
+ t.Fatalf("machines = %d, want 1 (the catch-all must not duplicate a typed resource)", len(machines))
+ }
+ others := inv.ByKind(infraview.KindOther)
+ if len(others) != 1 || others[0].NativeType != "sqs" {
+ t.Fatalf("others = %+v, want one sqs resource", others)
+ }
+ if inv.Provider != "aws" || inv.Account != "1234" || inv.TakenAt.IsZero() {
+ t.Fatalf("header not stamped: %+v", inv)
+ }
+}
+
+// Auth-shaped failure with nothing discovered is the one case that must
+// reach the caller as an error — it is what latches the console's pass off
+// so an ungranted deployment says "not granted" instead of erroring hourly.
+func TestAuthShapedWithNoResourcesIsAnError(t *testing.T) {
+ authErr := &fakeAPIErr{code: "AccessDenied"}
+ factory := func(region string) Clients {
+ return Clients{
+ EC2: &fakeEC2Failing{err: authErr},
+ Tagging: &fakeTagging{err: authErr},
+ }
+ }
+ p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "")
+ if _, err := p.Inventory(context.Background()); err == nil {
+ t.Fatal("want an error when every read is denied and nothing was discovered")
+ }
+}
+
+// The same denial, but something WAS discovered: the inventory is returned
+// with the failures named in Partial, never thrown away.
+func TestPartialSurvivesWhenSomethingWasDiscovered(t *testing.T) {
+ factory := func(region string) Clients {
+ return Clients{
+ EC2: &fakeEC2{instances: &ec2.DescribeInstancesOutput{
+ Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{
+ InstanceId: aws.String("i-123"),
+ }}}},
+ errVols: errors.New("AccessDenied: not authorized"),
+ }},
+ Tagging: &fakeTagging{err: errors.New("AccessDenied: not authorized")},
+ }
+ }
+ p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "")
+ inv, err := p.Inventory(context.Background())
+ if err != nil {
+ t.Fatalf("Inventory: %v", err)
+ }
+ if len(inv.Partial) == 0 {
+ t.Fatal("failures must be named in Partial")
+ }
+}
+```
+
+Add the two small fakes this test needs to `rest_test.go`:
+
+```go
+type fakeAPIErr struct{ code string }
+
+func (e *fakeAPIErr) Error() string { return e.code }
+func (e *fakeAPIErr) ErrorCode() string { return e.code }
+func (e *fakeAPIErr) ErrorMessage() string { return e.code }
+func (e *fakeAPIErr) ErrorFault() smithy.ErrorFault { return smithy.FaultClient }
+
+// fakeEC2Failing fails every call with the same error.
+type fakeEC2Failing struct{ err error }
+
+func (f *fakeEC2Failing) DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) {
+ return nil, f.err
+}
+// … the remaining six EC2API methods, each `return nil, f.err`
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/awsinv/ -count=1`
+Expected: FAIL — `undefined: newWithFactory`.
+
+- [ ] **Step 3: Write `rest.go`**
+
+Mappings, each following `ec2Resources`' contract exactly (own resources, own `partial` line, never an error):
+
+```go
+package awsinv
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/acm"
+ "github.com/aws/aws-sdk-go-v2/service/cloudwatch"
+ "github.com/aws/aws-sdk-go-v2/service/iam"
+ "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi"
+ "github.com/aws/aws-sdk-go-v2/service/route53"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/carlosframework/platform/internal/infraview"
+)
+
+// joinedRecordTypes are the record types that can participate in the
+// DNS -> IP -> machine join, and so the only ones materialised as their own
+// Resource. Everything else is counted into the zone's record_count: a zone
+// can hold thousands of records and this object is read on every page load.
+var joinedRecordTypes = map[string]bool{"A": true, "AAAA": true}
+
+func s3Resources(ctx context.Context, api S3API) (out []infraview.Resource, partial []string) {
+ o, err := api.ListBuckets(ctx, &s3.ListBucketsInput{})
+ if err != nil {
+ return nil, []string{fmt.Sprintf("buckets: %v", err)}
+ }
+ for _, b := range o.Buckets {
+ name := aws.ToString(b.Name)
+ r := res(infraview.KindBucket, "s3:bucket", name, name, "")
+ if loc, err := api.GetBucketLocation(ctx, &s3.GetBucketLocationInput{Bucket: b.Name}); err == nil {
+ region := string(loc.LocationConstraint)
+ if region == "" {
+ region = "us-east-1" // the API's documented empty-means-us-east-1
+ }
+ r.Region = region
+ }
+ // Each of the three posture reads is best-effort per bucket: a
+ // bucket whose versioning cannot be read still appears, with the
+ // attribute absent rather than a false "off". "off" is a claim.
+ if v, err := api.GetBucketVersioning(ctx, &s3.GetBucketVersioningInput{Bucket: b.Name}); err == nil {
+ set(&r, "versioning", strings.ToLower(string(v.Status)))
+ }
+ if _, err := api.GetBucketEncryption(ctx, &s3.GetBucketEncryptionInput{Bucket: b.Name}); err == nil {
+ set(&r, "encryption", "on")
+ }
+ if p, err := api.GetPublicAccessBlock(ctx, &s3.GetPublicAccessBlockInput{Bucket: b.Name}); err == nil && p.PublicAccessBlockConfiguration != nil {
+ c := p.PublicAccessBlockConfiguration
+ if aws.ToBool(c.BlockPublicAcls) && aws.ToBool(c.BlockPublicPolicy) {
+ set(&r, "public_access_block", "on")
+ } else {
+ set(&r, "public_access_block", "partial")
+ }
+ }
+ out = append(out, r)
+ }
+ return out, partial
+}
+
+func route53Resources(ctx context.Context, api Route53API) (out []infraview.Resource, partial []string) {
+ zones, err := api.ListHostedZones(ctx, &route53.ListHostedZonesInput{})
+ if err != nil {
+ return nil, []string{fmt.Sprintf("hosted zones: %v", err)}
+ }
+ for _, z := range zones.HostedZones {
+ id := aws.ToString(z.Id)
+ name := strings.TrimSuffix(aws.ToString(z.Name), ".")
+ zr := res(infraview.KindDNSZone, "route53:hosted-zone", id, name, "")
+ if z.Config != nil && z.Config.PrivateZone {
+ set(&zr, "private", "yes")
+ }
+ count := 0
+ var next *string
+ for {
+ recs, err := api.ListResourceRecordSets(ctx, &route53.ListResourceRecordSetsInput{
+ HostedZoneId: z.Id, StartRecordName: next,
+ })
+ if err != nil {
+ partial = append(partial, fmt.Sprintf("records in %s: %v", name, err))
+ break
+ }
+ for _, rec := range recs.ResourceRecordSets {
+ count++
+ rtype := string(rec.Type)
+ if !joinedRecordTypes[rtype] {
+ continue
+ }
+ rname := strings.TrimSuffix(aws.ToString(rec.Name), ".")
+ for _, v := range rec.ResourceRecords {
+ rr := res(infraview.KindDNSRecord, "route53:record", rname+"/"+rtype+"/"+aws.ToString(v.Value), rname, "")
+ set(&rr, "zone", name)
+ set(&rr, "type", rtype)
+ set(&rr, "value", aws.ToString(v.Value))
+ out = append(out, rr)
+ }
+ if rec.AliasTarget != nil {
+ rr := res(infraview.KindDNSRecord, "route53:alias", rname+"/ALIAS", rname, "")
+ set(&rr, "zone", name)
+ set(&rr, "type", "ALIAS")
+ set(&rr, "value", strings.TrimSuffix(aws.ToString(rec.AliasTarget.DNSName), "."))
+ out = append(out, rr)
+ }
+ }
+ if !recs.IsTruncated {
+ break
+ }
+ next = recs.NextRecordName
+ }
+ set(&zr, "record_count", strconv.Itoa(count))
+ out = append(out, zr)
+ }
+ return out, partial
+}
+
+// iamResources reports identities as NAMES and a count of attached
+// policies. Policy documents are deliberately never read: this page is a
+// map, not a credential viewer (spec §4).
+func iamResources(ctx context.Context, api IAMAPI) (out []infraview.Resource, partial []string) {
+ if o, err := api.ListRoles(ctx, &iam.ListRolesInput{}); err != nil {
+ partial = append(partial, fmt.Sprintf("IAM roles: %v", err))
+ } else {
+ for _, role := range o.Roles {
+ name := aws.ToString(role.RoleName)
+ r := res(infraview.KindIdentity, "iam:role", aws.ToString(role.Arn), name, "")
+ set(&r, "kind", "role")
+ if pol, err := api.ListAttachedRolePolicies(ctx, &iam.ListAttachedRolePoliciesInput{RoleName: role.RoleName}); err == nil {
+ set(&r, "attached_policies", strconv.Itoa(len(pol.AttachedPolicies)))
+ }
+ out = append(out, r)
+ }
+ }
+ if o, err := api.ListUsers(ctx, &iam.ListUsersInput{}); err != nil {
+ partial = append(partial, fmt.Sprintf("IAM users: %v", err))
+ } else {
+ for _, u := range o.Users {
+ r := res(infraview.KindIdentity, "iam:user", aws.ToString(u.Arn), aws.ToString(u.UserName), "")
+ set(&r, "kind", "user")
+ out = append(out, r)
+ }
+ }
+ return out, partial
+}
+
+func alarmResources(ctx context.Context, api CloudWatchAPI, region string) (out []infraview.Resource, partial []string) {
+ o, err := api.DescribeAlarms(ctx, &cloudwatch.DescribeAlarmsInput{})
+ if err != nil {
+ return nil, []string{fmt.Sprintf("alarms in %s: %v", region, err)}
+ }
+ for _, a := range o.MetricAlarms {
+ r := res(infraview.KindAlarm, "cloudwatch:alarm", aws.ToString(a.AlarmArn), aws.ToString(a.AlarmName), region)
+ set(&r, "state", string(a.StateValue))
+ set(&r, "metric", aws.ToString(a.MetricName))
+ for _, d := range a.Dimensions {
+ if aws.ToString(d.Name) == "InstanceId" {
+ set(&r, "target", aws.ToString(d.Value))
+ }
+ }
+ out = append(out, r)
+ }
+ return out, partial
+}
+
+func certResources(ctx context.Context, api ACMAPI, region string) (out []infraview.Resource, partial []string) {
+ o, err := api.ListCertificates(ctx, &acm.ListCertificatesInput{})
+ if err != nil {
+ return nil, []string{fmt.Sprintf("certificates in %s: %v", region, err)}
+ }
+ for _, c := range o.CertificateSummaryList {
+ r := res(infraview.KindCertificate, "acm:certificate", aws.ToString(c.CertificateArn), aws.ToString(c.DomainName), region)
+ set(&r, "domain", aws.ToString(c.DomainName))
+ set(&r, "status", string(c.Status))
+ if c.NotAfter != nil {
+ set(&r, "expires", c.NotAfter.UTC().Format("2006-01-02"))
+ }
+ out = append(out, r)
+ }
+ return out, partial
+}
+
+// catchAll is the one genuinely AWS-shaped trick in this package: one call
+// that returns every TAGGABLE resource in the region, so a resource type
+// nobody wrote a mapping for is still visible. seen holds the ids the typed
+// mappings already produced — an ARN whose id is in it is skipped, so the
+// catch-all only ever ADDS.
+func catchAll(ctx context.Context, api TaggingAPI, region string, seen map[string]bool) (out []infraview.Resource, partial []string) {
+ var token *string
+ for {
+ o, err := api.GetResources(ctx, &resourcegroupstaggingapi.GetResourcesInput{PaginationToken: token})
+ if err != nil {
+ return out, append(partial, fmt.Sprintf("resource inventory in %s: %v", region, err))
+ }
+ for _, m := range o.ResourceTagMappingList {
+ arn := aws.ToString(m.ResourceARN)
+ service, id := parseARN(arn)
+ if id == "" || seen[id] || seen[arn] {
+ continue
+ }
+ r := res(infraview.KindOther, service, arn, id, region)
+ for _, t := range m.Tags {
+ if r.Tags == nil {
+ r.Tags = map[string]string{}
+ }
+ r.Tags[aws.ToString(t.Key)] = aws.ToString(t.Value)
+ }
+ out = append(out, r)
+ }
+ if o.PaginationToken == nil || *o.PaginationToken == "" {
+ return out, partial
+ }
+ token = o.PaginationToken
+ }
+}
+
+// parseARN pulls the service and the trailing resource id out of an ARN:
+// arn:aws:ec2:eu-west-1:1234:instance/i-123 -> ("ec2", "i-123").
+func parseARN(arn string) (service, id string) {
+ parts := strings.SplitN(arn, ":", 6)
+ if len(parts) < 6 {
+ return "", ""
+ }
+ service = parts[2]
+ tail := parts[5]
+ if i := strings.LastIndexAny(tail, "/:"); i >= 0 {
+ tail = tail[i+1:]
+ }
+ return service, tail
+}
+```
+
+- [ ] **Step 4: Write `awsinv.go` (assembly)**
+
+```go
+package awsinv
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/ec2"
+ "github.com/aws/aws-sdk-go-v2/service/sts"
+ "github.com/aws/smithy-go"
+ "github.com/carlosframework/platform/internal/infraview"
+)
+
+// credentialProbe is how long New waits for the credential chain to answer
+// before deciding this deployment has none. Five seconds, matching
+// cepull.go's own bounded probe: on a box with an instance role it is
+// instant, and on a laptop with no credential at all the chain can
+// otherwise hang on IMDS.
+const credentialProbe = 5 * time.Second
+
+// Provider is the AWS implementation of infraview.Provider.
+type Provider struct {
+ factory Factory
+ home string // the region whose clients answer the global services
+ regions []string // every region to inventory
+ account string
+ now func() time.Time // test seam
+}
+
+// New resolves the credential chain once, discovers the account's
+// opted-in regions, and returns a Provider. Every failure here is
+// construction-class — no config, no credential, no reachable EC2 — and the
+// caller (the console pass) treats an auth-shaped one as "this deployment
+// has no inventory grant", latching the pass off rather than retrying
+// hourly forever.
+func New(ctx context.Context) (*Provider, error) {
+ cfg, err := awsconfig.LoadDefaultConfig(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("awsinv: aws config: %w", err)
+ }
+ probeCtx, cancel := context.WithTimeout(ctx, credentialProbe)
+ defer cancel()
+ if _, err := cfg.Credentials.Retrieve(probeCtx); err != nil {
+ return nil, fmt.Errorf("awsinv: no usable credential: %w", err)
+ }
+ factory := factoryFor(cfg)
+ home := cfg.Region
+ if home == "" {
+ home = "us-east-1"
+ }
+
+ regions := []string{home}
+ if o, err := factory(home).EC2.DescribeRegions(ctx, &ec2.DescribeRegionsInput{}); err != nil {
+ if IsAuthShaped(err) {
+ return nil, fmt.Errorf("awsinv: describe regions: %w", err)
+ }
+ // Not auth-shaped: inventory the home region alone rather than
+ // nothing. The missing regions surface in Partial at assembly.
+ } else {
+ regions = regions[:0]
+ for _, r := range o.Regions {
+ if r.RegionName != nil {
+ regions = append(regions, *r.RegionName)
+ }
+ }
+ }
+
+ account := ""
+ if id, err := sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}); err == nil && id.Account != nil {
+ account = *id.Account
+ }
+ return &Provider{factory: factory, home: home, regions: regions, account: account, now: time.Now}, nil
+}
+
+// newWithFactory is New's test twin: no credential chain, no network.
+func newWithFactory(f Factory, home string, regions []string, account string) *Provider {
+ return &Provider{factory: f, home: home, regions: regions, account: account, now: time.Now}
+}
+
+// Inventory walks every region's typed mappings, then the catch-all, then
+// the global services once from the home region.
+//
+// The error contract: an error means "this deployment cannot inventory
+// itself at all". Anything less — a throttled call, one dead region, a
+// service the role cannot read — lands in Partial and the inventory is
+// still returned, because a page showing eleven of twelve resource classes
+// is worth far more than an error.
+func (p *Provider) Inventory(ctx context.Context) (infraview.Inventory, error) {
+ inv := infraview.Inventory{Provider: "aws", Account: p.account, TakenAt: p.now().UTC()}
+ seen := map[string]bool{}
+ authDenied := false
+
+ note := func(lines []string) {
+ inv.Partial = append(inv.Partial, lines...)
+ }
+ add := func(rs []infraview.Resource) {
+ for _, r := range rs {
+ seen[r.ID] = true
+ inv.Resources = append(inv.Resources, r)
+ }
+ }
+
+ for _, region := range p.regions {
+ c := p.factory(region)
+ rs, partial := ec2Resources(ctx, c.EC2, region)
+ add(rs)
+ note(partial)
+
+ rs, partial = alarmResources(ctx, c.CloudWatch, region)
+ add(rs)
+ note(partial)
+
+ rs, partial = certResources(ctx, c.ACM, region)
+ add(rs)
+ note(partial)
+
+ rs, partial = catchAll(ctx, c.Tagging, region, seen)
+ add(rs)
+ note(partial)
+ }
+
+ global := p.factory(p.home)
+ rs, partial := s3Resources(ctx, global.S3)
+ add(rs)
+ note(partial)
+
+ rs, partial = route53Resources(ctx, global.Route53)
+ add(rs)
+ note(partial)
+
+ rs, partial = iamResources(ctx, global.IAM)
+ add(rs)
+ note(partial)
+
+ for _, line := range inv.Partial {
+ if looksDenied(line) {
+ authDenied = true
+ break
+ }
+ }
+ if len(inv.Resources) == 0 && authDenied {
+ return inv, errors.New("awsinv: every read was denied — this deployment has no inventory grant")
+ }
+ return inv, nil
+}
+
+// IsAuthShaped is cepull.go's isAuthShapedError, exported here so the
+// console classifies THIS package's construction errors by the same rule it
+// classifies Cost Explorer's. One rule, one list of codes.
+func IsAuthShaped(err error) bool {
+ var apiErr smithy.APIError
+ if !errors.As(err, &apiErr) {
+ return false
+ }
+ return looksDenied(apiErr.ErrorCode())
+}
+
+func looksDenied(s string) bool {
+ for _, shape := range []string{
+ "AccessDenied", "UnauthorizedOperation", "UnrecognizedClient",
+ "InvalidClientTokenId", "InvalidAccessKeyId", "AuthFailure", "ExpiredToken",
+ } {
+ if strings.Contains(s, shape) {
+ return true
+ }
+ }
+ return false
+}
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/awsinv/ -count=1 -v`
+Expected: PASS — all five tests, including the three from this task.
+
+- [ ] **Step 6: Add the remaining mapping tests**
+
+One focused test per mapping in `rest_test.go`, each with a fake returning one item and asserting the produced `Resource`. `TestS3BucketMapping` is the pattern the rest follow:
+
+```go
+type fakeS3 struct {
+ buckets *s3.ListBucketsOutput
+ vers *s3.GetBucketVersioningOutput
+}
+
+func (f *fakeS3) ListBuckets(context.Context, *s3.ListBucketsInput, ...func(*s3.Options)) (*s3.ListBucketsOutput, error) {
+ return f.buckets, nil
+}
+func (f *fakeS3) GetBucketLocation(context.Context, *s3.GetBucketLocationInput, ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error) {
+ return &s3.GetBucketLocationOutput{LocationConstraint: s3types.BucketLocationConstraintEuWest1}, nil
+}
+func (f *fakeS3) GetBucketVersioning(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) {
+ return f.vers, nil
+}
+func (f *fakeS3) GetBucketEncryption(context.Context, *s3.GetBucketEncryptionInput, ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error) {
+ return nil, errors.New("no encryption configured")
+}
+func (f *fakeS3) GetPublicAccessBlock(context.Context, *s3.GetPublicAccessBlockInput, ...func(*s3.Options)) (*s3.GetPublicAccessBlockOutput, error) {
+ return nil, errors.New("no public access block")
+}
+
+// A bucket whose posture reads fail still appears — with the attribute
+// ABSENT, never a false "off". "Off" is a claim this read did not earn.
+func TestS3BucketMapping(t *testing.T) {
+ f := &fakeS3{
+ buckets: &s3.ListBucketsOutput{Buckets: []s3types.Bucket{{Name: aws.String("carlos-data-ie")}}},
+ vers: &s3.GetBucketVersioningOutput{Status: s3types.BucketVersioningStatusEnabled},
+ }
+ rs, partial := s3Resources(context.Background(), f)
+ if len(partial) != 0 || len(rs) != 1 {
+ t.Fatalf("rs=%+v partial=%v", rs, partial)
+ }
+ b := rs[0]
+ if b.Kind != infraview.KindBucket || b.Name != "carlos-data-ie" || b.Region != "eu-west-1" {
+ t.Errorf("bucket = %+v", b)
+ }
+ if b.Attr("versioning") != "enabled" {
+ t.Errorf("versioning = %q, want enabled", b.Attr("versioning"))
+ }
+ if _, ok := b.Attrs["encryption"]; ok {
+ t.Errorf("a failed encryption read must leave the attribute absent, not claim a value")
+ }
+}
+```
+
+Then the remaining ones, same shape:
+`TestS3BucketMapping` (name, region, versioning/encryption/PAB attrs), `TestRoute53RecordsMaterialiseOnlyJoinable` (an A record becomes a `dns_record`; a TXT record only bumps `record_count`), `TestIAMNeverCarriesPolicyDocuments` (assert no attr value contains `"Statement"`), `TestAlarmTargetsInstanceDimension`, `TestCertMapping`, `TestParseARN` (table-driven, including a malformed ARN returning `("", "")`).
+
+- [ ] **Step 7: Run the full package tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/awsinv/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 8: Gate and commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
+gofmt -l internal/awsinv
+git add internal/awsinv go.mod go.sum
+git commit -m "awsinv: S3/Route53/IAM/CloudWatch/ACM mappings, tagging catch-all, assembly
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: The console inventory pass
+
+**Files:**
+- Create: `internal/console/infrapass.go`, `internal/console/infrapass_test.go`
+- Modify: `internal/console/server.go` (Server fields), `cmd/console/main.go:273` (start the pass)
+
+**Interfaces:**
+- Consumes: `infraview.Inventory`, `infraview.InventoryKey`, `awsinv.New`, `awsinv.IsAuthShaped`, `release.Store`.
+- Produces: `(*Server).StartInfraPass(ctx context.Context)`, `(*Server).infraPass(ctx context.Context) error`, `(*Server).loadInventory(ctx context.Context) (*infraview.Inventory, string)` where the string is a human-readable reason when the inventory is nil (`"never"`, `"ungranted"`, or a read error), and the `Server` seam field `inventory func(ctx context.Context) (infraview.Inventory, error)`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/infraview"
+)
+
+// The pass writes the snapshot where the page reads it, and loadInventory
+// reads back exactly what was written.
+func TestInfraPassWritesSnapshot(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.inventory = func(context.Context) (infraview.Inventory, error) {
+ return infraview.Inventory{
+ Provider: "aws", Account: "1234", TakenAt: time.Now().UTC(),
+ Resources: []infraview.Resource{{Provider: "aws", Kind: infraview.KindMachine, ID: "i-1"}},
+ }, nil
+ }
+ if err := fx.srv.infraPass(context.Background()); err != nil {
+ t.Fatalf("infraPass: %v", err)
+ }
+ body, _, err := fx.rel.Get(context.Background(), infraview.InventoryKey)
+ if err != nil {
+ t.Fatalf("read back: %v", err)
+ }
+ var inv infraview.Inventory
+ if err := json.Unmarshal(body, &inv); err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if len(inv.Resources) != 1 || inv.Resources[0].ID != "i-1" {
+ t.Fatalf("stored = %+v", inv)
+ }
+ got, why := fx.srv.loadInventory(context.Background())
+ if got == nil || why != "" {
+ t.Fatalf("loadInventory = %+v, %q; want the snapshot", got, why)
+ }
+}
+
+// An auth-shaped failure latches the pass off for the process after ONE
+// attempt: "this deployment has no inventory grant" is a permanent answer,
+// not something to re-ask AWS every hour forever.
+func TestInfraPassLatchesOnAuthShapedFailure(t *testing.T) {
+ fx := newConsoleFixture(t)
+ calls := 0
+ fx.srv.inventory = func(context.Context) (infraview.Inventory, error) {
+ calls++
+ return infraview.Inventory{}, &fakeAPIError{code: "AccessDenied"}
+ }
+ if err := fx.srv.infraPass(context.Background()); err == nil {
+ t.Fatal("want an error from the denied pass")
+ }
+ if err := fx.srv.infraPass(context.Background()); err == nil {
+ t.Fatal("want an error from the latched pass")
+ }
+ if calls != 1 {
+ t.Fatalf("provider called %d times, want 1 — the latch must stop the second attempt", calls)
+ }
+ if _, why := fx.srv.loadInventory(context.Background()); why != "ungranted" {
+ t.Fatalf("loadInventory reason = %q, want %q", why, "ungranted")
+ }
+}
+
+// A transient failure is NOT the latch: CE throttles under load and a blip
+// must not silently kill inventory until the next redeploy.
+func TestInfraPassRetriesTransientFailure(t *testing.T) {
+ fx := newConsoleFixture(t)
+ calls := 0
+ fx.srv.inventory = func(context.Context) (infraview.Inventory, error) {
+ calls++
+ return infraview.Inventory{}, errors.New("Throttling: rate exceeded")
+ }
+ _ = fx.srv.infraPass(context.Background())
+ _ = fx.srv.infraPass(context.Background())
+ if calls != 2 {
+ t.Fatalf("provider called %d times, want 2 — a throttle must not latch", calls)
+ }
+}
+
+// With no snapshot ever written, the page must be told "never", never
+// handed an empty inventory that would render as "this account has no
+// resources".
+func TestLoadInventoryNeverTaken(t *testing.T) {
+ fx := newConsoleFixture(t)
+ inv, why := fx.srv.loadInventory(context.Background())
+ if inv != nil || why != "never" {
+ t.Fatalf("loadInventory = %+v, %q; want nil, \"never\"", inv, why)
+ }
+}
+```
+
+Add `fakeAPIError` to `infrapass_test.go` (the same four-method `smithy.APIError` shape as Task 3's `fakeAPIErr`).
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestInfraPass -count=1`
+Expected: FAIL — `fx.srv.inventory undefined`.
+
+- [ ] **Step 3: Add the Server fields**
+
+In `internal/console/server.go`, beside the existing `ceCosts`/`ceDisabled`/`ceWarnOnce` seam fields on the `Server` struct:
+
+```go
+ // The infrastructure inventory seam (infrapass.go), built exactly like
+ // the Cost Explorer seam above it: tests set inventory to a fake, the
+ // real path lazily constructs an awsinv.Provider once. infraDisabled
+ // latches an auth-shaped failure off for the process — a deployment
+ // with no inventory IAM grant answers "ungranted" once, not hourly.
+ inventory func(ctx context.Context) (infraview.Inventory, error)
+ infraOnce sync.Once
+ infraProvider infraview.Provider
+ infraDisabled bool
+ infraRefreshing atomic.Bool
+```
+
+- [ ] **Step 4: Write `infrapass.go`**
+
+```go
+// The infrastructure inventory pass: once an hour, ask the deployment's
+// cloud provider what it is carrying and write the answer to
+// infraview.InventoryKey in the deployment bucket, where
+// /fleet/architecture reads it.
+//
+// The failure posture is cepull.go's, verbatim and for the same reasons.
+// Construction-class failures — no AWS config, no usable credential, an
+// AccessDenied-shaped answer to the first calls — latch the pass off for
+// the process after a single log line. There is no "the IAM grant might
+// appear later" retry worth making, and a deployment without the grant must
+// show "not granted" rather than filling its log with an hourly error.
+// Everything else — throttling, a 5xx, a network blip — logs and the next
+// tick tries again.
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/awsinv"
+ "github.com/carlosframework/platform/internal/infraview"
+)
+
+// infraPassInterval matches economicsPassInterval: an estate changes on the
+// timescale of a deploy, not a request, and the refresh button covers the
+// moment an operator actually needs it now.
+const infraPassInterval = time.Hour
+
+// infraPassTimeout bounds one whole inventory — every region, every
+// service. A pass that hangs must not hold the goroutine until the process
+// restarts.
+const infraPassTimeout = 2 * time.Minute
+
+// StartInfraPass runs infraPass immediately and then hourly until ctx is
+// done — StartEconomicsPass's shape (economicspass.go:38).
+func (s *Server) StartInfraPass(ctx context.Context) {
+ if s.cfg.Releases == nil {
+ return // no bucket, nowhere to write, nothing to do
+ }
+ t := time.NewTicker(infraPassInterval)
+ defer t.Stop()
+ for {
+ if err := s.infraPass(ctx); err != nil {
+ log.Printf("console: infra pass: %v", err)
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ }
+ }
+}
+
+// resolveInventory is the seam: the test fake when one is set, otherwise a
+// lazily constructed awsinv.Provider reused for the life of the process.
+func (s *Server) resolveInventory(ctx context.Context) (infraview.Inventory, error) {
+ if s.inventory != nil {
+ return s.inventory(ctx)
+ }
+ if s.infraDisabled {
+ return infraview.Inventory{}, errors.New("console: infra inventory disabled for this process")
+ }
+ s.infraOnce.Do(func() {
+ p, err := awsinv.New(ctx)
+ if err != nil {
+ // Construction failed. Auth-shaped means no grant: latch.
+ // Anything else means this process cannot build a provider at
+ // all, which is equally permanent for this process.
+ log.Printf("console: infra inventory unavailable: %v", err)
+ s.infraDisabled = true
+ return
+ }
+ s.infraProvider = p
+ })
+ if s.infraProvider == nil {
+ s.infraDisabled = true
+ return infraview.Inventory{}, errors.New("console: no infrastructure provider")
+ }
+ return s.infraProvider.Inventory(ctx)
+}
+
+// infraPass takes one inventory and stores it. An auth-shaped failure
+// latches; everything else is returned for the caller to log and retry.
+func (s *Server) infraPass(ctx context.Context) error {
+ if s.infraDisabled {
+ return errors.New("console: infra inventory disabled for this process")
+ }
+ ctx, cancel := context.WithTimeout(ctx, infraPassTimeout)
+ defer cancel()
+
+ inv, err := s.resolveInventory(ctx)
+ if err != nil {
+ if awsinv.IsAuthShaped(err) || isAuthShapedError(err) {
+ s.infraDisabled = true
+ return fmt.Errorf("console: infra inventory not granted (disabled for this process): %w", err)
+ }
+ return fmt.Errorf("console: infra inventory: %w", err)
+ }
+ return s.putInventory(ctx, inv)
+}
+
+// putInventory CAS-writes the snapshot — upsertBill's read-etag-then-put
+// (economicspass.go:942), with os.ErrNotExist meaning "first ever write".
+func (s *Server) putInventory(ctx context.Context, inv infraview.Inventory) error {
+ if s.cfg.Releases == nil {
+ return nil
+ }
+ _, etag, err := s.cfg.Releases.Get(ctx, infraview.InventoryKey)
+ switch {
+ case err == nil:
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ default:
+ return fmt.Errorf("console: inventory: read: %w", err)
+ }
+ body, err := json.Marshal(inv)
+ if err != nil {
+ return fmt.Errorf("console: inventory: marshal: %w", err)
+ }
+ if _, err := s.cfg.Releases.Put(ctx, infraview.InventoryKey, body, etag); err != nil {
+ return fmt.Errorf("console: inventory: write: %w", err)
+ }
+ return nil
+}
+
+// loadInventory reads the stored snapshot for the page. The second return
+// is why there is nothing to show, and it distinguishes the three states
+// that must never be rendered as one: "never" (no pass has ever succeeded),
+// "ungranted" (this process latched off), and a read error. "no resources"
+// is a claim; none of these three earn it.
+func (s *Server) loadInventory(ctx context.Context) (*infraview.Inventory, string) {
+ if s.cfg.Releases == nil {
+ return nil, "never"
+ }
+ body, _, err := s.cfg.Releases.Get(ctx, infraview.InventoryKey)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ if s.infraDisabled {
+ return nil, "ungranted"
+ }
+ return nil, "never"
+ case err != nil:
+ log.Printf("console: inventory read: %v", err)
+ return nil, "unreadable"
+ }
+ var inv infraview.Inventory
+ if err := json.Unmarshal(body, &inv); err != nil {
+ log.Printf("console: inventory parse: %v", err)
+ return nil, "unreadable"
+ }
+ return &inv, ""
+}
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestInfraPass -count=1 -v` and `-run TestLoadInventory`
+Expected: PASS — four tests.
+
+- [ ] **Step 6: Start the pass**
+
+In `cmd/console/main.go`, beside line 273:
+
+```go
+ go srv.StartEconomicsPass(context.Background())
+ go srv.StartInfraPass(context.Background())
+```
+
+- [ ] **Step 7: Gate and commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
+gofmt -l internal/console cmd/console
+git add internal/console/infrapass.go internal/console/infrapass_test.go internal/console/server.go cmd/console/main.go
+git commit -m "console: hourly infrastructure inventory pass
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: The graph joins, box cards and cost/drift
+
+**Files:**
+- Create: `internal/console/archgraph.go`, `internal/console/archgraph_test.go`
+
+**Interfaces:**
+- Consumes: `infraview.Inventory`, `status.Fleet`, `HostRow` (`view.go:679`), `registry.Route`, `economics.RateCard`, `economics.HoursPerMonth`.
+- Produces:
+
+```go
+type ArchInput struct {
+ Inv *infraview.Inventory
+ Fleet *status.Fleet
+ Hosts []HostRow
+ Routes []registry.Route
+ Rates economics.RateCard
+}
+type KV struct{ K, V string }
+type Node struct {
+ ID, Kind, Title, Subtitle string
+ Detail []KV
+ X, Y, W, H int
+}
+type Edge struct{ From, To, Kind string }
+type BoxCard struct {
+ Label, Region, InstanceType, State string
+ Cores, MemGB, EBSGB float64
+ DiscoveredDiskGB float64
+ USDMonth float64
+ Apps []string
+ Static, Resident, Hibernating int
+ Awake, Asleep *int
+ Release, Load string
+ Stale bool
+ Dark string
+ Drift []string
+ CloudID string // "" = no cloud machine behind this box
+}
+type ResourceGroup struct {
+ Title string
+ Rows []ResourceRow
+}
+type ResourceRow struct{ Name, NativeType, Region, Detail string }
+type ArchView struct {
+ Nodes []Node
+ Edges []Edge
+ Width, Height int
+ Boxes []BoxCard
+ Groups []ResourceGroup
+ TakenAt time.Time
+ Account string
+ Partial []string
+ Missing string // "" | "never" | "ungranted" | "unreadable"
+}
+func BuildArch(in ArchInput) ArchView
+```
+
+- [ ] **Step 1: Write the failing join test**
+
+```go
+package console
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/economics"
+ "github.com/carlosframework/platform/internal/infraview"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+func archFixture() ArchInput {
+ inv := &infraview.Inventory{Provider: "aws", Account: "1234", Resources: []infraview.Resource{
+ {Provider: "aws", Kind: infraview.KindDNSZone, ID: "Z1", Name: "carloku.com",
+ Attrs: map[string]string{"record_count": "12"}},
+ {Provider: "aws", Kind: infraview.KindDNSRecord, ID: "r1", Name: "console.carloku.com",
+ Attrs: map[string]string{"zone": "carloku.com", "type": "A", "value": "52.0.0.1"}},
+ {Provider: "aws", Kind: infraview.KindIP, ID: "eipalloc-1",
+ Attrs: map[string]string{"address": "52.0.0.1", "attached_to": "i-123"}},
+ {Provider: "aws", Kind: infraview.KindMachine, ID: "i-123", Name: "carlos-eu-west-1", Region: "eu-west-1",
+ Attrs: map[string]string{"type": "t4g.medium", "state": "running", "public_ip": "52.0.0.1"}},
+ {Provider: "aws", Kind: infraview.KindDisk, ID: "vol-1",
+ Attrs: map[string]string{"size_gb": "40", "attached_to": "i-123"}},
+ {Provider: "aws", Kind: infraview.KindMachine, ID: "i-999", Name: "mystery-box", Region: "eu-west-1",
+ Attrs: map[string]string{"type": "t4g.nano", "state": "running"}},
+ {Provider: "aws", Kind: infraview.KindBucket, ID: "carlos-data-ie", Name: "carlos-data-ie"},
+ {Provider: "aws", Kind: infraview.KindOther, NativeType: "sqs", ID: "arn:aws:sqs:eu-west-1:1234:q", Name: "q"},
+ }}
+ return ArchInput{
+ Inv: inv,
+ Fleet: &status.Fleet{Boxes: []status.FleetBox{
+ {Label: "carlos-eu-west-1", Region: "eu-west-1"},
+ {Label: "pi-dublin", Region: "home"},
+ }},
+ Hosts: []HostRow{
+ {Label: "carlos-eu-west-1", Region: "eu-west-1", Metrics: &status.BoxMetrics{
+ Release: "abc1234",
+ RouteVersions: map[string]string{"console.carloku.com": "abc1234"},
+ }},
+ {Label: "pi-dublin", Region: "home", Metrics: &status.BoxMetrics{}},
+ },
+ Routes: []registry.Route{
+ {Host: "console.carloku.com", App: "console", Kind: "instance", Status: "ready", Version: "abc1234"},
+ },
+ Rates: economics.RateCard{Boxes: map[string]economics.BoxSpec{
+ "": {HourUSD: 0.02, Cores: 2, MemGB: 4, EBSGB: 30},
+ "carlos-eu-west-1": {HourUSD: 0.0336, Cores: 2, MemGB: 4, EBSGB: 30},
+ }, EBSGBMonthUSD: 0.088},
+ }
+}
+
+// The join that makes the picture true: a DNS record's value is an EIP's
+// address, that EIP is attached to a machine, and that machine's Name tag
+// is a reporting box's label.
+func TestArchJoinsDNSThroughIPToBox(t *testing.T) {
+ v := BuildArch(archFixture())
+
+ var box *BoxCard
+ for i := range v.Boxes {
+ if v.Boxes[i].Label == "carlos-eu-west-1" {
+ box = &v.Boxes[i]
+ }
+ }
+ if box == nil {
+ t.Fatal("no card for carlos-eu-west-1")
+ }
+ if box.CloudID != "i-123" {
+ t.Errorf("CloudID = %q, want i-123 (Name tag matched to box label)", box.CloudID)
+ }
+ if box.InstanceType != "t4g.medium" {
+ t.Errorf("InstanceType = %q", box.InstanceType)
+ }
+ if box.DiscoveredDiskGB != 40 {
+ t.Errorf("DiscoveredDiskGB = %v, want 40", box.DiscoveredDiskGB)
+ }
+ if len(box.Apps) != 1 || box.Apps[0] != "console" {
+ t.Errorf("Apps = %v, want [console]", box.Apps)
+ }
+ if !hasEdge(v, "record:r1", "ip:eipalloc-1") || !hasEdge(v, "ip:eipalloc-1", "box:carlos-eu-west-1") {
+ t.Errorf("missing the DNS -> IP -> box chain in %+v", v.Edges)
+ }
+}
+
+func hasEdge(v ArchView, from, to string) bool {
+ for _, e := range v.Edges {
+ if e.From == from && e.To == to {
+ return true
+ }
+ }
+ return false
+}
+
+// Unjoined in either direction is DRAWN, never hidden — the rule most of
+// this page's value rests on.
+func TestArchShowsUnjoinedBothWays(t *testing.T) {
+ v := BuildArch(archFixture())
+
+ var found bool
+ for _, n := range v.Nodes {
+ if n.ID == "machine:i-999" {
+ found = true
+ if n.Subtitle == "" {
+ t.Error("an unclaimed machine must say so")
+ }
+ }
+ }
+ if !found {
+ t.Error("a machine no box reports must still be drawn (it is drift)")
+ }
+
+ var pi *BoxCard
+ for i := range v.Boxes {
+ if v.Boxes[i].Label == "pi-dublin" {
+ pi = &v.Boxes[i]
+ }
+ }
+ if pi == nil {
+ t.Fatal("a box with no cloud machine must still get a card — it is a BYO box, not an error")
+ }
+ if pi.CloudID != "" {
+ t.Errorf("pi CloudID = %q, want empty", pi.CloudID)
+ }
+}
+
+// Cost is the rate card's, so this page and /fleet/economics can never
+// disagree; drift is flagged, not silently priced away.
+func TestArchCostAndDrift(t *testing.T) {
+ in := archFixture()
+ v := BuildArch(in)
+ var box BoxCard
+ for _, b := range v.Boxes {
+ if b.Label == "carlos-eu-west-1" {
+ box = b
+ }
+ }
+ want := 0.0336*economics.HoursPerMonth + 30*0.088
+ if diff := box.USDMonth - want; diff > 0.001 || diff < -0.001 {
+ t.Errorf("USDMonth = %v, want %v (rate card, not a second cost model)", box.USDMonth, want)
+ }
+ // The card says 30 GB of EBS; the estate has 40. That is drift.
+ if len(box.Drift) == 0 {
+ t.Error("a 30 GB rate-card entry against a 40 GB discovered disk must raise drift")
+ }
+
+ // A reporting box with no rate-card entry of its own is also drift:
+ // it is being priced by the default spec, which is a guess.
+ var pi BoxCard
+ for _, b := range v.Boxes {
+ if b.Label == "pi-dublin" {
+ pi = b
+ }
+ }
+ if len(pi.Drift) == 0 {
+ t.Error("a box priced by the default spec must say so")
+ }
+}
+
+// Resources the graph has no opinion about must still reach the page.
+func TestArchGroupsCarryOtherResources(t *testing.T) {
+ v := BuildArch(archFixture())
+ for _, g := range v.Groups {
+ for _, r := range g.Rows {
+ if r.NativeType == "sqs" {
+ return
+ }
+ }
+ }
+ t.Fatal("an unmapped resource must appear in the resource groups")
+}
+
+// No inventory at all: the CARLOS half still renders and Missing says which
+// of the three states applies.
+func TestArchWithoutInventory(t *testing.T) {
+ in := archFixture()
+ in.Inv = nil
+ v := BuildArch(in)
+ if v.Missing == "" {
+ t.Error("a nil inventory must set Missing")
+ }
+ if len(v.Boxes) != 2 {
+ t.Errorf("boxes = %d, want the CARLOS half to render regardless", len(v.Boxes))
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestArch -count=1`
+Expected: FAIL — `undefined: BuildArch`.
+
+- [ ] **Step 3: Write `archgraph.go`**
+
+Implement `BuildArch` in this order, each step a small named helper so the file stays readable:
+
+1. `machineFor(inv, label)` — case-insensitive match of `machine.Name` against the box label; returns `*infraview.Resource`.
+2. `diskGBFor(inv, machineID)` — sum of `disk.size_gb` where `attached_to` == machine id.
+3. `appsFor(host HostRow, routes []registry.Route)` — the box's `Metrics.RouteVersions` keys looked up in `routes` by `Host`; distinct app names, sorted.
+4. `boxCost` and `driftFor`, in full — these two carry the page's money claims, so they are given rather than described:
+
+```go
+// boxCost prices one box the way /fleet/economics prices it and no other
+// way: the rate card's own spec for that label, falling back to the ""
+// default exactly as economics.AllocateBox does. explicit is false when the
+// fallback was used, which is itself worth telling the operator — a box
+// priced by the default spec is priced by a guess.
+func boxCost(rc economics.RateCard, label string) (usd float64, spec economics.BoxSpec, explicit bool) {
+ spec, explicit = rc.Boxes[label]
+ if !explicit {
+ spec = rc.Boxes[""]
+ }
+ usd = spec.HourUSD*economics.HoursPerMonth + spec.EBSGB*rc.EBSGBMonthUSD
+ return usd, spec, explicit
+}
+
+// driftFor names every way the rate card and the estate disagree about this
+// box. Two checks only — the two this page can make honestly. Comparing the
+// instance TYPE against the card's cores/memory would need a type->shape
+// table, which is a second source of truth and out of scope (spec §10).
+func driftFor(card BoxCard, spec economics.BoxSpec, explicit bool) []string {
+ var out []string
+ if !explicit {
+ out = append(out, "priced by the default spec — no rate-card entry for this box")
+ }
+ if card.DiscoveredDiskGB > 0 && spec.EBSGB > 0 {
+ if d := card.DiscoveredDiskGB - spec.EBSGB; d >= 1 || d <= -1 {
+ out = append(out, fmt.Sprintf("rate card says %.0f GB of EBS, the estate has %.0f GB",
+ spec.EBSGB, card.DiscoveredDiskGB))
+ }
+ }
+ return out
+}
+```
+6. Node and edge construction, ids namespaced by kind (`zone:Z1`, `record:r1`, `ip:eipalloc-1`, `box:<label>`, `machine:<id>` for an unclaimed machine, `apps:<label>`, `bucket:<name>`):
+ - `zone → record` for each record whose `zone` attr matches.
+ - `record → ip` via `inv.Find(KindIP, "address", record.Attr("value"))`.
+ - `ip → box` when the IP's `attached_to` machine is a box's `CloudID`; `ip → machine:<id>` when it is not.
+ - `record → external:<value>` when the value matches no IP and no machine — an arrow leaving the diagram, per spec §6.3.
+ - `box → apps:<label>` carrying the instance counts as the node subtitle.
+ - `box → bucket:<deployment bucket>` for every reporting box (the one bucket relationship the console can state as fact).
+ - `alarm → box` when the alarm's `target` attr is that box's `CloudID`.
+7. `Groups` — compute (`machine`, `disk`), network (`ip`, `firewall`, `network`), storage (`bucket`), DNS (`dns_zone`, `dns_record`), identity, alarms, certificates, other. Each row's `Detail` is the resource's own attrs rendered `k=v`, sorted by key, so an attribute no one wrote code for still appears.
+8. `Missing`, `TakenAt`, `Account`, `Partial` copied from the inventory (with `Missing` set by the caller when `Inv == nil`; `BuildArch` sets `"never"` when handed nil and no reason).
+
+The counts on the apps node come from the box's `status.FleetBox.Routes` (`Static`/`Resident`/`Hibernating`, and the `*int` `Awake`/`Asleep` rendered as "unknown" when nil).
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestArch -count=1 -v`
+Expected: PASS — five tests.
+
+- [ ] **Step 5: Gate and commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
+gofmt -l internal/console
+git add internal/console/archgraph.go internal/console/archgraph_test.go
+git commit -m "console: architecture graph joins, box cards, rate-card cost and drift
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: Deterministic layout and SVG
+
+**Files:**
+- Create: `internal/console/archlayout.go`, `internal/console/archlayout_test.go`
+- Modify: `internal/console/archgraph.go` (call `layout` at the end of `BuildArch`)
+
+**Interfaces:**
+- Consumes: `ArchView`, `Node`, `Edge` (Task 5).
+- Produces: `layout(v *ArchView)` (sets every `Node.X/Y/W/H` and `ArchView.Width/Height`), `RenderArchSVG(v ArchView) template.HTML`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package console
+
+import (
+ "strings"
+ "testing"
+)
+
+// The same fleet must draw the same picture every time. A diagram that
+// reshuffles between refreshes cannot be read for change, which is most of
+// what an operator opens it for.
+func TestLayoutIsDeterministic(t *testing.T) {
+ a := RenderArchSVG(BuildArch(archFixture()))
+ b := RenderArchSVG(BuildArch(archFixture()))
+ if a != b {
+ t.Fatal("two builds of the same input produced different SVG")
+ }
+}
+
+// Layer order is the story the page tells: DNS enters at the left, routers
+// and boxes in the middle, apps at the right.
+func TestLayoutOrdersLayers(t *testing.T) {
+ v := BuildArch(archFixture())
+ x := map[string]int{}
+ for _, n := range v.Nodes {
+ x[n.ID] = n.X
+ }
+ if !(x["zone:Z1"] < x["record:r1"] && x["record:r1"] < x["ip:eipalloc-1"] &&
+ x["ip:eipalloc-1"] < x["box:carlos-eu-west-1"] && x["box:carlos-eu-west-1"] < x["apps:carlos-eu-west-1"]) {
+ t.Fatalf("layers out of order: %+v", x)
+ }
+}
+
+// No node may overlap another: the layout is the only thing standing
+// between a growing fleet and an unreadable page.
+func TestLayoutDoesNotOverlap(t *testing.T) {
+ v := BuildArch(archFixture())
+ for i := range v.Nodes {
+ for j := i + 1; j < len(v.Nodes); j++ {
+ a, b := v.Nodes[i], v.Nodes[j]
+ if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H {
+ t.Fatalf("%s overlaps %s", a.ID, b.ID)
+ }
+ }
+ }
+}
+
+// The SVG must scale without script and must not carry raw user strings
+// unescaped.
+func TestSVGIsSelfContainedAndEscaped(t *testing.T) {
+ in := archFixture()
+ in.Fleet.Boxes[0].Label = `evil"><script>alert(1)</script>`
+ in.Hosts[0].Label = in.Fleet.Boxes[0].Label
+ out := string(RenderArchSVG(BuildArch(in)))
+ if !strings.Contains(out, "viewBox=") {
+ t.Error("SVG must carry a viewBox so it scales with no JS")
+ }
+ if strings.Contains(out, "<script") {
+ t.Error("a box label must never reach the SVG unescaped")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestLayout -count=1`
+Expected: FAIL — `undefined: RenderArchSVG`.
+
+- [ ] **Step 3: Write `archlayout.go`**
+
+```go
+// Layout and SVG emission for /fleet/architecture. Deterministic by
+// construction: layer by kind, order within a layer by one barycentre sweep
+// each way, then a stable sort by node id. The same fleet must produce a
+// byte-identical picture on every refresh — a diagram that reshuffles
+// cannot be read for change.
+//
+// No JavaScript and no layout library: the console vendors its icons and
+// loads nothing from a CDN, and a server-computed layout is the only way to
+// keep that promise while still drawing a graph.
+package console
+
+import (
+ "fmt"
+ "html/template"
+ "sort"
+ "strings"
+)
+
+// Layer assignment by node kind — the story the page tells, left to right.
+var archLayer = map[string]int{
+ "zone": 0, "record": 1, "ip": 2, "router": 3,
+ "box": 4, "machine": 4, "apps": 5, "bucket": 6,
+}
+
+const (
+ nodeW = 190
+ nodeH = 64
+ gapX = 90
+ gapY = 26
+ padding = 24
+ satelliteKinds = "alarm identity firewall network disk certificate other"
+)
+
+func layout(v *ArchView) {
+ // Satellites (alarms, identities, firewalls…) are listed in the cards
+ // below the diagram rather than drawn: a graph that draws every IAM role
+ // is a hairball, and the resource groups already carry them in full.
+ kept := v.Nodes[:0]
+ for _, n := range v.Nodes {
+ if strings.Contains(satelliteKinds, n.Kind) {
+ continue
+ }
+ kept = append(kept, n)
+ }
+ v.Nodes = kept
+
+ byLayer := map[int][]int{}
+ for i := range v.Nodes {
+ l := archLayer[v.Nodes[i].Kind]
+ byLayer[l] = append(byLayer[l], i)
+ }
+ // Stable order first, so the barycentre sweeps start from a fixed
+ // arrangement and cannot depend on map iteration order anywhere.
+ for l := range byLayer {
+ idx := byLayer[l]
+ sort.Slice(idx, func(a, b int) bool { return v.Nodes[idx[a]].ID < v.Nodes[idx[b]].ID })
+ }
+ // One sweep forward and one back, ordering each layer by the mean row of
+ // its neighbours in the adjacent layer — the standard crossing
+ // reduction, and enough for a graph this shape. Ties break on node id so
+ // the result cannot depend on map or slice iteration order anywhere.
+ row := map[string]int{}
+ for _, idx := range byLayer {
+ for r, i := range idx {
+ row[v.Nodes[i].ID] = r
+ }
+ }
+ neighbours := func(id string, forward bool) []string {
+ var out []string
+ for _, e := range v.Edges {
+ if forward && e.To == id {
+ out = append(out, e.From)
+ }
+ if !forward && e.From == id {
+ out = append(out, e.To)
+ }
+ }
+ return out
+ }
+ sweep := func(forward bool) {
+ layers := make([]int, 0, len(byLayer))
+ for l := range byLayer {
+ layers = append(layers, l)
+ }
+ sort.Ints(layers)
+ if !forward {
+ for i, j := 0, len(layers)-1; i < j; i, j = i+1, j-1 {
+ layers[i], layers[j] = layers[j], layers[i]
+ }
+ }
+ for _, l := range layers {
+ idx := byLayer[l]
+ bary := map[string]float64{}
+ for _, i := range idx {
+ id := v.Nodes[i].ID
+ ns := neighbours(id, forward)
+ if len(ns) == 0 {
+ bary[id] = float64(row[id])
+ continue
+ }
+ sum := 0
+ for _, n := range ns {
+ sum += row[n]
+ }
+ bary[id] = float64(sum) / float64(len(ns))
+ }
+ sort.SliceStable(idx, func(a, b int) bool {
+ ia, ib := v.Nodes[idx[a]].ID, v.Nodes[idx[b]].ID
+ if bary[ia] != bary[ib] {
+ return bary[ia] < bary[ib]
+ }
+ return ia < ib
+ })
+ for r, i := range idx {
+ row[v.Nodes[i].ID] = r
+ }
+ }
+ }
+ sweep(true)
+ sweep(false)
+
+ maxRows := 0
+ for l, idx := range byLayer {
+ for row, i := range idx {
+ v.Nodes[i].W, v.Nodes[i].H = nodeW, nodeH
+ v.Nodes[i].X = padding + l*(nodeW+gapX)
+ v.Nodes[i].Y = padding + row*(nodeH+gapY)
+ }
+ if len(idx) > maxRows {
+ maxRows = len(idx)
+ }
+ }
+ layers := 0
+ for l := range byLayer {
+ if l+1 > layers {
+ layers = l + 1
+ }
+ }
+ v.Width = padding*2 + layers*nodeW + (layers-1)*gapX
+ v.Height = padding*2 + maxRows*nodeH + (maxRows-1)*gapY
+}
+
+// RenderArchSVG emits the diagram. Every string that came from a hostname,
+// a tag or a label goes through template.HTMLEscapeString — these are
+// operator-supplied values from a registry and a cloud account, and the one
+// thing this page must never do is execute one of them.
+func RenderArchSVG(v ArchView) template.HTML {
+ var b strings.Builder
+ fmt.Fprintf(&b, `<svg class="archsvg" viewBox="0 0 %d %d" role="img" aria-label="Deployment architecture">`, v.Width, v.Height)
+ pos := map[string]Node{}
+ for _, n := range v.Nodes {
+ pos[n.ID] = n
+ }
+ // Edges first so nodes paint over them.
+ for _, e := range v.Edges {
+ from, okF := pos[e.From]
+ to, okT := pos[e.To]
+ if !okF || !okT {
+ continue // a satellite edge whose node the layout dropped
+ }
+ x1, y1 := from.X+from.W, from.Y+from.H/2
+ x2, y2 := to.X, to.Y+to.H/2
+ mid := (x1 + x2) / 2
+ fmt.Fprintf(&b, `<path class="archedge" d="M%d %d H%d V%d H%d" />`, x1, y1, mid, y2, x2)
+ }
+ for _, n := range v.Nodes {
+ fmt.Fprintf(&b, `<g class="archnode archnode-%s">`, template.HTMLEscapeString(n.Kind))
+ fmt.Fprintf(&b, `<rect x="%d" y="%d" width="%d" height="%d" rx="8" />`, n.X, n.Y, n.W, n.H)
+ fmt.Fprintf(&b, `<text x="%d" y="%d" class="archtitle">%s</text>`,
+ n.X+12, n.Y+24, template.HTMLEscapeString(n.Title))
+ if n.Subtitle != "" {
+ fmt.Fprintf(&b, `<text x="%d" y="%d" class="archsub">%s</text>`,
+ n.X+12, n.Y+44, template.HTMLEscapeString(n.Subtitle))
+ }
+ b.WriteString(`</g>`)
+ }
+ b.WriteString(`</svg>`)
+ return template.HTML(b.String())
+}
+```
+
+Call `layout(&v)` as the last statement of `BuildArch` before it returns.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run "TestLayout|TestSVG|TestArch" -count=1 -v`
+Expected: PASS.
+
+- [ ] **Step 5: Gate and commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
+gofmt -l internal/console
+git add internal/console/archlayout.go internal/console/archlayout_test.go internal/console/archgraph.go
+git commit -m "console: deterministic architecture layout and inline SVG
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 7: The page, the gate and the refresh door
+
+**Files:**
+- Create: `internal/console/archpage.go`, `internal/console/archpage_test.go`, `internal/console/templates/architecture.html`
+- Modify: `internal/console/server.go` (routes at :688-693, `page` struct), `internal/console/templates/fleet.html` (one link), `internal/console/templates/layout.html` (the page's CSS block)
+
+**Interfaces:**
+- Consumes: `BuildArch`, `ArchView`, `RenderArchSVG`, `loadInventory`, `FleetView`, `HostRows`, `openRegistry`, the active scenario loader used by `handleEconomics`.
+- Produces: `(*Server).handleArchitecture`, `(*Server).handleArchitectureRefresh`, `page.Arch *ArchView`, `page.ArchSVG template.HTML`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package console
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// TestFleetOperatorOnly's shape against /fleet/architecture: signed out,
+// signed in but not an operator, and a revoked operator all get the exact
+// 404 an unrouted path gets — byte-identical, so the route's existence is
+// not probeable.
+func TestArchitectureOperatorOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+
+ out := newBrowser(t)
+ ghostStatus, _, ghost := get(t, out, fx.ts.URL+"/this-route-does-not-exist")
+ if ghostStatus != http.StatusNotFound {
+ t.Fatalf("sanity: missing route = %d", ghostStatus)
+ }
+ if st, _, body := get(t, out, fx.ts.URL+"/fleet/architecture"); st != http.StatusNotFound || body != ghost {
+ t.Fatalf("signed-out = %d %q, want the same 404 as a missing route", st, body)
+ }
+ owner := signInPaul(t, fx)
+ if st, _, body := get(t, owner, fx.ts.URL+"/fleet/architecture"); st != http.StatusNotFound || body != ghost {
+ t.Fatalf("non-operator = %d %q, want the same 404", st, body)
+ }
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ st, _, body := get(t, owner, fx.ts.URL+"/fleet/architecture")
+ if st != 200 || !strings.Contains(body, "archsvg") {
+ t.Fatalf("operator view = %d, want 200 with the diagram:\n%s", st, body)
+ }
+ if err := fx.st.SetOperator("paul@keymail.dev", false); err != nil {
+ t.Fatal(err)
+ }
+ if st, _, body := get(t, owner, fx.ts.URL+"/fleet/architecture"); st != http.StatusNotFound || body != ghost {
+ t.Fatalf("revoked = %d %q, want the same 404", st, body)
+ }
+}
+
+// With no inventory ever taken, the page still renders and says WHICH of
+// the three states it is in — never a confident "no resources".
+func TestArchitecturePageWithoutInventory(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ st, _, body := get(t, c, fx.ts.URL+"/fleet/architecture")
+ if st != 200 {
+ t.Fatalf("= %d, want 200", st)
+ }
+ if !strings.Contains(body, "No inventory has been taken yet") {
+ t.Errorf("page must name the never-taken state:\n%s", body)
+ }
+ if strings.Contains(body, "no resources") {
+ t.Errorf("page must not claim an empty estate it never read")
+ }
+}
+
+// The refresh door: operator-gated, CSRF-checked, POST-only.
+func TestArchitectureRefreshDoor(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ called := 0
+ fx.srv.inventory = func(ctx context.Context) (infraview.Inventory, error) {
+ called++
+ return infraview.Inventory{Provider: "aws", TakenAt: time.Now().UTC()}, nil
+ }
+
+ // No CSRF token: refused, and the pass must not have run.
+ st, _, _ := postForm(t, c, fx.ts.URL+"/fleet/architecture/refresh", url.Values{})
+ if st != http.StatusForbidden {
+ t.Fatalf("no-CSRF refresh = %d, want 403", st)
+ }
+ if called != 0 {
+ t.Fatalf("a refused refresh must not run the pass")
+ }
+
+ // With the token: runs once and redirects back to the page.
+ csrf := sessionToken(t, fx, c)
+ st, loc, body := postForm(t, c, fx.ts.URL+"/fleet/architecture/refresh", url.Values{"csrf": {csrf}})
+ if st != http.StatusSeeOther || loc != "/fleet/architecture" {
+ t.Fatalf("refresh = %d -> %q, want 303 back to the page:\n%s", st, loc, body)
+ }
+ if called != 1 {
+ t.Fatalf("pass ran %d times, want 1", called)
+ }
+}
+```
+
+Helpers used above already exist: `sessionToken(t, fx, c)` and `postForm(t, c, url, values) (status int, location, body string)` — see `appdomains_test.go:27`. The test file imports `context`, `net/url`, `time` and `internal/infraview` on top of the usual set.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestArchitecture -count=1`
+Expected: FAIL — 404 on every request; the route is not registered.
+
+- [ ] **Step 3: Write `archpage.go`**
+
+```go
+// GET /fleet/architecture — the operator's map of the deployment: how a
+// request reaches a process, and everything the account is carrying that
+// makes that possible.
+//
+// The gate is handleFleet's, verbatim (server.go): session -> IsOperator ->
+// bare http.NotFound on EVERY failure including the store error. A styled
+// refusal would confirm the route exists, and this page names hostnames,
+// instance ids and the shape of the estate.
+//
+// Every read soft-fails on its own. The inventory, the fleet aggregate, the
+// per-box snapshots, the registry and the rate card are five independent
+// reads; any one of them having a bad moment costs its own section and a
+// line in the footer, never the page.
+package console
+
+import (
+ "log"
+ "net/http"
+
+ "github.com/carlosframework/platform/internal/economics"
+)
+
+func (s *Server) handleArchitecture(w http.ResponseWriter, r *http.Request) {
+ addr, raw, ok := s.operatorOnly(w, r)
+ if !ok {
+ return
+ }
+ in := ArchInput{}
+ inv, missing := s.loadInventory(r.Context())
+ in.Inv = inv
+
+ if f, err := FleetView(r.Context(), s.cfg.Releases); err != nil {
+ log.Printf("console: architecture: fleet view: %v", err)
+ } else {
+ in.Fleet = f
+ }
+ if hosts, err := HostRows(r.Context(), s.cfg.Releases, time.Now()); err != nil {
+ log.Printf("console: architecture: hosts: %v", err)
+ } else {
+ in.Hosts = hosts
+ }
+ if reg, err := openRegistry(s.cfg.Registry); err != nil {
+ log.Printf("console: architecture: registry: %v", err)
+ } else if routes, err := reg.List(); err != nil {
+ log.Printf("console: architecture: routes: %v", err)
+ } else {
+ in.Routes = routes
+ }
+ // The rate card comes from the SAME loader /fleet/economics uses
+ // (economicsview.go:748), so the two pages cannot disagree about what a
+ // box costs. Its warning is already surfaced on that page; here a bad
+ // scenario simply means the zero rate card, and every box then reports
+ // drift — which is the honest answer, not a silent $0.
+ sc, _ := s.loadActiveScenario(r.Context())
+ in.Rates = sc.RateCard
+
+ v := BuildArch(in)
+ if missing != "" {
+ v.Missing = missing
+ }
+ p := page{Title: "Architecture", Address: addr, CSRF: raw, Arch: &v, ArchSVG: RenderArchSVG(v)}
+ s.render(w, http.StatusOK, "architecture", p)
+}
+
+// handleArchitectureRefresh runs one inventory now. Single-flighted
+// (infraRefreshing): a leaned-on button must not stack passes against a
+// rate-limited API. It deliberately does NOT clear the auth latch — a
+// deployment without the IAM grant gets the same honest answer however many
+// times the button is pressed.
+func (s *Server) handleArchitectureRefresh(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.NotFound(w, r)
+ return
+ }
+ _, raw, ok := s.operatorOnly(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ if !s.infraRefreshing.CompareAndSwap(false, true) {
+ s.setFlash(w, "err", "An inventory is already running.")
+ http.Redirect(w, r, "/fleet/architecture", http.StatusSeeOther)
+ return
+ }
+ defer s.infraRefreshing.Store(false)
+
+ if err := s.infraPass(r.Context()); err != nil {
+ log.Printf("console: architecture refresh: %v", err)
+ s.setFlash(w, "err", "The inventory could not be refreshed.")
+ } else {
+ s.setFlash(w, "ok", "Inventory refreshed.")
+ }
+ http.Redirect(w, r, "/fleet/architecture", http.StatusSeeOther)
+}
+```
+
+Extract `operatorOnly(w, r) (addr, raw string, ok bool)` from `handleFleet` — the four-line gate, unchanged in behaviour — and have `handleFleet`, `handleFleetMetrics`, `handleEconomics` and both new handlers call it, so there is exactly one copy of the shape the tests pin. Extracting it must not change a single response: run the existing `TestFleetOperatorOnly` and `TestEconomicsOperatorOnly` before and after.
+
+`s.setFlash(w, kind, msg)` takes `"ok"` or `"err"` as its kind (`appdomains.go:57`).
+
+- [ ] **Step 4: Register the routes**
+
+In `internal/console/server.go` beside line 690:
+
+```go
+ s.mux.HandleFunc("GET /fleet/architecture", s.handleArchitecture)
+ s.mux.HandleFunc("POST /fleet/architecture/refresh", s.handleArchitectureRefresh)
+```
+
+And on the `page` struct, beside `Metrics`:
+
+```go
+ // /fleet/architecture (archgraph.go): the deployment's map. Arch is nil
+ // only on paths this handler never takes; ArchSVG is pre-rendered in Go
+ // rather than assembled in the template because the layout has to be
+ // deterministic and testable as a whole.
+ Arch *ArchView
+ ArchSVG template.HTML
+```
+
+- [ ] **Step 5: Write the template**
+
+`internal/console/templates/architecture.html`:
+
+```html
+{{define "architecture"}}{{template "top" .}}
+<h1>Architecture</h1>
+{{with .Arch}}
+<p class="small">
+ {{if .Missing}}
+ {{if eq .Missing "never"}}No inventory has been taken yet — the first pass runs within the hour.
+ {{else if eq .Missing "ungranted"}}This deployment has no infrastructure read grant, so the cloud half of this page is unavailable. The boxes below are what CARLOS itself reports.
+ {{else}}The inventory snapshot could not be read.{{end}}
+ {{else}}
+ Estate {{.Account}} · inventoried {{fmtTime .TakenAt}}
+ {{end}}
+</p>
+
+<div class="archwrap">{{$.ArchSVG}}</div>
+
+<h2>Boxes</h2>
+{{range .Boxes}}
+<section class="card">
+ <h3>{{.Label}}{{if .Region}} <span class="small">{{.Region}}</span>{{end}}</h3>
+ <p class="small">
+ {{if .InstanceType}}{{.InstanceType}} · {{end}}
+ {{if .MemGB}}{{.MemGB}} GB · {{end}}
+ {{if .DiscoveredDiskGB}}{{.DiscoveredDiskGB}} GB disk · {{end}}
+ {{if .USDMonth}}${{printf "%.0f" .USDMonth}}/mo{{end}}
+ {{if not .CloudID}} · no cloud machine (bring-your-own box){{end}}
+ </p>
+ <p class="small">Apps: {{if .Apps}}{{range .Apps}}<code>{{.}}</code> {{end}}{{else}}none{{end}}</p>
+ <p class="small">
+ static {{.Static}} · resident {{.Resident}} · hibernating {{.Hibernating}} ·
+ awake {{intOrDash .Awake}} · asleep {{intOrDash .Asleep}}
+ </p>
+ {{range .Drift}}<p class="small chip-warn">{{.}}</p>{{end}}
+</section>
+{{end}}
+
+{{range .Groups}}
+<h2>{{.Title}}</h2>
+<div class="tablewrap"><table>
+ <tr><th>Name</th><th>Type</th><th>Region</th><th>Detail</th></tr>
+ {{range .Rows}}<tr><td>{{.Name}}</td><td><code>{{.NativeType}}</code></td><td>{{.Region}}</td><td class="small">{{.Detail}}</td></tr>{{end}}
+</table></div>
+{{end}}
+
+{{if .Partial}}
+<h2>Reads that failed</h2>
+<ul class="small">{{range .Partial}}<li>{{.}}</li>{{end}}</ul>
+{{end}}
+{{end}}
+
+<form method="post" action="/fleet/architecture/refresh">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <button type="submit">Refresh inventory</button>
+</form>
+{{template "bottom" .}}{{end}}
+```
+
+(Check the closing template name the other operator pages use — `fleet.html`'s last line — and match it.)
+
+Add to `layout.html`'s style block, beside the other page-specific rules:
+
+```css
+ /* /fleet/architecture: the diagram. Colours come from the same tokens as
+ the rest of the console so it themes light and dark with no second
+ palette to keep in sync. */
+ .archwrap { overflow-x: auto; margin: 1rem 0 2rem; }
+ .archsvg { width: 100%; min-width: 720px; height: auto; }
+ .archsvg rect { fill: var(--accent-soft); stroke: var(--rule); }
+ .archsvg .archnode-box rect { stroke: var(--accent); }
+ .archsvg .archtitle { fill: var(--ink); font: 600 13px var(--sans); }
+ .archsvg .archsub { fill: var(--muted); font: 400 11px var(--sans); }
+ .archsvg .archedge { fill: none; stroke: var(--rule); stroke-width: 1.5; }
+```
+
+- [ ] **Step 6: Link it from the fleet page**
+
+At the top of `internal/console/templates/fleet.html`, after the `<h1>`:
+
+```html
+<p class="small"><a href="/fleet/architecture">See the architecture →</a></p>
+```
+
+- [ ] **Step 7: Run tests to verify they pass**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1`
+Expected: PASS — the three new tests and every existing console test.
+
+- [ ] **Step 8: Gate and commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
+GOOS=darwin GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
+gofmt -l internal/console
+git add internal/console cmd/console
+git commit -m "console: /fleet/architecture page, gate and refresh door
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 8: The IAM grant and the deploy note
+
+**Files:**
+- Modify: `infra/modules/region-host/main.tf` (the box role's `aws_iam_role_policy`)
+- Modify: `docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md` (mark the grant's status)
+
+**Interfaces:**
+- Consumes: nothing in Go.
+- Produces: the read-only inventory statement the pass needs.
+
+- [ ] **Step 1: Read the existing policy**
+
+Run: `sed -n '90,160p' infra/modules/region-host/main.tf`
+Note the existing `aws_iam_role_policy` document's shape and its `Statement` list — the new statement is appended to it, in its style.
+
+- [ ] **Step 2: Add the statement**
+
+```hcl
+ # Infrastructure inventory (2026-08-19-fleet-architecture-diagram-design.md
+ # §8): what the console's hourly infra pass needs to draw
+ # /fleet/architecture. Read-only in the strictest sense — every action
+ # here is a List/Describe/Get of a resource's SHAPE. No write action,
+ # no s3:GetObject, and deliberately no iam:GetPolicyVersion: the page
+ # shows that a role exists and how many policies are attached to it,
+ # never what those policies say.
+ {
+ Effect = "Allow"
+ Action = [
+ "ec2:DescribeInstances",
+ "ec2:DescribeVolumes",
+ "ec2:DescribeAddresses",
+ "ec2:DescribeSecurityGroups",
+ "ec2:DescribeVpcs",
+ "ec2:DescribeSubnets",
+ "ec2:DescribeRegions",
+ "tag:GetResources",
+ "s3:ListAllMyBuckets",
+ "s3:GetBucketLocation",
+ "s3:GetBucketVersioning",
+ "s3:GetEncryptionConfiguration",
+ "s3:GetBucketPublicAccessBlock",
+ "route53:ListHostedZones",
+ "route53:ListResourceRecordSets",
+ "iam:ListRoles",
+ "iam:ListUsers",
+ "iam:ListAttachedRolePolicies",
+ "cloudwatch:DescribeAlarms",
+ "acm:ListCertificates",
+ "sts:GetCallerIdentity",
+ ]
+ Resource = "*"
+ },
+```
+
+(`Resource = "*"` is required: every action listed is an account-wide List/Describe that AWS does not scope to a resource ARN.)
+
+- [ ] **Step 3: Validate the module**
+
+Run: `cd infra/modules/region-host && tofu fmt -check && tofu validate` (or `terraform` if that is what is installed; if neither is present, run `tofu fmt -check` only and note it).
+Expected: clean.
+
+- [ ] **Step 4: Record the rollout status in the spec**
+
+Append to §8 of the spec:
+
+```markdown
+**Status (2026-08-19):** the module statement is in this repo as of this
+branch. The flagship box runs a role provisioned from pinfra, so the grant
+reaches production only when the matching pinfra change is applied — until
+then `/fleet/architecture` renders the CARLOS half and reports the estate
+as ungranted, which is the designed state and not a fault.
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add infra/modules/region-host/main.tf docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md
+git commit -m "infra: read-only inventory grant for the architecture page
+
+Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
+```
+
+---
+
+### Task 9: Branch finish
+
+- [ ] **Step 1: Full gate, including the cross-compile CI runs**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
+GOOS=darwin GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
+gofmt -l internal/infraview internal/awsinv internal/console cmd/console
+```
+
+Expected: every command clean, `gofmt -l` printing nothing.
+
+- [ ] **Step 2: Confirm no AWS write reached the tree**
+
+```bash
+grep -rnE "\b(Create|Put|Delete|Modify|Terminate|Run|Attach|Detach|Tag|Untag)[A-Z]" internal/awsinv/*.go | grep -v _test
+```
+
+Expected: no output. Any hit is a mutating SDK call that must not be here.
+
+- [ ] **Step 3: Push the branch and open the PR**
+
+```bash
+git push -u origin "$(git branch --show-current)"
+gh pr create --title "Fleet architecture diagram: /fleet/architecture over a provider-neutral inventory" --body "$(cat <<'BODY'
+Adds the operator page the spec describes: how a request reaches a process,
+drawn over a live inventory of what the deployment's cloud account is
+carrying, with each box's size, cost, apps and instance counts.
+
+- `internal/infraview` — provider-neutral inventory shape (no SDK)
+- `internal/awsinv` — the AWS implementation; the only SDK importer, read-only throughout
+- `internal/console/infrapass.go` — hourly pass to `infra/inventory.json`, `cepull.go`'s latch posture
+- `internal/console/archgraph.go` / `archlayout.go` — joins, cost/drift, deterministic SVG
+- `GET /fleet/architecture` + `POST /fleet/architecture/refresh`, `handleFleet`'s gate
+- `infra/modules/region-host` — the read-only grant (needs a matching pinfra apply to reach flagship)
+
+Spec: `docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md`
+Plan: `docs/superpowers/plans/2026-08-19-fleet-architecture-diagram.md`
+
+Until the pinfra grant is applied the page renders the CARLOS half and
+reports the estate as ungranted — the designed state, covered by a test.
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+BODY
+)"
+```
+
+- [ ] **Step 4: Report what remains**
+
+State plainly in the handoff: the page has **not been seen in a browser**, and the AWS half has **not run against a real account** until the pinfra grant lands. Neither is a claim the tests can make.