carlos / platform Public

system: carlos system box publish-record (copy and census pending)

The host claim's enrollment needs each box's AWS-signed identity record at
control/boxes/<box>.json. Its only writer, publishBoxRecord, runs only from
boxRecordLoop behind CARLOS_REGION_HA, which stays off on dev. The dev
claim trial needs records for leo-climbing and sophie-knitting, published
once by a reviewed command.

The verb runs on the box, as root or the agent user with host.env loaded,
and makes one publishBoxRecord attempt with the box's own label, without
the region HA gate. It refuses on a fleet box, succeeds as a no-op when a
record for this instance is already there, and refuses, changing nothing,
when a record for another instance holds the name. publishBoxRecord gets
boxRecordStore, which admits only Get and create-only Put on the one key
and refuses Delete, List and replace-only Put before the bucket.
boxrecord.go is unchanged.

Tests run the real S3Store against leasetest's S3 server behind a fake of
the dev bucket's create-only, never-delete policy, and pin boxRecordLoop's
gate. Census: envelope-only plan 14 overlay and one 9B8 entry, both marked
PROPOSED-box-publish-record for platform-1e. Copy is drafted, not yet
reviewed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ea68e8a6904d478973c4c44291d0c628ce022b90 parent b111892
11 files changed, +729 −1
  • docs/site/cli.md +10 −0
  • docs/superpowers/notes/census-proposal-box-publish-record.md +114 −0
  • internal/host/boxpublish.go +166 −0
  • internal/host/boxpublish_test.go +367 −0
  • internal/provision/box_publish_record_census_test.go +16 −0
  • internal/provision/plan14_root_reads_test.go +7 −0
  • internal/provision/plan9_b8_census_test.go +1 −1
  • internal/provision/testdata/plan14-root-reads-box-publish-record.tsv +6 −0
  • system.go +5 −0
  • system_roles.go +8 −0
  • system_roles_test.go +29 −0

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.012s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	324.692s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.970s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.356s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.712s
ok  	github.com/carlosframework/platform/internal/activator	251.150s
ok  	github.com/carlosframework/platform/internal/agentsock	0.019s
ok  	github.com/carlosframework/platform/internal/alerting	0.005s
ok  	github.com/carlosframework/platform/internal/analytics	0.019s
ok  	github.com/carlosframework/platform/internal/appconfig	2.487s
ok  	github.com/carlosframework/platform/internal/apperr	0.083s
ok  	github.com/carlosframework/platform/internal/applog	0.149s
ok  	github.com/carlosframework/platform/internal/awsinv	0.006s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.005s
ok  	github.com/carlosframework/platform/internal/boxid	0.842s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.014s
ok  	github.com/carlosframework/platform/internal/certwarm	0.005s
ok  	github.com/carlosframework/platform/internal/cgroup	0.713s
ok  	github.com/carlosframework/platform/internal/changelog	0.345s
ok  	github.com/carlosframework/platform/internal/claimactivation	0.031s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	406.610s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	5.615s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.005s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.003s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.396s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	44.930s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.008s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.134s
ok  	github.com/carlosframework/platform/internal/gate	1.656s
ok  	github.com/carlosframework/platform/internal/genselect	0.014s
ok  	github.com/carlosframework/platform/internal/geo	0.017s
ok  	github.com/carlosframework/platform/internal/geoip	0.030s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.157s
ok  	github.com/carlosframework/platform/internal/host	179.899s
ok  	github.com/carlosframework/platform/internal/hostactors	5.909s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.006s
ok  	github.com/carlosframework/platform/internal/hostbox	2.762s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.589s
ok  	github.com/carlosframework/platform/internal/hostenv	0.005s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.006s
ok  	github.com/carlosframework/platform/internal/hostexec	5.025s
ok  	github.com/carlosframework/platform/internal/hostfence	0.034s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.491s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.013s
ok  	github.com/carlosframework/platform/internal/hostguard	1.001s
ok  	github.com/carlosframework/platform/internal/hostops	1.641s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.002s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.002s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.492s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.166s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.004s
ok  	github.com/carlosframework/platform/internal/infraview	0.002s
ok  	github.com/carlosframework/platform/internal/instancectl	5.225s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.766s
ok  	github.com/carlosframework/platform/internal/instancefs	0.020s
ok  	github.com/carlosframework/platform/internal/instancekey	0.017s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.022s
ok  	github.com/carlosframework/platform/internal/layout	1.593s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.018s
ok  	github.com/carlosframework/platform/internal/lease	9.821s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.008s
ok  	github.com/carlosframework/platform/internal/ledger	0.012s
ok  	github.com/carlosframework/platform/internal/mail	0.191s
ok  	github.com/carlosframework/platform/internal/maintenance	0.081s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.533s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.092s
ok  	github.com/carlosframework/platform/internal/metrics	0.007s
ok  	github.com/carlosframework/platform/internal/moves	21.187s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.799s
ok  	github.com/carlosframework/platform/internal/objstore	0.211s
ok  	github.com/carlosframework/platform/internal/ops	0.188s
ok  	github.com/carlosframework/platform/internal/park	0.206s
ok  	github.com/carlosframework/platform/internal/privatefile	0.009s
ok  	github.com/carlosframework/platform/internal/provision	38.494s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.011s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.531s
ok  	github.com/carlosframework/platform/internal/relay	7.237s
ok  	github.com/carlosframework/platform/internal/release	22.830s
ok  	github.com/carlosframework/platform/internal/rollout	3.811s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.013s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.063s
ok  	github.com/carlosframework/platform/internal/rootstate	0.026s
ok  	github.com/carlosframework/platform/internal/runs	0.561s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.527s
ok  	github.com/carlosframework/platform/internal/seeds	0.012s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	10.307s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.151s
ok  	github.com/carlosframework/platform/internal/status	0.062s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.389s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.005s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.583s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 49283234 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
  minio: downloaded, digest verified
  mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
  mc: downloaded, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.024s
