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
+38
View File
@@ -13,6 +13,44 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
## Unreleased
### Added
- **`pi-devbox-firstboot` skill + a managed-block pointer to it, so a new
device can find the fleet's first-boot acceptance round at all.** The 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. Previously every device re-ran the entire sequence on
every release, which was drift rather than a decision: each device plants its
own successor ask carrying the previous round's checklist, so scope only 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 *identical image bytes* was recorded as context rather than as grounds
to skip. The v1.8.10-era "image half — already settled, do not repeat" split
was never retired; it simply lapsed.
Two properties the skill exists to protect. First, **the gate needs no
roster**: fleet membership changes in both directions, so nothing addresses a
device and nothing enrolls it — state is read, not pushed. The case that
therefore has no delivery path at all is a *brand-new* device, whose mailbox
is legitimately empty and whose coordination chain does not exist yet; it must
plant its own first ask, and this skill is how it learns that. Second, the
skill is a **finder, not the policy**: it locates the current policy artifact
in the logstream instead of restating it, because a policy baked into an image
is pinned to that image — a device on an older release still reaches the
current policy, and a revision needs no release.
Baked canonical (like `pi-devbox-environment`), not skillset-owned: the round
verifies the image, so pinning it to the image is correct, and a skillset-owned
skill would need the vendoring machinery (`ARG SKILLSET_SNAPSHOT_REF`, the
manifest/label pair, a per-skill refresh script) that today exists only for
`mempalace`. `entrypoint-user.sh` and `pi-devbox-version` both enumerate the
baked skills directory, so no wiring change was needed for either to pick it
up. `scripts/smoke-test.sh` asserts the baked file, both resolution loops, and
the `AGENTS.md` pointer **by path rather than by prose** — a new device reaches
the round only through that line, so rewording the paragraph must not be able
to break the link silently.
### Fixed
- **CI: a smoke job's image could be deleted mid-test by another job's disk
+9 -1
View File
@@ -804,7 +804,14 @@ directory, and they compose:
mechanisms, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`),
tmux 0-indexing, uv-first Python, and pi-studio reachability, all as
*mechanisms* (deployment-specific hostnames/domains/nameservers are
discovered at runtime, never hardcoded).
discovered at runtime, never hardcoded). **`pi-devbox-firstboot`** ships the
same way and is the entry point to the fleet's first-boot acceptance round:
it carries the *state-based scope gate* (two claim lookups, keyed by release
tag and by architecture, deciding which tiers of checks this device still
owes) rather than the policy itself, which it locates in the logstream so a
policy revision needs no release. Baked rather than skillset-owned on purpose
— the device that most needs it is a brand-new one with an empty mailbox and
no coordination chain, so nothing would deliver it.
- **Vendored fallback skills.** The pi-toolkit global `AGENTS.md` tells every
pi session to read `~/.agents/skills/pi-extensions/SKILL.md` at start (to fix
fork/recall under-utilisation). That pointer would dangle in a container
@@ -1124,6 +1131,7 @@ pi-devbox v1.8.14
credential-incident-response baked
mempalace live /workspace/skillset @ 4d7c0ea (identical to baked snapshot)
pi-devbox-environment baked
pi-devbox-firstboot baked
pi-extensions baked
```
@@ -71,6 +71,26 @@ rather than merely confusing you:
the MCP tools while both look correct. Use the MCP tools for the central
palace; the CLI only for a local one.
## First boot on a new release: scope the acceptance round
If you are in a pi-devbox container that was just recreated, or one running a
release this device has not accepted before — or a `firstboot-acceptance` ask is
in your mailbox, or your mailbox is **empty and this device has never run that
round** — read `~/.agents/skills/pi-devbox-firstboot/SKILL.md` before starting.
That round is **scoped by published state, not run in full by every device**:
two claim lookups, keyed by release tag and by architecture, decide whether you
owe the release tier, the per-architecture image tier, or only the device tier.
Three rules are load-bearing enough to repeat here. **Derive the release from
`/etc/pi-devbox/build-manifest.json`; never predict it** — four tags in this
image's history published nothing. **Honour a peer's claim only if its keys
match your own first-hand readings**, or one stale claim silently switches off
verification across the whole fleet while every device still reports PASS. And
**the device tier always runs**, because the same image reads differently per
device and both readings are correct (a warn count is not a device property
unless the invocation is pinned). An empty mailbox on a device with no chain
means *you* plant the first ask — nobody is watching for new devices.
## Before you file a finding: second measurement, different route
This is here rather than in a skill because it has to fire *without* a matching
@@ -10,6 +10,7 @@ one", which was a bug).
|-------|-------|------------------|
| `pi-devbox-environment` | pi-devbox (this repo) | authored here; the canonical copy |
| `credential-incident-response` | pi-devbox (this repo) | authored here; the canonical copy |
| `pi-devbox-firstboot` | pi-devbox (this repo) | authored here; the canonical copy |
| `pi-extensions` | the `pi-extensions` package repo (`skill/`) | **vendored fallback** + refreshed at build |
| `mempalace` | the `skillset` repo | **vendored fallback** (snapshot only) |
@@ -27,6 +28,20 @@ actually picks it up rather than relying on description-matching.
`pi-extensions`'s directive already ships in pi-toolkit's `AGENTS.md`, so only
its skill file needed baking.
`pi-devbox-firstboot` is the third case: authored here, baked canonical, **and**
pointed at from the managed block, for a reason specific to what it does. It is
the entry point to the fleet's first-boot acceptance round, and the device that
most needs it is a **brand-new** one whose mailbox is empty and whose chain does
not exist yet — so nothing will deliver the procedure to it, and
description-matching on an empty mailbox is exactly the case that fails. It is
deliberately a *finder*: it locates the current policy artifact in the logstream
rather than restating it, so a policy revision does not require a release and a
device booting an older image still reaches the current policy. It is **not**
skillset-owned, because the round verifies the image and should therefore be
pinned to the image — and because a skillset-owned skill needs the vendoring
machinery (`ARG SKILLSET_SNAPSHOT_REF`, the manifest/label pair, and a
per-skill refresh script), which today exists only for `mempalace`.
## Freshness model (layered — see Dockerfile.variant)
- **`pi-extensions`** — Option 1 + Option 2. The committed copy here is the
@@ -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.
+9 -2
View File
@@ -291,12 +291,19 @@ run "image-baked pi-devbox-environment skill" \
"test -f /usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md"
run "image-baked credential-incident-response skill" \
"test -f /usr/local/share/pi-devbox/skills/credential-incident-response/SKILL.md"
run "image-baked pi-devbox-firstboot skill" \
"test -f /usr/local/share/pi-devbox/skills/pi-devbox-firstboot/SKILL.md"
run "global-AGENTS append snippet present" \
"test -f /usr/local/share/pi-devbox/pi-global-AGENTS.append.md"
run "pi-devbox block merged into pi-global-AGENTS.md" \
"grep -q 'pi-devbox:managed-block' /opt/pi-toolkit/pi-global-AGENTS.md"
run "mempalace session-start pointer merged into global AGENTS.md" \
"grep -q 'load the mempalace skill' /opt/pi-toolkit/pi-global-AGENTS.md"
# The firstboot pointer is asserted by PATH, not prose: a brand-new device with
# an empty mailbox reaches the acceptance round only through this line, and
# rewording the paragraph must not be able to silently break the link.
run "firstboot skill pointer merged into global AGENTS.md" \
"grep -q 'pi-devbox-firstboot/SKILL.md' /opt/pi-toolkit/pi-global-AGENTS.md"
# Vendored fallback skills (so a no-skillset container still resolves the
# AGENTS.md 'read the pi-extensions skill' pointer).
run "image-baked pi-extensions fallback skill" \
@@ -811,7 +818,7 @@ exec_test "mempalace skill snapshot is current" 'f=$HOME/.agents/skills/mempa
# Link TARGETS, not just link existence: with no skillset mounted (as here) the
# baked tree must be what resolves, for all four vendored skills.
exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \
'for s in mempalace pi-extensions pi-devbox-environment credential-incident-response; do
'for s in mempalace pi-extensions pi-devbox-environment credential-incident-response pi-devbox-firstboot; do
case "$(readlink -f $HOME/.agents/skills/$s)" in
/usr/local/share/pi-devbox/skills/$s) ;;
*) echo "$s resolves to $(readlink -f $HOME/.agents/skills/$s)" >&2; exit 1 ;;
@@ -825,7 +832,7 @@ exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \
exec_test "pi-devbox-version reports skill sources (all baked, no skillset here)" \
'out=$(pi-devbox-version)
echo "$out" | grep -q "skills:" || { echo "no skills section" >&2; exit 1; }
for s in mempalace pi-extensions pi-devbox-environment credential-incident-response; do
for s in mempalace pi-extensions pi-devbox-environment credential-incident-response pi-devbox-firstboot; do
echo "$out" | grep -qE "^ $s +baked( \([^)]*\))?$" \
|| { echo "$s not reported as baked" >&2; exit 1; }
done; echo ok'