carlos / platform Public

Architecture review at de6c46e7: well-layered, not tangled, six places to consolidate Idea open

James asked whether the platform codebase is well-factored, coherent and composable after nine weeks of broad and deep development, or whether it has become a tangled mess. This is the written-up answer. It was measured against worktree HEAD de6c46e7 (deploy-exciting-20261006: main plus the three instance-catchall branches merged on 2026-10-06), so line numbers below are as of that commit.

Method: go list for the import graph, line counts per package, grep-based structural measures (interfaces, env reads, route registrations, type assertions, exported names), and a full go build, go vet, gofmt -l and go test ./... -count=1 on this box.

Verdict

Not a tangled mess. The dependency graph is clean and genuinely layered, the seams that matter are real interfaces with tests behind them, and the security-critical surface is gated structurally rather than by convention. The debt is real but concentrated in about six places, all of them nameable and mostly mechanical to fix. The pattern is "grown fast along sound lines, never consolidated", not spaghetti.

| Measure | Value | |---|---| | Production Go | 189K lines, 1196 files, 66 internal packages plus the root CLI | | Test Go | 250K lines, 6510 test functions | | Commits | 2282 in 66 days (1619 in August, 609 in September, 54 so far in October) | | go build, go vet, gofmt -l . | all clean | | go test ./... -count=1 | one failure, an environment leak on this box (see below) | | Comment to code ratio | 0.54 overall; above 0.8 in edge, host, appconfig, provision | | TODO / FIXME / XXX | 0 | | Binaries (-s -w) | carlos 37 MB, console 74 MB |

What is holding up well

  • Layering is genuine. Leaf vocabulary packages at the bottom (registry, s3compat, controlname, caps, gate, geo), the bucket store above them, domain packages, then edge, activator and provision, then the three composition roots: host, console and the CLI. I found only two upward edges, both inside internal/release (finding 1).
  • The seams are interfaces, and they are used. 72 interfaces in production code, mostly narrow wrappers around AWS SDKs or process control (awsinv, mail, objstore, steering, activator). The bucket store (internal/release/store.go:42) is five methods. Its directory implementation appears in 184 test files; only 8 test files need MinIO. The activator is the best-factored large package: a Config, a Service, and six injected interfaces (Backend, DueStore, RouteLookup, Lease, Rep, Proc).
  • The API gate is structural. All 125 CLI doors register through cliRoute (internal/console/authcli.go:416), which binds the permitted roles at registration and runs bearer, principal and role checks before the handler sees the request. svcroutes_test.go pins the table. Browser handlers open with a small family of named gate helpers; apiAccountApp is the first call in 59 of them. This is lower risk than a 54K-line package suggests.
  • Low incidental mess. Helper duplication is minimal (dataDir x4, openStore x3), only 18 package-level function variables used as test seams, one init(), and nearly every package carries a why-paragraph citing its design.
  • The CLI/console contract is exercised for real. Root-package tests run the built binary 97 times and call verbs in-process 122 times against an in-process console and a directory bucket. consoleFixture is shared by 75 of the 158 console test files.

Where it is tangled

1. internal/release is a grab-bag wearing the hub's name. 34 files, 9.3K lines, 675 exported names, imported by 32 packages. About half of those importers use nothing but Store, ErrCASConflict and AppPrefix. Alongside the release plane (manifests, pointers, ladder, grants, bake) it holds trash markers, feature flags, retention, restart-gen, restart-scope, sidecar-wake, the platform's own system pointer, worker-control records, the on-box version store, cache eviction, MaterializeHost, and the adopt pass, which walks registry.Route rows. That is why the package imports registry and maintenanceguard upward. Pulling the store and key builders into their own package would take most of the fan-in with them and let "release" mean release.

2. The console is one flat 54K-line package. 117 non-test files, a Server struct (internal/console/server.go:746) with about 60 fields and 641 methods, 227 handle* methods, 2129 exported names. server.go is 4.7K lines and was touched by 74 of the last 300 commits. The console is also a batch runner: serve() in cmd/console/main.go starts ten background passes (alert watcher, economics, infra, scan, relay, mail event push, door-key resign, apex squats, placement discrepancies). The file-name prefixes (api* 32 files, region* 10, instance* 5, arch* 5, store* 4, domain* 4) already show the sub-packages it wants to be.

3. The CLI and console redeclare the wire contract by hand. The console emits 70 of its 148 JSON responses as map[string]any literals and decodes 39 anonymous inline request structs. The CLI mirrors them in 67 private api* structs across 4.4K lines and 147 functions in apimode.go. There is no shared wire package and no API version segment; compatibility is handled door by door in comments ("the original restart door had no request body; keep accepting those clients"). A handful of doors already share types through dbdoor, ledger and schedule, so the pattern exists; it is applied to roughly a quarter of the surface. The e2e tests are the only thing preventing drift, which is a large part of why the root package's tests take 424 s.

