carlos / platform Public

docs+module: fleet boxes without platform-bucket credentials

- fleet_byo.html: no-bucket env contract is now primary (CARLOS_FLEET,
  CARLOS_FLEET_BOX, CARLOS_REGION, CARLOS_TUNNEL_URL/NAME/TOKEN,
  CARLOS_HIBERNATE_BUCKET + own-bucket creds); explicit no-deployment-
  bucket/no-platform-creds line; CARLOS_STATUS_PREFIX moved out to an
  optional-override note; deployment-bucket path demoted to a "Legacy
  direct-S3 (grandfathered)" note. accountfleet_test.go pins the new note.
- region-pi.md: "flipping to the fleet API" note — drop
  CARLOS_DEPLOYMENT_BUCKET + the platform-bucket credential half once
  worktree-fleet-api merges and rolls; CARLOS_STATUS_PREFIX becomes
  optional.
- eleven-fleet.md: preconditions reworded from "not yet built" to "built
  on branch worktree-fleet-api, pending merge + edge roll" for both the
  edge door and the region-remote-host module change.
- region-remote-host module: deployment_bucket_arn now optional (default
  ""); its IAM statement moved to a separate aws_iam_user_policy resource
  gated by count, emitted only when the ARN is set (matches the module's
  existing host_route53 optional-statement pattern in region-host).
  README reframed: fleet-API consumers need no deployment-bucket grant at
  all; grandfathered direct-S3 paragraph kept. tofu validate clean.
- spec status line: all six implementation tasks built on this branch,
  pending merge + edge roll.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell c01221642d97c024a67cb8a85bfb5ed206fdb572 parent b0e2e54
8 files changed, +109 −31
  • docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md +7 −3
  • infra/dev/region-pi.md +12 −0
  • infra/eleven-fleet.md +12 −9
  • infra/modules/region-remote-host/README.md +20 −3
  • infra/modules/region-remote-host/main.tf +33 −13
  • infra/modules/region-remote-host/variables.tf +2 −1
  • internal/console/accountfleet_test.go +7 −0
  • internal/console/templates/fleet_byo.html +16 −2