=== RUN   TestT4StorageProbeMinio
2026/10/18 00:36:41 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF789F14E00EC4, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.022s
minio contract: PASS
diff --git a/docs/site/cli.md b/docs/site/cli.md
index f5156ce8a..c637893a6 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -2264,6 +2264,16 @@ carlos system group role ie default
Ops account owners and `CARLOS_SYSTEM_OPERATORS`.
+#### A box's identity record
+
+carlos system box publish-record publishes the box's identity record to the deployment bucket, at control/boxes/<box>.json, where the host claim's enrollment reads it. The record is the box's EC2 instance identity document and AWS's signature over it, so AWS vouches for the box, not the box itself or you. The agent publishes it by itself only in a region with HA on. Anywhere else, run this once on the box, as root or the agent user, with the box's host.env loaded.
+
+It only ever creates the record. If the box's own record is already there, it says so and writes nothing. If a record for another instance holds the box's name, it refuses and changes nothing. A fleet box publishes no record, so it refuses there too.
+
+```sh
+carlos system box publish-record
+```
+
#### Boxes a region's group launches
In a region with HA on, the boxes its Auto Scaling group launches set themselves up with no hand steps. A box's start-up runs two commands as root before it starts the agent.
diff --git a/docs/superpowers/notes/census-proposal-box-publish-record.md b/docs/superpowers/notes/census-proposal-box-publish-record.md
new file mode 100644
index 000000000..ed08629bc
--- /dev/null
+++ b/docs/superpowers/notes/census-proposal-box-publish-record.md
@@ -0,0 +1,114 @@
+# Census proposal: carlos system box publish-record
+
+Status: PROPOSED-box-publish-record. platform-1e rules on every row here, as
+the host claim's owner.
+
+Branch `box-publish-record`, cut from main at b1118925 (region HA 2b).
+
+## What changed
+
+`carlos system box publish-record` makes one `publishBoxRecord` attempt on a
+box, by hand, without `boxRecordLoop`'s `CARLOS_REGION_HA` gate. The dev claim
+trial needs records at `control/boxes/<box>.json` for leo-climbing
+(carlos-dev-ie-b) and sophie-knitting (carlos-dev-ie), and region HA stays off
+on dev.
+
+- `internal/host/boxpublish.go` (new): `RunSystemBoxPublishRecord`,
+ `publishRecordOnce`, `boxRecordStore` and the verb's lines.
+- `system_roles.go`: `runSystemBoxVerb` hands `box publish-record` to the
+ host verb before `roleArgs`.
+- `system.go`: `systemUsage` gains the synopsis and description lines.
+- `internal/host/boxrecord.go` is not edited. `publishBoxRecord`,
+ `boxRecordLoop` and its gate are byte for byte main's.
+
+## Root-read census (TestPlan14RootReadCensus)
+
+Overlay `internal/provision/testdata/plan14-root-reads-box-publish-record.tsv`,
+loaded after `plan14-root-reads-region-ha-2b.tsv`.
+
+The scan finds no read site in any new or changed unit, so no root-read unit
+is added or changed. The only discrepancies were the two source envelopes.
+
+| Unit | Proposed disposition | Reason |
+|---|---|---|
+| `@coverage .` | re-pin to `c4e6efe2…a179` | `system.go` (usage lines) and `system_roles.go` (dispatch) changed. Neither adds a read. |
+| `@coverage internal/host` | re-pin to `03d139a8…9327` | `boxpublish.go` is new. Its units have no read site of their own. |
+| legacy seal | unchanged (`fc584e11…1b5d`) | The overlay adds no `@legacy` row. |
+
+What the verb reads when run as root, all through existing units whose
+dispositions do not change:
+
+- the box's label: `metricsBoxLabel`, and `readMachineID` for `/etc/machine-id`;
+- the deployment store: `openDeployStoreEnv`, so `CARLOS_DEPLOYMENT_BUCKET`
+ through `release.NewS3Store` and `rootaws`;
+- IMDS: `readIdentityRecord` and `imdsGetRaw`, both accepted-legacy (J4,
+ root-input);
+- one bucket key, `control/boxes/<box>.json`: `publishBoxRecord` through
+ `boxid.Load` and `boxid.Publish`.
+
+Points for the ruling:
+
+1. **A new root entry point with no new read unit.** Root can now reach
+ `publishBoxRecord`, which until now ran only in the agent (as the agent
+ user) behind region HA. The census sees no unit to classify because the
+ reads are in units it already classifies. The verb may also run as the
+ agent user. It does not need root: the box's instance role and IMDS answer
+ either user.
+2. **Nothing it reads grants authority.** The record is evidence for
+ enrollment, signed by AWS. The only decision the verb takes from what it
+ reads is whether to write. It writes only when nothing is under the key,
+ and refuses when a record for another instance is there. The bucket
+ enforces create-only twice over: `If-None-Match: *` on the PUT, and the dev
+ bucket's deny-only `BoxRecordsAreCreateOnly` and
+ `BoxRecordsAreNeverDeleted` statements.
+3. **It refuses on fleet boxes** (`shouldSkipPublisherElection`), before any
+ store or IMDS read, the way `boxRecordLoop` returns on one.
+
+Proposed: envelope-only, at main's dispositions, seal unchanged, on the
+condition that `boxRecordStore` stays the only store the verb hands
+`publishBoxRecord` (point 4 below).
+
+## Record-write census (TestPlan9B8RecordWriteCensus)
+
+The inventory is re-pinned by `internal/provision/box_publish_record_census_test.go`
+(`plan14BoxPublishRecordWriteCensusInventory`, which the test now uses). It
+appends one entry to `plan14ActivationToolsWriteCensusInventory`.
+
+| Unit | Kinds | Proposed class | Reason |
+|---|---|---|---|
+| `internal/host/boxpublish.go:boxRecordStore.Put` | `def:Put,sel:Put` | `nonrecord` | It writes `control/boxes/<box>.json`, a box identity record, never an instance or sidecar record. It touches no record-key constructor or record path literal. It admits only a create-only Put on its one key. |
+
+Digest `e5628ec8d3c7e22f88ab877209f578bfbc49f8d7a2d444da42f649625d643ea8`.
+
+4. **The new caller of the control/boxes write.** Before this branch the one
+ writer of `control/boxes/` was `boxid.Publish`, reached only from
+ `publishBoxRecord` in `boxRecordLoop`. The verb is a second caller of the
+ same chain, not a second writer: `boxid.Publish` (`nonrecord`, `sel:Put`)
+ is unchanged. What is new is `boxRecordStore`, which wraps the store the
+ chain writes through. It refuses before the bucket any Put on another key,
+ any Put with an etag (replace-only), and every `Delete`, `List` and
+ `ListDirs`. `Get` is admitted on the one key only. The census sees its
+ `Put` method by name, so it is the unit to classify.
+
+## Tests behind the claims
+
+`internal/host/boxpublish_test.go` runs the real `S3Store` (through
+`openDeployStoreEnv`) against `leasetest`'s S3 server, behind a fake of the dev
+bucket's policy, which denies a PUT under `control/boxes/` without
+`If-None-Match: *` and any DELETE there:
+
+- a fresh box: the record is written at the right key with the right fields,
+ by one create-only PUT;
+- the same instance already recorded: a no-op success, no PUT;
+- a different instance recorded: refused, no PUT, bytes unchanged;
+- a different record created between the verb's read and its write: the
+ bucket's 412 stops the PUT and nothing is overwritten;
+- a fleet box: refused with no bucket request and no IMDS read;
+- `boxRecordStore` refuses everything but read and create on its key, before
+ the inner store;
+- `boxRecordLoop` still returns at once with region HA off, unparseable or on
+ a fleet box, and sends nothing.
+
+`system_roles_test.go` runs `carlos system box publish-record` from
+`runSystem` in a child process on a fleet box, and checks the refusal and the
+exit status.
diff --git a/internal/host/boxpublish.go b/internal/host/boxpublish.go
new file mode 100644
index 000000000..5603c50b0
--- /dev/null
+++ b/internal/host/boxpublish.go
@@ -0,0 +1,166 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/boxid"
+ "github.com/carlosframework/platform/internal/cliflag"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// carlos system box publish-record: one publishBoxRecord attempt, by hand,
+// without the CARLOS_REGION_HA gate boxRecordLoop has.
+//
+// The host claim's enrollment step needs a box's signed identity record at
+// control/boxes/<box>.json, and the agent writes it only when region HA is
+// on. This verb lets an operator standing on a box publish it once, with
+// the box's own AWS credentials and IMDS: AWS signs the identity document,
+// not the box and not the operator. It runs as root or as the agent user,
+// with host.env loaded, the way the agent's environment is.
+//
+// It changes nothing the loop does. It refuses on a fleet box, as the loop
+// skips one. It hands publishBoxRecord a store that can read and create
+// this box's one record key and nothing else (boxRecordStore), so it can
+// never delete or overwrite: the dev bucket's box_records policy
+// (BoxRecordsAreCreateOnly, BoxRecordsAreNeverDeleted) denies both anyway.
+
+const boxPublishRecordUsage = "usage: carlos system box publish-record\n\n" +
+ "Publish this box's AWS-signed identity record to the deployment bucket, once.\n" +
+ "Run it on the box, as root or the agent user, with host.env loaded. It never replaces or deletes a record.\n"
+
+// errBoxRecordStore is boxRecordStore refusing anything but reading and
+// creating this box's own record. publishBoxRecord never asks for more, so
+// a user only sees it if that changes.
+var errBoxRecordStore = errors.New("publish-record only reads and creates this box's own record")
+
+// boxRecordStore is the only store publish-record gives publishBoxRecord:
+// Get on this box's record key, and Put on it create-only (ifMatch "",
+// which S3Store sends as If-None-Match: *). Everything else is refused
+// before it reaches the bucket. wrote is whether this run's create landed.
+type boxRecordStore struct {
+ inner release.Store
+ key string
+ wrote bool
+}
+
+func (s *boxRecordStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key != s.key {
+ return nil, "", errBoxRecordStore
+ }
+ return s.inner.Get(ctx, key)
+}
+
+func (s *boxRecordStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if key != s.key || ifMatch != "" {
+ return "", errBoxRecordStore
+ }
+ etag, err := s.inner.Put(ctx, key, body, "")
+ if err == nil {
+ s.wrote = true
+ }
+ return etag, err
+}
+
+func (s *boxRecordStore) List(context.Context, string) ([]string, error) {
+ return nil, errBoxRecordStore
+}
+
+func (s *boxRecordStore) ListDirs(context.Context, string) ([]string, error) {
+ return nil, errBoxRecordStore
+}
+
+func (s *boxRecordStore) Delete(context.Context, string) error { return errBoxRecordStore }
+
+// The lines publish-record prints. Every one is in the copy review deck.
+func boxRecordPublishedLine(box, instance string) string {
+ return fmt.Sprintf("published the identity record for %s (instance %s) to %s", box, instance, boxid.Key(box))
+}
+
+func boxRecordInPlaceLine(box, instance string) string {
+ return fmt.Sprintf("the identity record for %s (instance %s) is already in place at %s. nothing was written", box, instance, boxid.Key(box))
+}
+
+const (
+ boxRecordFleetRefusal = "this is a fleet box. fleet boxes publish no identity record, so nothing was written"
+ boxRecordNoNameRefusal = "this box's name could not be read, so nothing was written"
+)
+
+func boxRecordStoreFailure(err error) error {
+ return fmt.Errorf("the deployment bucket could not be opened: %v. nothing was written", err)
+}
+
+func boxRecordRefusal(err error) error {
+ return fmt.Errorf("refused: %v. nothing was changed", err)
+}
+
+func boxRecordRetryable(box string, err error) error {
+ return fmt.Errorf("the identity record for %s was not published: %v. nothing was written, so it is safe to run again", box, err)
+}
+
+// publishRecordOnce is the verb: the fleet refusal, the box's own label
+// (metricsBoxLabel, as boxRecordLoop takes it), its deployment store, and
+// one publishBoxRecord through boxRecordStore. read builds the record from
+// IMDS. It returns the line to print, or the refusal.
+func publishRecordOnce(ctx context.Context, getenv func(string) string, machineID string,
+ open func(context.Context, func(string) string) (release.Store, error),
+ read func(box string) (boxid.Record, error)) (string, error) {
+ if shouldSkipPublisherElection(getenv) {
+ return "", errors.New(boxRecordFleetRefusal)
+ }
+ box := metricsBoxLabel(getenv, machineID)
+ if box == "" {
+ return "", errors.New(boxRecordNoNameRefusal)
+ }
+ inner, err := open(ctx, getenv)
+ if err != nil {
+ return "", boxRecordStoreFailure(err)
+ }
+ store := &boxRecordStore{inner: inner, key: boxid.Key(box)}
+ var mine boxid.Record
+ done, err := publishBoxRecord(ctx, store, box, func() (boxid.Record, error) {
+ rec, err := read(box)
+ mine = rec
+ return rec, err
+ })
+ switch {
+ case err != nil && done:
+ return "", boxRecordRefusal(err)
+ case err != nil:
+ return "", boxRecordRetryable(box, err)
+ }
+ instance, err := recordInstanceID(mine)
+ if err != nil {
+ // publishBoxRecord has already read this instance id to compare
+ // it, or written the record whole; it cannot fail here.
+ instance = "unknown"
+ }
+ if store.wrote {
+ return boxRecordPublishedLine(box, instance), nil
+ }
+ return boxRecordInPlaceLine(box, instance), nil
+}
+
+// RunSystemBoxPublishRecord is `carlos system box publish-record`.
+func RunSystemBoxPublishRecord(args []string) {
+ fs := flag.NewFlagSet("system box publish-record", flag.ExitOnError)
+ fs.Usage = cliflag.Usage(fs, boxPublishRecordUsage)
+ _ = fs.Parse(args)
+ if fs.NArg() != 0 {
+ fs.Usage()
+ os.Exit(2)
+ }
+ client := &http.Client{Timeout: 2 * time.Second}
+ line, err := publishRecordOnce(context.Background(), os.Getenv, readMachineID(), openDeployStoreEnv,
+ func(box string) (boxid.Record, error) { return readIdentityRecord(client, imdsBase, box, time.Now()) })
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ fmt.Println(line)
+}
diff --git a/internal/host/boxpublish_test.go b/internal/host/boxpublish_test.go
new file mode 100644
index 000000000..fed33e953
--- /dev/null
+++ b/internal/host/boxpublish_test.go
@@ -0,0 +1,367 @@
+package host
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/boxid"
+ "github.com/carlosframework/platform/internal/lease/leasetest"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// carlos system box publish-record, through the production wiring: the
+// real S3Store (openDeployStoreEnv over CARLOS_DEPLOYMENT_BUCKET and
+// CARLOS_S3_ENDPOINT) against leasetest's S3 server, which enforces
+// If-None-Match: * itself, behind the dev bucket's box_records policy;
+// and the real IMDS reader over fakeIMDS (boxrecord_test.go).
+
+const (
+ publishMachineID = "0123456789abcdef0123456789abcdef"
+ publishBucket = "deploy"
+ publishDoc = "{\n \"accountId\" : \"111122223333\",\n \"region\" : \"eu-west-1\",\n \"instanceId\" : \"i-0abc\"\n}\n"
+)
+
+var publishSig = base64.StdEncoding.EncodeToString([]byte(strings.Repeat("s", 256)))
+
+// s3Request is one request the fake S3 saw.
+type s3Request struct{ Method, Key, IfNoneMatch, IfMatch string }
+
+// boxRecordsBucket is the fake deployment bucket: leasetest's versioned S3
+// server, behind the dev bucket's deny-only box_records policy. Under
+// control/boxes/ a PUT without If-None-Match: * is AccessDenied
+// (BoxRecordsAreCreateOnly) and so is any DELETE
+// (BoxRecordsAreNeverDeleted). Every request is recorded. beforePut, when
+// set, runs once before the first PUT reaches the server: a racing writer.
+type boxRecordsBucket struct {
+ model *leasetest.VersionedBucket
+ mu sync.Mutex
+ seen []s3Request
+
+ beforePut func()
+}
+
+func (b *boxRecordsBucket) requests() []s3Request {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return append([]s3Request(nil), b.seen...)
+}
+
+func (b *boxRecordsBucket) handler() http.Handler {
+ next, _ := leasetest.NewS3Server(b.model)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, key, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
+ b.mu.Lock()
+ b.seen = append(b.seen, s3Request{r.Method, key, r.Header.Get("If-None-Match"), r.Header.Get("If-Match")})
+ race := b.beforePut
+ if r.Method == http.MethodPut {
+ b.beforePut = nil
+ }
+ b.mu.Unlock()
+ if strings.HasPrefix(key, boxid.Prefix) {
+ denied := r.Method == http.MethodDelete || (r.Method == http.MethodPut && r.Header.Get("If-None-Match") != "*")
+ if denied {
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte("<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>"))
+ return
+ }
+ }
+ if r.Method == http.MethodPut && race != nil {
+ race()
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// publishFixture points the process's AWS and CARLOS_* environment at a
+// fresh fake bucket, as host.env would on a box.
+func publishFixture(t *testing.T) *boxRecordsBucket {
+ t.Helper()
+ b := &boxRecordsBucket{model: leasetest.NewVersionedBucket()}
+ b.model.AddBucket(publishBucket)
+ server := httptest.NewServer(b.handler())
+ t.Cleanup(server.Close)
+ for k, v := range map[string]string{
+ "AWS_ACCESS_KEY_ID": "test-key", "AWS_SECRET_ACCESS_KEY": "test-secret", "AWS_SESSION_TOKEN": "",
+ "AWS_REGION": "eu-west-1", "AWS_EC2_METADATA_DISABLED": "true",
+ "AWS_CONFIG_FILE": "/nonexistent", "AWS_SHARED_CREDENTIALS_FILE": "/nonexistent",
+ "CARLOS_S3_ENDPOINT": server.URL, "CARLOS_DEPLOYMENT_BUCKET": publishBucket,
+ "CARLOS_DEPLOYMENT_DIR": "", "CARLOS_FLEET": "", "CARLOS_FLEET_BOX": "", "CARLOS_BOX_REFERENCE": "",
+ "CARLOS_REGION_HA": "",
+ } {
+ t.Setenv(k, v)
+ }
+ return b
+}
+
+func publishIMDS() func(string) (boxid.Record, error) {
+ client := imdsClient(&fakeIMDS{doc: publishDoc, sig: publishSig})
+ return func(box string) (boxid.Record, error) {
+ return readIdentityRecord(client, "http://imds.test", box, recordNow)
+ }
+}
+
+func runPublish(t *testing.T) (string, error) {
+ t.Helper()
+ return publishRecordOnce(context.Background(), os.Getenv, publishMachineID, openDeployStoreEnv, publishIMDS())
+}
+
+// assertCreateOnly: no DELETE, and every PUT under control/boxes/ was
+// create-only. Overwrites would also have been denied by the fake's
+// policy, but the verb must never even ask.
+func assertCreateOnly(t *testing.T, reqs []s3Request) (puts int) {
+ t.Helper()
+ for _, r := range reqs {
+ switch r.Method {
+ case http.MethodDelete:
+ t.Errorf("publish-record sent DELETE %s", r.Key)
+ case http.MethodPut:
+ puts++
+ if r.IfNoneMatch != "*" || r.IfMatch != "" {
+ t.Errorf("PUT %s with If-None-Match %q If-Match %q; want create-only", r.Key, r.IfNoneMatch, r.IfMatch)
+ }
+ case http.MethodGet:
+ default:
+ t.Errorf("publish-record sent %s %s", r.Method, r.Key)
+ }
+ }
+ return puts
+}
+
+func TestPublishRecordWritesAFreshBoxsRecord(t *testing.T) {
+ b := publishFixture(t)
+ box := status.Label(publishMachineID)
+ line, err := runPublish(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := "published the identity record for " + box + " (instance i-0abc) to control/boxes/" + box + ".json"; line != want {
+ t.Fatalf("line = %q\nwant %q", line, want)
+ }
+ raw, ok := b.model.Current(publishBucket, "control/boxes/"+box+".json")
+ if !ok {
+ t.Fatal("no record at control/boxes/<box>.json")
+ }
+ var rec map[string]any
+ if err := json.Unmarshal(raw, &rec); err != nil {
+ t.Fatal(err)
+ }
+ want := map[string]any{"v": float64(1), "box": box, "identity_document": base64.StdEncoding.EncodeToString([]byte(publishDoc)),
+ "identity_signature": publishSig, "written_at": "2026-10-06T09:00:00Z"}
+ if len(rec) != len(want) {
+ t.Fatalf("record has fields %v, want exactly %v", rec, want)
+ }
+ for k, v := range want {
+ if rec[k] != v {
+ t.Errorf("record %s = %v, want %v", k, rec[k], v)
+ }
+ }
+ if puts := assertCreateOnly(t, b.requests()); puts != 1 {
+ t.Fatalf("%d PUTs, want one", puts)
+ }
+ for _, r := range b.requests() {
+ if r.Key != "control/boxes/"+box+".json" {
+ t.Errorf("%s %s: publish-record touched another key", r.Method, r.Key)
+ }
+ }
+}
+
+func TestPublishRecordIsANoOpWhenThisInstanceIsRecorded(t *testing.T) {
+ b := publishFixture(t)
+ box := status.Label(publishMachineID)
+ held := identityRecord("i-0abc", "2026-10-01T00:00:00Z")
+ held.Box = box
+ before, _ := json.Marshal(held)
+ b.model.Put(publishBucket, boxid.Key(box), before)
+ line, err := runPublish(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := "the identity record for " + box + " (instance i-0abc) is already in place at control/boxes/" + box + ".json. nothing was written"; line != want {
+ t.Fatalf("line = %q\nwant %q", line, want)
+ }
+ if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(before) {
+ t.Fatalf("the record was rewritten:\n%s\n%s", before, after)
+ }
+ if puts := assertCreateOnly(t, b.requests()); puts != 0 {
+ t.Fatalf("%d PUTs for a record already in place, want none", puts)
+ }
+}
+
+func TestPublishRecordRefusesARecordForAnotherInstance(t *testing.T) {
+ b := publishFixture(t)
+ box := status.Label(publishMachineID)
+ held := identityRecord("i-0zzz", "2026-10-01T00:00:00Z")
+ held.Box = box
+ before, _ := json.Marshal(held)
+ b.model.Put(publishBucket, boxid.Key(box), before)
+ line, err := runPublish(t)
+ if err == nil {
+ t.Fatalf("published over another instance's record: %q", line)
+ }
+ if want := "refused: the record already under " + box + " is signed for instance i-0zzz, this box is i-0abc. nothing was changed"; err.Error() != want {
+ t.Fatalf("err = %q\nwant %q", err, want)
+ }
+ if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(before) {
+ t.Fatalf("the record was changed:\n%s\n%s", before, after)
+ }
+ if puts := assertCreateOnly(t, b.requests()); puts != 0 {
+ t.Fatalf("%d PUTs, want none", puts)
+ }
+}
+
+// The conditional create is the bucket's: a record another instance
+// creates after the verb's read and before its write is not overwritten,
+// because the fake answers the If-None-Match: * PUT with 412.
+func TestPublishRecordLosesARaceWithoutOverwriting(t *testing.T) {
+ b := publishFixture(t)
+ box := status.Label(publishMachineID)
+ held := identityRecord("i-0zzz", "2026-10-01T00:00:00Z")
+ held.Box = box
+ racer, _ := json.Marshal(held)
+ b.beforePut = func() { b.model.Put(publishBucket, boxid.Key(box), racer) }
+ want := "refused: another record already holds " + box + ": boxid: " + box + " already has a different record: store: compare-and-swap conflict. nothing was changed"
+ if line, err := runPublish(t); err == nil || err.Error() != want {
+ t.Fatalf("line=%q err=%v\nwant %q", line, err, want)
+ }
+ if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(racer) {
+ t.Fatalf("the racing record was overwritten:\n%s", after)
+ }
+ if puts := assertCreateOnly(t, b.requests()); puts != 1 {
+ t.Fatalf("%d PUTs, want the one conditional create", puts)
+ }
+}
+
+func TestPublishRecordRefusesAFleetBox(t *testing.T) {
+ b := publishFixture(t)
+ t.Setenv("CARLOS_FLEET", "acme")
+ t.Setenv("CARLOS_FLEET_BOX", "kass-1")
+ imdsRead := false
+ _, err := publishRecordOnce(context.Background(), os.Getenv, publishMachineID, openDeployStoreEnv,
+ func(string) (boxid.Record, error) { imdsRead = true; return boxid.Record{}, nil })
+ if err == nil || err.Error() != boxRecordFleetRefusal {
+ t.Fatalf("err = %v, want %q", err, boxRecordFleetRefusal)
+ }
+ if n := len(b.requests()); n != 0 || imdsRead {
+ t.Fatalf("a fleet box reached the bucket (%d requests) or IMDS (%v)", n, imdsRead)
+ }
+}
+
+// A failure publishBoxRecord would retry is one attempt here, reported as
+// safe to run again, and writes nothing.
+func TestPublishRecordReportsAReadFailureAsRetryable(t *testing.T) {
+ b := publishFixture(t)
+ box := status.Label(publishMachineID)
+ _, err := publishRecordOnce(context.Background(), os.Getenv, publishMachineID, openDeployStoreEnv,
+ func(string) (boxid.Record, error) { return boxid.Record{}, errors.New("IMDS down") })
+ want := "the identity record for " + box + " was not published: IMDS down. nothing was written, so it is safe to run again"
+ if err == nil || err.Error() != want {
+ t.Fatalf("err = %v\nwant %q", err, want)
+ }
+ if puts := assertCreateOnly(t, b.requests()); puts != 0 {
+ t.Fatalf("%d PUTs, want none", puts)
+ }
+}
+
+func TestPublishRecordNeedsADeploymentStore(t *testing.T) {
+ publishFixture(t)
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+ _, err := runPublish(t)
+ if err == nil || !strings.HasPrefix(err.Error(), "the deployment bucket could not be opened: ") {
+ t.Fatalf("err = %v", err)
+ }
+}
+
+// recordingStore counts every call that reaches it.
+type recordingStore struct {
+ release.Store
+ calls []string
+}
+
+func (r *recordingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ r.calls = append(r.calls, "Get "+key)
+ return r.Store.Get(ctx, key)
+}
+
+func (r *recordingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ r.calls = append(r.calls, "Put "+key+" "+ifMatch)
+ return r.Store.Put(ctx, key, body, ifMatch)
+}
+
+func (r *recordingStore) Delete(ctx context.Context, key string) error {
+ r.calls = append(r.calls, "Delete "+key)
+ return r.Store.Delete(ctx, key)
+}
+
+// The store publish-record hands publishBoxRecord refuses, before the
+// bucket, everything but reading and creating this box's own record.
+func TestBoxRecordStoreOnlyReadsAndCreatesItsOwnKey(t *testing.T) {
+ ctx := context.Background()
+ inner := &recordingStore{Store: release.NewDirStore(t.TempDir())}
+ s := &boxRecordStore{inner: inner, key: boxid.Key("annie-welding")}
+ if _, err := s.Put(ctx, boxid.Key("annie-welding"), []byte("{}"), "etag"); !errors.Is(err, errBoxRecordStore) {
+ t.Errorf("replace-only Put: %v", err)
+ }
+ if _, err := s.Put(ctx, boxid.Key("bert-sawing"), []byte("{}"), ""); !errors.Is(err, errBoxRecordStore) {
+ t.Errorf("Put on another box's key: %v", err)
+ }
+ if _, _, err := s.Get(ctx, boxid.Key("bert-sawing")); !errors.Is(err, errBoxRecordStore) {
+ t.Errorf("Get on another box's key: %v", err)
+ }
+ if err := s.Delete(ctx, boxid.Key("annie-welding")); !errors.Is(err, errBoxRecordStore) {
+ t.Errorf("Delete: %v", err)
+ }
+ if _, err := s.List(ctx, boxid.Prefix); !errors.Is(err, errBoxRecordStore) {
+ t.Errorf("List: %v", err)
+ }
+ if _, err := s.ListDirs(ctx, "control/"); !errors.Is(err, errBoxRecordStore) {
+ t.Errorf("ListDirs: %v", err)
+ }
+ if len(inner.calls) != 0 || s.wrote {
+ t.Fatalf("refused calls reached the store: %v (wrote=%v)", inner.calls, s.wrote)
+ }
+ if _, err := s.Put(ctx, boxid.Key("annie-welding"), []byte("{}"), ""); err != nil || !s.wrote {
+ t.Fatalf("create: err=%v wrote=%v", err, s.wrote)
+ }
+ if _, err := s.Put(ctx, boxid.Key("annie-welding"), []byte("{}"), ""); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("a second create: %v, want the store's ErrCASConflict", err)
+ }
+}
+
+// boxRecordLoop's gate is unchanged: with CARLOS_REGION_HA off, or on a
+// fleet box, it returns at once and never reaches the bucket. Before the
+// gate it would retry each minute, so a broken gate shows as a timeout.
+func TestBoxRecordLoopGateIsUnchanged(t *testing.T) {
+ for name, env := range map[string]map[string]string{
+ "region HA off": {"CARLOS_REGION_HA": ""},
+ "region HA unparseable": {"CARLOS_REGION_HA": "yes"},
+ "fleet box, region HA on": {"CARLOS_REGION_HA": "1", "CARLOS_FLEET": "acme", "CARLOS_FLEET_BOX": "kass-1"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ b := publishFixture(t)
+ for k, v := range env {
+ t.Setenv(k, v)
+ }
+ done := make(chan struct{})
+ go func() { boxRecordLoop(); close(done) }()
+ select {
+ case <-done:
+ case <-time.After(10 * time.Second):
+ t.Fatal("boxRecordLoop did not return: its region HA gate let it run")
+ }
+ if n := len(b.requests()); n != 0 {
+ t.Fatalf("boxRecordLoop sent %d requests with its gate shut", n)
+ }
+ })
+ }
+}
diff --git a/internal/provision/box_publish_record_census_test.go b/internal/provision/box_publish_record_census_test.go
new file mode 100644
index 000000000..059346ee2
--- /dev/null
+++ b/internal/provision/box_publish_record_census_test.go
@@ -0,0 +1,16 @@
+package provision
+
+// plan14BoxPublishRecordWriteCensusInventory is the record-write inventory
+// with carlos system box publish-record's one write site.
+// PROPOSED-box-publish-record: platform-1e rules on it
+// (docs/superpowers/notes/census-proposal-box-publish-record.md).
+//
+// boxRecordStore.Put is the store publish-record hands publishBoxRecord. It
+// writes control/boxes/<box>.json, a box's identity record, never an
+// instance or sidecar record, and only create-only: a Put on any other key,
+// or with an etag, is refused before the store. Hence nonrecord.
+func plan14BoxPublishRecordWriteCensusInventory() []censusEntry {
+ return append(plan14ActivationToolsWriteCensusInventory(), censusEntry{
+ "internal/host/boxpublish.go", "boxRecordStore.Put", "nonrecord", "def:Put,sel:Put", "e5628ec8d3c7e22f88ab877209f578bfbc49f8d7a2d444da42f649625d643ea8",
+ })
+}
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index a1baddea9..16b35522c 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -827,6 +827,13 @@ func TestPlan14RootReadCensus(t *testing.T) {
t.Fatal(err)
}
seal = "fc584e113d0c6786bdeb3344354ae567c112c607e68f85056352545557a91b5d"
+ // Census overlay box-publish-record (carlos system box publish-record),
+ // loaded last. PROPOSED-box-publish-record: platform-1e rules on it.
+ // Envelope-only: it re-pins the internal/host and root package
+ // envelopes, adds and changes no root-read unit, and the seal stands.
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-box-publish-record.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 6f71d60e4..f66dd1740 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -366,7 +366,7 @@ func TestPlan9B8RecordWriteCensus(t *testing.T) {
if len(got) < 100 {
t.Fatalf("census found only %d units under %s; the walk is broken", len(got), root)
}
- inventory := plan14ActivationToolsWriteCensusInventory()
+ inventory := plan14BoxPublishRecordWriteCensusInventory()
problems := append(censusDiff(inventory, got), censusPolicy(inventory)...)
if len(problems) > 0 {
t.Fatalf("record-write census rejected %d site(s); update the explicit inventory only after reviewing the source:\n %s", len(problems), strings.Join(problems, "\n "))
diff --git a/internal/provision/testdata/plan14-root-reads-box-publish-record.tsv b/internal/provision/testdata/plan14-root-reads-box-publish-record.tsv
new file mode 100644
index 000000000..8489d7727
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-box-publish-record.tsv
@@ -0,0 +1,6 @@
+# Plan 14 census overlay box-publish-record (carlos system box publish-record, approved by Paul; owner platform-1e as the host claim's owner), loaded last, after region-ha-2b. PROPOSED-box-publish-record: every row below is proposed, not ruled; platform-1e rules on them (docs/superpowers/notes/census-proposal-box-publish-record.md).
+# internal/host boxpublish.go: RunSystemBoxPublishRecord and publishRecordOnce make one publishBoxRecord attempt, without boxRecordLoop's CARLOS_REGION_HA gate, through boxRecordStore, which admits only Get and create-only Put (If-None-Match: *) on control/boxes/<box>.json and refuses List, ListDirs, Delete, replace-only Put and every other key before the bucket. It refuses on a fleet box (shouldSkipPublisherElection) before any store or IMDS read. Its reads are the existing units it calls, unchanged: metricsBoxLabel and readMachineID for the box's label, openDeployStoreEnv for the deployment store, readIdentityRecord and imdsGetRaw (accepted-legacy) for IMDS, publishBoxRecord and boxid.Load for the one record key. The scan finds no read site in the new units, so no root-read unit is added or changed; boxrecord.go is not edited, and boxRecordLoop's gate is pinned by TestBoxRecordLoopGateIsUnchanged.
+# Root package (system_roles.go, system.go): runSystemBoxVerb hands `box publish-record` to host.RunSystemBoxPublishRecord before roleArgs; systemUsage gains its synopsis and description lines. No root read is added.
+# Envelope-only: the internal/host and root package envelopes are re-pinned; the legacy seal stands. The record-write census (9B8) gains internal/host/boxpublish.go:boxRecordStore.Put, proposed nonrecord (box_publish_record_census_test.go).
+@coverage . c4e6efe2958853ba6480f680da813895cfeb7103fdbeb0977e84bf9fe856a179
+@coverage internal/host 03d139a8f15177642b96900fea16265dd274dd7289b3fd42444c61f4f7ec9327
diff --git a/system.go b/system.go
index 7e0f61187..bd1ecb91a 100644
--- a/system.go
+++ b/system.go
@@ -141,6 +141,7 @@ func systemUsage() {
carlos system seeds-floor publish --min-release <release>
carlos system legacy-routes assign --name <name> --to <account>/<app>
carlos system box role <box> <default|legacy|platform>
+ carlos system box publish-record
carlos system group role <region> <default|legacy>
carlos system host-env <write|seed> --deployment <name> --region <code> [flags]
@@ -165,6 +166,10 @@ The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS):
group role <region> <default|legacy>
the same for every box a region's group has enrolled, and for
every box it enrolls later
+ box publish-record
+ run on a box, as root or the agent user: publish its AWS-signed
+ identity record to the deployment bucket, once. It never
+ replaces or deletes a record
A box a region's group launches (region HA):
diff --git a/system_roles.go b/system_roles.go
index 487d52bc5..c6b7c6e6f 100644
--- a/system_roles.go
+++ b/system_roles.go
@@ -8,6 +8,8 @@ import (
"net/http"
"net/url"
"os"
+
+ "github.com/carlosframework/platform/internal/host"
)
// carlos system box role <box> <role> and carlos system group role
@@ -94,6 +96,12 @@ func roleArgs(rest []string) (name, role, console string) {
}
func runSystemBoxVerb(rest []string) {
+ if len(rest) > 0 && rest[0] == "publish-record" {
+ // On the box itself, not through the console (internal/host
+ // boxpublish.go).
+ host.RunSystemBoxPublishRecord(rest[1:])
+ return
+ }
box, role, consoleFlag := roleArgs(rest)
sess := requireDeployAPISession(consoleFlag)
printUsing(stdout, sess)
diff --git a/system_roles_test.go b/system_roles_test.go
index f8fc0383f..d38be7bb0 100644
--- a/system_roles_test.go
+++ b/system_roles_test.go
@@ -1,10 +1,14 @@
package main
import (
+ "bytes"
"context"
"errors"
"net/http"
+ "os"
+ "os/exec"
"reflect"
+ "strings"
"testing"
)
@@ -17,6 +21,31 @@ func TestSystemVerbKnowsBoxAndGroup(t *testing.T) {
}
}
+// carlos system box publish-record reaches the box verb, not the console
+// role call: run in a child on a fleet box, it prints the fleet refusal
+// and exits 1 before any store, console or IMDS is touched.
+func TestSystemBoxPublishRecordDispatch(t *testing.T) {
+ if os.Getenv("CARLOS_TEST_PUBLISH_RECORD_CHILD") == "1" {
+ runSystem([]string{"box", "publish-record"})
+ os.Exit(0)
+ }
+ cmd := exec.Command(os.Args[0], "-test.run=^TestSystemBoxPublishRecordDispatch$")
+ scratch := t.TempDir()
+ cmd.Dir = scratch
+ cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + scratch, "CARLOS_TEST_PUBLISH_RECORD_CHILD=1",
+ "CARLOS_FLEET=acme", "CARLOS_FLEET_BOX=kass-1"}
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout, cmd.Stderr = &stdout, &stderr
+ err := cmd.Run()
+ var exit *exec.ExitError
+ if !errors.As(err, &exit) || exit.ExitCode() != 1 {
+ t.Fatalf("exit: %v; stdout %q stderr %q", err, stdout.String(), stderr.String())
+ }
+ if got := strings.TrimSpace(stderr.String()); got != "this is a fleet box. fleet boxes publish no identity record, so nothing was written" {
+ t.Fatalf("stderr = %q", got)
+ }
+}
+
// fakeRolePost answers one role call with status and body, recording what
// was asked.
type fakeRolePost struct {