4. Configuration is read where it is used. 105 distinct CARLOS_* and 12 CONSOLE_* variables. internal/host reads env in 14 files (37 reads in agent.go alone) and has no Config struct; RunAgent is 400 lines launching about twenty goroutines. There are 61 *Loop/*Pass/*Tick/*Sweep functions across the tree, 36 of them in host and 13 in console. cmd/console/main.go has 30 small env-parsing helpers. Nothing is wrong in isolation, but no single place states what a box or a console can be told.

5. The registry row is a god row. registry.Route has 42 columns after 35 ALTER TABLE migrations. Kind, Backing, Platform, Canary, Replica, Tunnel, FleetBox, Caps, Steering, Gate, Unit each encode a different feature's shape in one record. The adoption rule in AGENTS.md is a prose list of which pass may touch which columns; it exists because the type cannot express it.

6. Half-landed work has reached the core. Eight packages were first committed on 2026-10-06 for the host-lifecycle work (hostclaim, hostguard, park, genselect, stopevidence, termfence, hostfiles, boxid), about 6.5K production lines. Four of them (park, hostfiles, stopevidence, termfence) have no production importer, and hostguard is reachable only through park. That is a staged landing, not rot, and #236 is the open thread for it; it is worth knowing that a third of today's new code is not yet linked into any binary. Separately, the September maintenance workstream brought maintenance, maintenanceexec, maintenanceguard, privatefile, launchctx as a nested Go module with a replace directive, and an implementation/ directory of proposals in a markedly different voice from the rest of the tree. Nothing in a binary reaches maintenanceexec, privatefile, or the WorkerControl/WorkerAssignment records, yet release already imports maintenanceguard, so the hub depends on a nested module for a protocol that is not live. internal/controlname is now an 8-line shim over launchctx/controlname.

7. Smaller coherence issues.

  • The etag-watched "generation object" is implemented three times (restartgen.go, sidecarwake.go, appconfig/generation.go), each about 100 lines, and 33 comments across the tree say some variant of "the same mechanism as". Each copy is one more place to get CAS wrong.
  • "Host" means a box in internal/host and an instance hostname in instances/<host>.json and hostclaim. "Fleet" means the whole deployment on the status page and /fleet, and an account-owned group of remote boxes in registry.Fleet and carlos fleets. carlos instances lists routes on a box but instance records through the console.
  • The code cites 30 design specs by path (105 citations); 26 of them are not in this repo. The bucket layout, which is the control-plane schema, lives in ten separate "Bucket layout" comment blocks and 179 key-builder functions across 15 packages, with no single document. The README is a sequence of dated change sections rather than an overview.
  • The one-binary design means the laptop CLI links a TURN server (pion/turn), litestream, modernc sqlite and the IAM, Route53 and CloudWatch SDKs.

The test run

The one failure is TestReadReplicaClientResolvesAnEmptyRegionLikeRestore in internal/lease. fakeCreds (internal/lease/endpoint_test.go:86) clears AWS_REGION and AWS_DEFAULT_REGION but not AWS_CONFIG_FILE, so the SDK's default chain reads this box's ~/.aws/config (region = eu-west-1) and the test, which expects litestream's non-AWS default, fails. It passes with the file masked and fails without:

AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null \
  go test ./internal/lease -run TestReadReplicaClientResolvesAnEmptyRegionLikeRestore -count=1   # ok
go test ./internal/lease -run TestReadReplicaClientResolvesAnEmptyRegionLikeRestore -count=1     # FAIL

Not a product bug: a test-isolation gap of the same class as #226 and the carlosTestEnv rule. CI stays green only because the runner has no AWS profile.

What I would do first

Ranked by leverage:

  1. Extract the bucket store and key builders from release into their own package (Store, DirStore, S3Store, prefixStore, ErrCASConflict, the *Key/*Prefix builders). Mechanical, and it makes "release" mean release.
  2. A shared wire-types package for the CLI API, migrating doors one at a time from map literals to typed responses and deleting the mirrored api* structs as each lands. This removes the largest class of silent drift.
  3. Move the ten background passes out of the console web server, then split the console along its existing prefixes.
  4. Decide the maintenance and launchctx workstream's fate: wire it or quarantine it, so the hub does not depend on a nested module for dead code.
  5. Build a host.Config once in RunAgent from env and pass it to the loops.
  6. Mask AWS_CONFIG_FILE and AWS_SHARED_CREDENTIALS_FILE in fakeCreds.
  7. One in-repo bucket-layout document, and mirror or publish the cited specs.

Reproducing the numbers

go list -f '{{.ImportPath}} {{join .Imports " "}}' ./...            # import graph
grep -rE '^\s*type\s+\w+\s+interface' --include='*.go' --exclude='*_test.go' .   # 72
grep -hE '^func \(s \*Server\)' internal/console/*.go | grep -v _test | wc -l     # 641
grep -h 's\.cliRoute(' internal/console/*.go | grep -v _test | wc -l              # 125
grep -hE 'writeJSON\(w, [^,]+, map\[string\]any\{' internal/console/*.go | wc -l  # 70
grep -cE '^type api\w+ struct' apimode.go                                         # 67
grep -rhoE '"CARLOS_[A-Z0-9_]+"' --include='*.go' --exclude='*_test.go' . | sort -u | wc -l   # 105
awk '/^type Route struct/,/^}/' internal/registry/registry.go | grep -vE '^\s*(//|$|type|})' | wc -l  # 42