diff --git a/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md b/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md
index f4d583723..a56c06867 100644
--- a/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md
+++ b/docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md
@@ -1,9 +1,13 @@
# Fleet API Surface: the deployment bucket behind the edge (issue #73)
**Date:** 2026-08-11
-**Status:** Draft — designed autonomously in the unattended fleets session;
-Paul pre-approved the direction in issue #73 (2026-08-07: "don't paper over
-it with a bucket policy — abstract the bucket behind the carlos API").
+**Status:** All six implementation tasks built on branch `worktree-fleet-api`
+(edge store door, `internal/fleetapi` client, host env-driven store
+selection + F6 retention skip, this doc + BYO page + region-pi.md +
+region-remote-host module updates) — pending merge to `main` and the edge
+roll; designed autonomously in the unattended fleets session, Paul
+pre-approved the direction in issue #73 (2026-08-07: "don't paper over it
+with a bucket policy — abstract the bucket behind the carlos API").
**Depends on:** customer fleets v1+v2 (merged, main 00c3344); the tunnel
dynamic-auth table (edge/tunnel.go).
**Closes:** #73. **Unblocks:** untrusted-customer fleets, the Amadan
diff --git a/infra/dev/region-pi.md b/infra/dev/region-pi.md
index d0f48c86f..7a03a9f54 100644
--- a/infra/dev/region-pi.md
+++ b/infra/dev/region-pi.md
@@ -41,6 +41,18 @@ Follow infra/remote-host.md with these dev-platform values:
shows UNREACHABLE in the fleet view and `carlos status -live`; instances
resume when it re-dials. This is the approved demo posture.
+**Flipping to the fleet API (issue #73, branch `worktree-fleet-api`, not yet
+merged):** once that branch is on `main` and rolled to the dev edge, drop
+`CARLOS_DEPLOYMENT_BUCKET` from the standard agent env on this box, and drop
+the platform-bucket half of `region-remote-host`'s IAM grant
+(`deployment_bucket_arn` unset on the `pi-fleet` tofu root — the module
+defaults it to `""` and simply omits that statement). Keep
+`AWS_SHARED_CREDENTIALS_FILE`/`CARLOS_HIBERNATE_BUCKET` exactly as above —
+the Pi still owns its data bucket directly. `CARLOS_STATUS_PREFIX` becomes
+optional at that point too (an unset value defaults to `fleets/<name>/` on
+the door branch); keep the explicit line above until this doc is updated
+post-merge, or drop it once verified.
+
Seeding: scripts/seed-pi-demo.sh (batches of ≤10/day — console limiter;
re-running an already-seeded batch is safe but not free, since the limiter
counts attempts, not successes, so only re-run a batch that actually
diff --git a/infra/eleven-fleet.md b/infra/eleven-fleet.md
index 4c10f803f..0495eaba8 100644
--- a/infra/eleven-fleet.md
+++ b/infra/eleven-fleet.md
@@ -17,17 +17,20 @@ Preconditions). Not a data migration: there is no data to migrate.
## Preconditions (must be true before Step 1)
- [ ] **#73 (fleet API surface) merged and deployed to Carloku's flagship
- edge.** *Not true at time of writing* — branch `worktree-fleet-api` has
- the design (`docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md`)
- and the first edge piece (`VerifyDynamic`, `2c9f697`/`b2b0867`), but the
+ edge.** *Built on branch `worktree-fleet-api`, pending merge + edge
+ roll* — the design
+ (`docs/superpowers/specs/2026-08-11-fleet-api-surface-design.md`), the
store door (`/store/o/<key>` etc.), the `internal/fleetapi` client, and
- the env-driven store selection in `openDeployStore` aren't built yet.
- Without it a fleet box still needs RW on Carloku's platform deployment
- bucket to run — unacceptable for boxes in Tito's own AWS account.
+ the env-driven store selection in `openDeployStore` are all built and
+ tested on that branch, but nothing has merged to `main` or shipped to
+ Carloku's edge yet. Without a deployed edge a fleet box still needs RW
+ on Carloku's platform deployment bucket to run — unacceptable for
+ boxes in Tito's own AWS account.
- [ ] **`region-remote-host`'s `deployment_bucket_arn` is optional.** Also
- #73 rollout item 4 — today the variable is required, no default
- (`infra/modules/region-remote-host/variables.tf`). Eleven's tofu root
- must be able to omit the grant.
+ built on branch `worktree-fleet-api` (`infra/modules/region-remote-host/
+ variables.tf` now defaults it to `""`, IAM statement emitted only when
+ set) but not yet merged. Eleven's tofu root must be able to omit the
+ grant, which requires that branch's module on `main`.
- [ ] **Eleven's account exists on Carloku**, passkey-reachable, apps already
created under it (`carlos accounts create -name eleven -owner
<address>`, then the normal `apps create` flow) — currently unplaced,
diff --git a/infra/modules/region-remote-host/README.md b/infra/modules/region-remote-host/README.md
index e68f31059..aceb8ff16 100644
--- a/infra/modules/region-remote-host/README.md
+++ b/infra/modules/region-remote-host/README.md
@@ -2,14 +2,31 @@
Provisions what an off-cloud CARLOS host needs when there is no EC2 instance
profile to lean on: a region data bucket plus an IAM user carrying a scoped
-inline policy and access key, limited to exactly the deployment bucket and
-this region's data bucket (wrong-region-fails-at-IAM, the same residency
-property `region-host` gives cloud hosts).
+inline policy and access key, limited to this region's data bucket and
+nothing else (wrong-region-fails-at-IAM, the same residency property
+`region-host` gives cloud hosts).
Consumed by customer fleets (paulca/pi-fleet is the live example) and
the platform's tito hq box. The module is reusable as-is for both — they
differ only in account and variable values, not shape.
+**`deployment_bucket_arn` is optional (default `""`).** The fleet API
+surface (issue #73) means a fleet box no longer needs any IAM grant on the
+platform's deployment bucket at all: it reads releases and status, and
+writes heartbeats, through the console's edge (`/store/o/...`) using the
+same tunnel token it already dials with — the door authenticates and
+scopes the request, not a bucket-level AWS credential. That is the shape
+every new fleet-API consumer's tofu root should apply with: leave
+`deployment_bucket_arn` unset, and the module emits no deployment-bucket
+IAM statement at all.
+
+The variable still exists for **grandfathered direct-S3 boxes** — ones
+still running the pre-#73 agent, or deliberately kept off the fleet API —
+which need RW straight on the deployment bucket exactly as before; set
+`deployment_bucket_arn` explicitly for those and the module emits the
+extra scoped statement (`aws_iam_user_policy.host_deployment_bucket`) on
+top of the always-present own-data-bucket grant.
+
The S3 action list in the inline policy is not independently chosen: it is
copied verbatim from `infra/modules/region-host/main.tf`'s
`aws_iam_role_policy.host_buckets` (the authority for what this principal
diff --git a/infra/modules/region-remote-host/main.tf b/infra/modules/region-remote-host/main.tf
index 90b74d912..64da92b08 100644
--- a/infra/modules/region-remote-host/main.tf
+++ b/infra/modules/region-remote-host/main.tf
@@ -3,9 +3,11 @@
# IAM principal, since there is no EC2 instance profile to lean on off-cloud.
#
# Residency is enforced the same way as region-host: the principal reaches
-# the deployment bucket plus ITS OWN region's data bucket and nothing else.
-# A wrong-region host fails loudly at the IAM layer, same property as every
-# cloud host.
+# ITS OWN region's data bucket and nothing else. A wrong-region host fails
+# loudly at the IAM layer, same property as every cloud host. The deployment
+# bucket is grandfathered-optional (var.deployment_bucket_arn, default "")
+# — a fleet-API box needs no platform-bucket grant at all; see the
+# aws_iam_user_policy.host_deployment_bucket comment below.
#
# The data bucket follows the deployment-buckets convention for this class
# of bucket (infra/modules/README.md, infra/modules/deployment-buckets/
@@ -58,8 +60,8 @@ resource "aws_iam_access_key" "host" {
user = aws_iam_user.host.name
}
-# Scope: the deployment bucket + THIS region's data bucket and nothing
-# else — wrong-region-fails-at-IAM, same property as every cloud host.
+# Scope: THIS region's data bucket, always — wrong-region-fails-at-IAM, same
+# property as every cloud host.
#
# The action list is not independently chosen: region-host's
# aws_iam_role_policy.host_buckets (infra/modules/region-host/main.tf) is
@@ -74,28 +76,46 @@ resource "aws_iam_user_policy" "host" {
Version = "2012-10-17"
Statement = [
{
- Sid = "DeploymentBucketObjects"
+ Sid = "OwnRegionDataObjects"
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
- Resource = ["${var.deployment_bucket_arn}/*"]
+ Resource = ["${aws_s3_bucket.data.arn}/*"]
},
{
- Sid = "DeploymentBucketList"
+ Sid = "OwnRegionDataList"
Effect = "Allow"
Action = ["s3:ListBucket", "s3:GetBucketLocation"]
- Resource = [var.deployment_bucket_arn]
+ Resource = [aws_s3_bucket.data.arn]
},
+ ]
+ })
+}
+
+# Deployment-bucket grant: GRANDFATHERED, direct-S3 boxes only. A fleet-API
+# box (issue #73) never needs this — it reaches the platform's deployment
+# bucket through the console's edge, over the same tunnel token it dials
+# with, so var.deployment_bucket_arn is left "" and this whole statement
+# (and the IAM policy resource carrying it) is omitted. count, not a dynamic
+# block, matches this module's own aws_iam_role_policy.host_route53 pattern
+# for an optional statement.
+resource "aws_iam_user_policy" "host_deployment_bucket" {
+ count = var.deployment_bucket_arn != "" ? 1 : 0
+ name = "carlos-remote-host-${var.region_code}-deployment-bucket"
+ user = aws_iam_user.host.name
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
{
- Sid = "OwnRegionDataObjects"
+ Sid = "DeploymentBucketObjects"
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
- Resource = ["${aws_s3_bucket.data.arn}/*"]
+ Resource = ["${var.deployment_bucket_arn}/*"]
},
{
- Sid = "OwnRegionDataList"
+ Sid = "DeploymentBucketList"
Effect = "Allow"
Action = ["s3:ListBucket", "s3:GetBucketLocation"]
- Resource = [aws_s3_bucket.data.arn]
+ Resource = [var.deployment_bucket_arn]
},
]
})
diff --git a/infra/modules/region-remote-host/variables.tf b/infra/modules/region-remote-host/variables.tf
index 8b1240278..2096ca518 100644
--- a/infra/modules/region-remote-host/variables.tf
+++ b/infra/modules/region-remote-host/variables.tf
@@ -9,6 +9,7 @@ variable "data_bucket_name" {
}
variable "deployment_bucket_arn" {
- description = "ARN of the existing deployment bucket the host reads releases/status from and writes heartbeats to."
+ description = "ARN of the existing deployment bucket the host reads releases/status from and writes heartbeats to. Optional (default \"\"): a fleet-API box (fleet API surface, issue #73) reaches the platform's deployment bucket over the console's edge with its tunnel token, not a direct IAM grant — leave this unset and no deployment-bucket statement is emitted. Only a grandfathered direct-S3 box needs it set."
type = string
+ default = ""
}
diff --git a/internal/console/accountfleet_test.go b/internal/console/accountfleet_test.go
index 1cd589a81..800664bb0 100644
--- a/internal/console/accountfleet_test.go
+++ b/internal/console/accountfleet_test.go
@@ -359,6 +359,13 @@ func TestFleetBYOPageMemberReadable(t *testing.T) {
t.Fatalf("BYO page missing %q in:\n%s", want, body)
}
}
+ // The primary env contract is bucketless: no platform-bucket var or
+ // credential belongs there (the box reaches releases/heartbeats through
+ // the console's edge with its tunnel token). CARLOS_DEPLOYMENT_BUCKET
+ // still appears, but only inside the demoted legacy-direct-S3 note.
+ if !strings.Contains(body, "Legacy direct-S3") {
+ t.Fatalf("BYO page missing the legacy direct-S3 note in:\n%s", body)
+ }
}
// TestAccountFleetDegradesOnStoreFailure: a bucket having a moment must cost
diff --git a/internal/console/templates/fleet_byo.html b/internal/console/templates/fleet_byo.html
index 5d88b38c2..b034e5230 100644
--- a/internal/console/templates/fleet_byo.html
+++ b/internal/console/templates/fleet_byo.html
@@ -31,13 +31,27 @@ CARLOS_HIBERNATE_BUCKET=&lt;your-bucket&gt;
CARLOS_FLEET=&lt;name&gt;
CARLOS_FLEET_BOX=&lt;label&gt;
CARLOS_REGION=fleet/&lt;name&gt;
-CARLOS_STATUS_PREFIX=fleets/&lt;name&gt;/
CARLOS_TUNNEL_URL=&lt;this deployment's tunnel address&gt;
CARLOS_TUNNEL_TOKEN=&lt;from carlos fleets add-box &lt;name&gt; &lt;label&gt; — shown once&gt;
CARLOS_TUNNEL_NAME=&lt;name&gt;/&lt;label&gt;</code></pre>
<p class="small"><code>CARLOS_FLEET_BOX</code> is optional and defaults from
<code>CARLOS_TUNNEL_NAME</code>'s label half. <code>AWS_SHARED_CREDENTIALS_FILE</code> points at
-an access key for the bucket above, written root-owned mode 600 alongside the env file.</p>
+an access key for the bucket above — <strong>your own bucket only</strong> — written root-owned
+mode 600 alongside the env file.</p>
+<p class="small">No <code>CARLOS_DEPLOYMENT_BUCKET</code>, and no AWS credentials for the
+platform's own bucket, belong in this file at all: the box reads its app releases and writes its
+heartbeats through the console's edge, over HTTPS, using the same tunnel token it already dials
+in with — not a bucket-level AWS grant.</p>
+<p class="small">Optional override: <code>CARLOS_STATUS_PREFIX=fleets/&lt;name&gt;/</code> — the box
+defaults to this on its own; set it explicitly only if you're pointing status/heartbeats
+somewhere non-standard.</p>
+
+<h3>Legacy direct-S3 (grandfathered)</h3>
+<p class="small">A box still running a pre-fleet-API agent build needs
+<code>CARLOS_DEPLOYMENT_BUCKET</code> set to the platform's own deployment bucket, plus an AWS
+credential granting it RW there, in addition to the hibernate-bucket vars above. New boxes should
+not be configured this way — it is kept working for boxes that haven't upgraded yet, not as a
+choice.</p>
<h2>Hardening</h2>
<ul>