diff --git a/CHANGELOG.md b/CHANGELOG.md index 779c4d4..0d75e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-` and `firstboot-image--` — 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 diff --git a/README.md b/README.md index 069e33a..952a7b9 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/rootfs/usr/local/share/pi-devbox/pi-global-AGENTS.append.md b/rootfs/usr/local/share/pi-devbox/pi-global-AGENTS.append.md index bf7744b..19f771f 100644 --- a/rootfs/usr/local/share/pi-devbox/pi-global-AGENTS.append.md +++ b/rootfs/usr/local/share/pi-devbox/pi-global-AGENTS.append.md @@ -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 diff --git a/rootfs/usr/local/share/pi-devbox/skills/VENDORED.md b/rootfs/usr/local/share/pi-devbox/skills/VENDORED.md index 6d92f91..6b6782f 100644 --- a/rootfs/usr/local/share/pi-devbox/skills/VENDORED.md +++ b/rootfs/usr/local/share/pi-devbox/skills/VENDORED.md @@ -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 diff --git a/rootfs/usr/local/share/pi-devbox/skills/pi-devbox-firstboot/SKILL.md b/rootfs/usr/local/share/pi-devbox/skills/pi-devbox-firstboot/SKILL.md new file mode 100644 index 0000000..067961b --- /dev/null +++ b/rootfs/usr/local/share/pi-devbox/skills/pi-devbox-firstboot/SKILL.md @@ -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="") +``` + +**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-", type="firstboot.release.settled") +I_claim <- event_list(correlation_id="firstboot-image--", 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-", 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. diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 7c5a19c..3990129 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -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'