skills: add pi-devbox-firstboot, and point the managed AGENTS block at it

The fleet's first-boot acceptance round is now scoped by published state
(policy v2, artifact art_20261023T204306_b388e93c3d64): a booting container
reads two claim keys -- firstboot-release-<TAG> and
firstboot-image-<TAG>-<ARCH> -- and derives whether it owes the release tier,
the per-architecture image tier, or only the device tier.

Nothing delivered that procedure to a device that does not already have a
coordination chain. The gate deliberately needs no roster, because fleet
membership changes in both directions, so no message addresses a new device and
nothing enrolls it. The consequence is that a BRAND-NEW device -- empty
mailbox, no chain, and the mempalace skill correctly telling it that an empty
mailbox is a fine answer -- had no path to the round at all. Historically a
peer planted the newcomer's first ask cross-device, which is exactly the roster
dependency the state design removes. This skill is how a device learns to plant
its own.

The skill is a FINDER, not the policy: it locates the newest policy.update on
topic=firstboot-acceptance and fetches the artifact, and says plainly that the
artifact wins on disagreement. A policy baked into an image is pinned to that
image; keeping only the lookup here means a revision needs no release and a
device on an older image still reaches the current policy.

Baked canonical, like pi-devbox-environment, rather than skillset-owned. The
round verifies the image, so pinning it to the image is right -- and a
skillset-owned skill needs the vendoring machinery (ARG SKILLSET_SNAPSHOT_REF,
the manifest/label pair, a per-skill refresh script) that today exists only for
mempalace. No wiring was needed: entrypoint-user.sh and pi-devbox-version both
enumerate the baked skills directory.

smoke-test.sh asserts the AGENTS.md pointer BY PATH, not by prose. A new device
reaches the round only through that line, so rewording the paragraph must not
be able to cut it silently. Both skill-resolution loops gain the new name.
This commit is contained in:
pi@mbp-m1-2020
2026-10-24 00:31:15 +02:00
parent 779efbf4fe
commit 5d6ff537c4
6 changed files with 295 additions and 3 deletions
@@ -0,0 +1,204 @@
---
name: pi-devbox-firstboot
description: >-
Run and correctly scope the fleet's first-boot acceptance round for a
pi-devbox container. Load this when a container was just recreated or runs a
release it has not accepted before, when a firstboot-acceptance ask is in
your mailbox, when verifying a new pi-devbox release, or when your mailbox is
EMPTY and this device has never run the round — the self-enrollment case,
where no chain exists and nobody will plant one for you. Covers the
state-based scope gate (two claim lookups keyed by release tag and
architecture), which checks settle once per release, which settle once per
architecture, and which must be re-measured per device whatever a peer
proved; validating a claim against your own readings before honouring it;
planting and perpetuating your own chain; and the invariants that keep it
alive (derive the release from the manifest instead of predicting it, reply
terminal before planting a successor, never score a cannot-run as a pass).
---
# First-boot acceptance for a pi-devbox container
**This skill is a FINDER, not the policy.** The authoritative procedure lives in
the logstream as a versioned artifact, because a policy baked into an image is
pinned to that image and cannot be corrected without a release. Your first job
is therefore to fetch the current policy, not to follow this file from memory.
```
mempalace_event_list(topic="firstboot-acceptance", type="policy.update", limit=5)
```
Take the newest, read `metadata.policy_artifact`, and fetch it:
```
mempalace_artifact_get(artifact_id="<policy_artifact>")
```
**If that artifact and this file disagree, the artifact wins** — it is newer by
construction. Treat this file as the thing that tells you the artifact exists.
(As of writing: policy v2, `art_20261023T204306_b388e93c3d64`, sha256
`40048d5d…`, 11252 B. Verify rather than trust that id; policies get revised.)
## Why there is a gate at all
Every device used to run the entire check sequence on every release. That was
not a decision — it was drift. Each device plants its own successor ask carrying
whatever the previous round contained, so scope only ever grew (4 checks at
v1.10.2 → B1–B5 at v1.11.2 → B1–B7 at v1.12.2), while a peer's PASS on the
*identical image bytes* was recorded as interesting context rather than as
grounds to skip anything. The fleet had actually decided the opposite in
2026-08 (v1.8.10: "IMAGE HALF — ALREADY SETTLED, DO NOT REPEAT"), and nothing
ever retired that decision; it simply lapsed.
The gate restores it, in a form that needs **no roster**. Nothing addresses you,
nothing enrolls you, and a device that leaves the fleet leaves nothing behind to
clean up. You derive your own scope by reading published state.
## The gate
```
release_tag, source_revision <- /etc/pi-devbox/build-manifest.json
arch, my_arch_digest <- your architecture, your PER-ARCH manifest digest
R_claim <- event_list(correlation_id="firstboot-release-<TAG>", type="firstboot.release.settled")
I_claim <- event_list(correlation_id="firstboot-image-<TAG>-<ARCH>", type="firstboot.image.settled")
```
| R valid | I valid | you run | meaning |
|---|---|---|---|
| no | no | **R + I + D** | first device on this release, anywhere |
| yes | no | **I + D** | first device on this *architecture* for this release |
| yes | yes | **D only** | cite the claim ids you inherited |
Emit a claim for each tier you newly settle. **Tier D always runs.**
The `correlation_id` *is* the state key, which makes this two exact server-side
lookups — no scanning, no metadata filtering, and the same cost whether the
fleet has three devices or thirty.
### RULE ZERO: derive the release, never predict it
Read `release_tag` from the manifest at boot. Do not write an expected release
into an ask, a drawer or an expectation file. Seven plants on this fleet have
died of predicting a version: v1.9.0, v1.10.0, v1.10.1 and v1.11.1 were all
tagged and then published nothing, so any ask naming them was wrong before it
was read.
### Validate before you honour — the one way this fails unsafely
A claim is honoured **only if its keys equal your own first-hand readings**:
- release claim: `release_tag` **and** `source_revision`
- image claim: `release_tag`, `arch` **and the arch digest you measured**
A mismatch means **absent**: run the tier. Without this check, a single
mis-keyed or stale claim silently switches off real verification across the
whole fleet, and the failure is invisible because every device still reports
PASS. A `firstboot.release.disputed` / `firstboot.image.disputed` event newer
than the newest claim on that key **voids** it — run the tier yourself.
Duplicate claims are harmless and expected: two devices booting the same release
before replication catches up both run full scope and both claim. The gate wants
**at least one** valid claim, not exactly one. There is deliberately no
reservation protocol, because a reservation can strand a tier if its holder dies
mid-round.
### The digest is the per-arch one
Your image key is the **per-architecture registry manifest digest**. On OrbStack
`docker image inspect` reports `Id` == `RepoDigests` == the multi-arch **index**
digest, while the `com.docker.compose.image` label carries the arch digest.
Comparing an index digest with an arch digest is a category error, not a
mismatch. An arm64 pass licenses **nothing** on amd64 — different bytes.
## The three tiers
**Tier R — release-level, architecture-independent.** The seven component refs
are resolved once per build and handed to every variant job, and the harness
lives in the repo rather than the image: component refs, CI run health,
`Dockerfile.variant` harness wiring, the directives truth table, host buildx,
changelog-vs-repo. Key: `release_tag` + `source_revision`.
**Tier I — image-level, per architecture.** Different bytes per arch: the obsmem
turn cap (two-sided, in both `src` and `dist` forms), dormancy-bridge
byte-identity against the manifest commit, base tool versions as installed, the
component `/opt` HEADs. Key: `release_tag` + `arch` + arch digest.
**Tier D — device-level, always, never inheritable.** Recreate timeline,
is-the-release-really-here, your own digest reading, the sanity script on every
invocation you report, ControlPath, volume survival, palace wiring and
extensions, `.env` and credential probes, KG and fleet triples, chain
bookkeeping — and the **config-coupled runtime checks**.
That last group is *why* D can never be inherited. The same image reads
differently per device and **both readings are correct**:
- `showSidebarOnStartup` is FALSE on some devices and TRUE on others, so an
absent startup sidebar is a defect on one device and by-design on another.
- Sanity warn counts differ by device **and by invocation** — bare vs
`--expected-image-version` gives 2 vs 1 on the same image in the same minute.
**A warn count is not a device property unless the invocation is pinned.**
Record the invocation next to every number.
- ControlPath warnings track the *host's* ssh config, not any image defect.
Carrying a peer's number across has already manufactured false defects here.
## Self-enrollment: if your mailbox is empty
The mempalace skill tells you to check your mailbox and that an empty result is
a fine answer. For this round, empty has two very different causes:
1. **You have a chain and its ask is dormant** — correct, nothing is owed.
2. **You have no chain at all** — you are a new device. Nothing is owed and
nothing ever will be, because asks are planted per device *by that device*.
In case 2, **plant your own first ask** rather than waiting for a peer to notice
you joined. Historically a peer did plant it cross-device (one device's ask was
authored by another), which is exactly the roster dependency the state design
removes. Query your own chain before concluding:
```
mempalace_event_list(correlation_id="firstboot-acceptance-<device>", limit=5)
```
## Chain invariants — the ways this chain dies quietly
- **Reply terminal FIRST, then plant the successor.** `isAnswered` joins on
`correlation_id` with a strictly-after comparison, so planting first silently
retires your own successor and the chain dies with no error at the next
recreate.
- **Dormancy baselines must be ISO-with-Z**, and verified against the *real*
`evaluateDormancy` from the booted bridge before you append. UTC digits
without the `Z` parse 7200 s low on a CEST host; an epoch (string or number)
is `NaN` and makes the condition unevaluable, so the ask nags forever while
looking like a broken feature. Import the function; do not regex a copy of it.
- **A cannot-run is never a pass.** Gates here deliberately separate rc=1
(violation) from rc=2 (cannot run). An empty file is a violation; an absent
path is a cannot-run. Scoring a cannot-run as a pass is how a gate that never
ran reports green.
- **Only the original requester can withdraw an ask.** `isWithdrawn` requires
the terminal event to be addressed to the exact mailbox, so you cannot retire
another device's ask and no broadcast can empty every mailbox at once.
- **Claims are `status=ready`.** Not `open` — the owed-work query selects
`status=open` and a `to_agent="*"` broadcast matches every mailbox, so an open
claim would appear as an owed ask on every device in the fleet. Not terminal
either, because terminal events join `isAnswered` and could clear real work.
## When you copy a probe forward, grep it for your own recorded landmines
Not just stale paths and the previous run's parameters. This fleet has a
documented instance of an agent re-importing a probe carrying the exact
`set -u` bug that its *own* retraction list named as having already killed that
probe. **Reading a retraction is not the same as applying it to the artefact the
retraction is about.**
## Limits of this file
- It tells you the procedure exists and where the real one lives. It cannot be
the procedure, because it ships inside the image it would be verifying — if
this file were wrong you would validate a bad image with its own instructions.
- It arrives only with a release. A device booting an older image gets an older
copy of this finder, which is the main reason it carries the *lookup* rather
than the policy: the lookup is stable across policy revisions.
- Nothing here is automatic. A session must load this skill (the pi-devbox
managed block in the global `AGENTS.md` points at it), and the device must
then act on it.