From 700de86b49077cac38f41945cdc54680b8bb0666 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 13:25:16 +0000 Subject: [PATCH 1/6] =?UTF-8?q?docs(prx):=20ADR=20=E2=80=94=20Message=20Ba?= =?UTF-8?q?tches=20API=20as=20a=20second=20model-call=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scopes the lift to route prx's single-shot classifier surfaces (triage type-pass, prioritize-bulk, eval fan-outs) through the async Message Batches API for the 50% discount and a collapsed serial wall-clock, behind an explicit submit/poll lifecycle. Frames batch against prx's artifact ratchet: because the pipeline advances on signed artifacts and humans are never inside the agentic loop, non-interactive legs are already async-queue-shaped; the near-term gate is transport, not the interaction model. Interactive surfaces stay on the agent SDK. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Amt3X8s8CEdJW5civefipL --- .claude/context/project.md | 1 + docs/prx/batch-transport.md | 201 ++++++++++++++++++++++++++++++++++++ prx.jsonld | 9 ++ 3 files changed, 211 insertions(+) create mode 100644 docs/prx/batch-transport.md diff --git a/.claude/context/project.md b/.claude/context/project.md index b6de56f0..e75691b2 100644 --- a/.claude/context/project.md +++ b/.claude/context/project.md @@ -35,6 +35,7 @@ source-available under `PolyForm-Noncommercial-1.0.0`. - Provenance signing — setup — `docs/provenance/signing.md` - Durable agents, two ways — and the half that's still missing — `docs/prx/articles/01-lra-vs-prx.md` - SLSA-for-agents: capability-security as the unsolved half — `docs/prx/articles/02-capability-security.md` +- ADR — the Message Batches API as a second model-call transport — `docs/prx/batch-transport.md` - ADR — wiring the beadsd door into the claude-box pod (prx-asr / prx-634) — `docs/prx/beadsd-door-wiring.md` - ADR — `prx ci` as a signed derivation chain (GH-352) — `docs/prx/ci-as-derivation.md` - ADR — the Claude runtime as a pinned OCI fleet (prx-d4o / prx-zj8) — `docs/prx/claude-runtime.md` diff --git a/docs/prx/batch-transport.md b/docs/prx/batch-transport.md new file mode 100644 index 00000000..7a39ada7 --- /dev/null +++ b/docs/prx/batch-transport.md @@ -0,0 +1,201 @@ +# ADR — the Message Batches API as a second model-call transport + +> Status: **proposed**. Spec/design, not build. Scopes the lift to route +> prx's single-shot classifier surfaces (`triage type-pass`, +> `triage prioritize-bulk`, and eval fan-outs) through the asynchronous +> [Message Batches API](https://docs.claude.com/en/docs/build-with-claude/batch-processing) +> for the 50% batch discount and a collapsed serial wall-clock. Does **not** +> touch the agentic surfaces (`plan`, `implement`, executor, pilot legs). See +> *Alternatives* for why "just add a flag" isn't the shape. + +## Problem + +Every model call prx makes today flows through **one transport**: +`@anthropic-ai/claude-agent-sdk`'s `query()`, wrapped by +`runClaudeAgentNonInteractive` (`packages/prx/src/claude/agent_service.ts`) +and dispatched from a `RuntimeProfileProjection` via `executeAgentProfile` +(`packages/prx/src/pr-state/executor.ts`). That path is a subprocess / +agentic-loop transport: streamed assistant deltas, an idle watchdog +(`armWatchdog`), live operator cancellation, `submit_plan` MCP capture, and a +per-run usage/audit row (`appendAuditRow`). It is the right shape for +interactive, latency-sensitive, tool-using work. + +It is the wrong shape for the **classifier surfaces**, which are already +batch-shaped and pay for it: + +- `prx triage type-pass` (`packages/prx/src/triage/type-pass.ts`) chunks the + type-less issue queue, packs each chunk into **one Haiku prompt** (a JSON + array of `{number, title, currentLabels}`), then runs the chunks in a + **synchronous sequential `for` loop** (`type-pass.ts:400`). +- `prx triage prioritize-bulk` (`packages/prx/src/triage/prioritize-bulk.ts`) + does the same over the priority axis (`prioritize-bulk.ts:396`). + +These calls are tool-free, single-shot, order-independent (every row is keyed +by issue number), and entirely latency-tolerant — nobody is watching a +type-pass stream. Running them one chunk at a time through the interactive +transport pays full synchronous price for a workload the +[Batches API](https://docs.claude.com/en/docs/build-with-claude/batch-processing) +was built for: **50% cheaper input and output**, submitted once, retrieved by +`custom_id`. + +The catch that sets the lift: **batch is not a mode of the SDK prx already +uses.** `messages.batches.*` lives on the raw `@anthropic-ai/sdk`, which prx +does not depend on directly — it is only a transitive *peer* dep of the agent +SDK and is not installed at top level. So "prx can use batch" is not a flag on +the existing path. It is a **second, parallel model-call transport** with its +own lifecycle (submit → poll `processing_status` → stream `.jsonl` results), +its own 24-hour processing window, and its own credential model. + +## Why the ratchet is already an async queue + +The classifiers are the *easy* fit, but they are not the reason batch belongs +in prx. The reason is structural: **prx ratchets on artifacts, and humans are +never inside the agentic loop.** A pipeline leg's contract is "produce a signed +artifact" — the headless planner's `submit_plan` → `PlanArtifact` +(`agent_service.ts`'s capture seam), a `checks/v1` CI derivation, a findings +attestation, a predicate-bundle member. The workflow advances **only** when +that artifact appears and verifies (`canEnterReadyToMerge()` and the +predicate-bundle verdict, `docs/prx/predicate-bundle-verdict.md`). Humans and +actors interact *with the artifact*, asynchronously — never by sitting in a +synchronous model stream. + +That is an async queue already. A leg that emits an artifact does not care +whether the model produced it in eight seconds on an open connection or forty +minutes in a batch worker — the ratchet is watching the artifact, not the +socket. The 24-hour batch window, which would be intolerable for an interactive +plan session, is a non-event for an artifact-gated leg: the unit of work is +"submit → artifact lands → verify → advance," and batch is a faithful transport +for exactly that unit. + +So the async-queue framing raises the ceiling of what is *worth* batching from +"tool-free classifiers" to "**every non-interactive, artifact-producing leg**." +What keeps the near-term scope narrow is not the interaction model — it is the +transport gate in the next section. + +## What fits, and what can't + +The dividing line is **not** agentic-vs-classifier — it is *interactive* vs +*artifact-producing*, crossed with the transport each surface runs on today. + +| Surface | Shape | Batch fit | +| --- | --- | --- | +| `triage type-pass` | chunked Haiku classify, serial loop | **direct** — already raw-Messages-shaped | +| `triage prioritize-bulk` | chunked Haiku classify, serial loop | **direct** — already raw-Messages-shaped | +| eval / claims-audit fan-outs | many independent scoring calls | **direct** | +| headless plan capture, headless executor | agentic, but artifact-gated ratchet leg | **strategic** — fits the async queue; gated by transport | +| `session open`, `plan session --interactive` | human in the stream | **no** — needs streaming / live feedback | + +Two distinctions, not one: + +- **Interactive surfaces (`session open`, `--interactive`) genuinely can't + batch.** A human is in the stream; a 24-hour turnaround defeats the point. + These stay on `query()` unconditionally. +- **Non-interactive agentic legs (headless plan capture, headless executor) + fit the async-queue model** — their contract is "produce an artifact," and + per *Why the ratchet is already an async queue* nothing waits synchronously. + The docs confirm server tools and the agentic loop run *inside* a batch (with + `pause_turn` continuation), so the interaction model is not the blocker. **The + transport is.** prx runs those legs through the `claude` CLI subprocess driven + by the agent SDK, not a loop over raw Messages, and `messages.batches` is raw + Messages. Batching them requires either the agent SDK growing a batch mode, or + reconstructing the loop over `messages.batches` + `custom_id` + `pause_turn`. + That is real work — hence *strategic*, not near-term. + +The classifiers are the near-term win because they are **already** the shape +`messages.batches` accepts (single-shot, tool-free, one Messages request per +`custom_id`), so routing them costs no loop reconstruction. The artifact-ratchet +legs are the target the async-queue framing unlocks once the transport gate is +paid down. + +## Decision + +Introduce a second transport — a `batch_service.ts` sibling to +`agent_service.ts` — and route only the classifier surfaces through it, behind +an explicit lifecycle flag. The interactive path is untouched. + +``` + ┌─ query() ──────────────► plan / implement / executor / pilot +RuntimeProfileProjection┤ (agent SDK, streaming, watchdog, submit_plan) + └─ messages.batches ──────► triage classifiers / eval fan-outs + (raw SDK, submit → poll → results-by-custom_id, 50% off) +``` + +- **`src/claude/batch_service.ts`** — mirrors `agent_service.ts`'s typed-result + contract: `submit(requests)` → `poll(batchId)` → `results(batchId)`, each + request carrying a `custom_id` and the standard Messages `params`. Returns + the same `UsageTelemetry` shape and emits the same audit rows via + `appendAuditRow`, so batch runs are inspectable through the existing sink. +- **Classifier call sites swap the serial loop for one submit + a poll loop.** + `type-pass.ts` / `prioritize-bulk.ts` keep their prompt-building, + `parseHaikuEnvelope`, audit rows, and the bd reconcile chain + (`runBeadsSync`). Only the dispatch changes: instead of N sequential + `executeAgentProfile` calls, one `submit` with N `custom_id`-keyed requests, + then re-join results to candidates by `custom_id`. The API's + order-independence is a non-issue — these sites already key everything by + issue number. +- **Lifecycle is an explicit surface, not a hidden block.** v0 exposes a + blocking form (submit, poll until `ended`, apply — cheapest) and an + `--async` form (submit, print the `batch_id`, exit), with + `prx triage batch-status` / `batch-results` verbs to reattach. This keeps the + 24-hour window a first-class operator concern rather than a wedged CLI. + +## The lift, in tiers + +- **Tier 0 — the transport (foundation), ~2–4 days.** Add `@anthropic-ai/sdk` + as a direct dep; write `batch_service.ts` with the submit/poll/results + contract and audit-row parity; decide where the `{batch_id, custom_id → + work-item}` map lives across the poll window (a blocking command needs only + in-memory state; a durable/resumable job needs a store — there is no generic + job store today, and the `beadsd`/`keeperd` daemons are for other concerns). +- **Tier 1 — route the classifiers, ~2–3 days on top of Tier 0.** Swap the + serial loops in `type-pass.ts` and `prioritize-bulk.ts`; add the + `--async`/blocking flag and status/results verbs. This is where the 50% + saving and the collapsed wall-clock actually land. +- **Tier 2 — generalize, ~1 week+.** A reusable batch-job abstraction (durable + store, resume, cancel wired to the cancel endpoint, + `prx runtime-profile`-inspectable like the SDK path) so future + many-independent-call surfaces (evals, bulk labeling, doc generation) opt in + without re-solving lifecycle. Most of the total cost lives here; defer it + until a second consumer exists. + +**Honest total for a real v0:** ~1 week (Tier 0 + Tier 1) — the classifiers on +batch behind an explicit lifecycle flag. It earns its keep *because* those +flows are already prompt-batched, tool-free, and latency-tolerant. + +## Gotchas to decide up front + +- **Credentials.** The agent SDK authenticates via the bundled `claude` CLI + (OAuth). `messages.batches` wants a workspace `ANTHROPIC_API_KEY`, and + batches are **workspace-scoped** (visible only to keys in that workspace). + This is a deliberate credential decision, not a reuse of the existing auth. +- **Lifecycle persistence.** Blocking poll vs. a durable resumable job across + the 24-hour window is a design call that decides whether this stays a 1-week + job (Tier 1) or grows into Tier 2. +- **Unsupported params.** Batch rejects `stream`, `speed`, `store`, + `previous_thread_event_id`, `cache_hint`/`context_hint`, `max_tokens: 0`, and + `research_preview_2026_02`. The classifier requests use none of these, but a + generalized Tier 2 submitter should validate against the list. +- **Best-effort caching.** The classifiers rely on a cache-stable system prompt + (`buildTriageHaikuClassifierRuntimeProfile` puts `TYPE_PASS_SYSTEM_PROMPT` in + `systemPromptStable`). In batch, cache hits are best-effort (30–98%); keep the + identical `cache_control` prefix across every request in a submission and, + for large runs, weigh the 1-hour cache duration. + +## Alternatives considered + +- **Add a `--batch` flag to the existing SDK path.** Rejected: the agent SDK's + `query()` does not expose `messages.batches` at all. There is no flag to add; + batch is a different SDK and a different lifecycle. +- **Batch the non-interactive agentic legs now.** Deferred, not rejected: the + artifact ratchet makes headless plan capture and the headless executor a + genuine async-queue fit (see *Why the ratchet is already an async queue*), but + they run on the CLI-subprocess transport, so batching them means the agent SDK + gaining a batch mode or reconstructing the loop over raw Messages + + `pause_turn`. Sequenced behind the classifiers, which need neither. +- **Batch the interactive surfaces.** Rejected outright: a human in the stream + cannot wait on a 24-hour window; `session open` / `--interactive` stay on + `query()`. +- **Do the full generic job abstraction first (Tier 2 up front).** Rejected as + premature: with a single consumer (the triage classifiers), a blocking or + `--async` command captures the value; the durable store earns its complexity + only when a second surface needs it. diff --git a/prx.jsonld b/prx.jsonld index 9182c518..92877013 100644 --- a/prx.jsonld +++ b/prx.jsonld @@ -269,6 +269,15 @@ "@id": "https://github.com/bounded-systems/prx" } }, + { + "@type": "TechArticle", + "@id": "https://github.com/bounded-systems/prx/blob/main/docs/prx/batch-transport.md", + "name": "ADR — the Message Batches API as a second model-call transport", + "url": "https://github.com/bounded-systems/prx/blob/main/docs/prx/batch-transport.md", + "isPartOf": { + "@id": "https://github.com/bounded-systems/prx" + } + }, { "@type": "TechArticle", "@id": "https://github.com/bounded-systems/prx/blob/main/docs/prx/beadsd-door-wiring.md", From 7a29542598d2825b40b84ead22589f2403e53fbb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 13:55:34 +0000 Subject: [PATCH 2/6] =?UTF-8?q?docs(prx):=20ADR=20+=20hook=20=E2=80=94=20c?= =?UTF-8?q?loud-box=20attestation=20via=20GitHub-anchored=20broker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes empirically what a Claude Code on the web session can prove about itself (no TPM/SEV/IMDS root of trust; base image identifiable but not attestable) and specifies a broker protocol that gates a privileged write (e.g. filing a bead) on GitHub-verifiable proofs — branch control and private-repo read — with zero credentials in the box. Adds .claude/attest-box.sh, a git-tracked SessionStart hook that emits the attestation bundle; its bytes are pinned to the head commit the broker re-verifies, so the attestation and its producer share one anchor. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Amt3X8s8CEdJW5civefipL --- .claude/attest-box.sh | 84 ++++++++++++++++++ .claude/context/project.md | 1 + .claude/settings.json | 4 + docs/prx/cloud-box-attestation.md | 139 ++++++++++++++++++++++++++++++ prx.jsonld | 9 ++ 5 files changed, 237 insertions(+) create mode 100755 .claude/attest-box.sh create mode 100644 docs/prx/cloud-box-attestation.md diff --git a/.claude/attest-box.sh b/.claude/attest-box.sh new file mode 100755 index 00000000..456ee7bf --- /dev/null +++ b/.claude/attest-box.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# SessionStart hook — emit a best-effort identity attestation for the cloud box. +# +# WHY A HOOK, NOT A SETUP SCRIPT: a SessionStart hook is part of the repo clone, +# so its exact bytes are pinned to the head commit a broker already verifies at +# GitHub. A setup script lives in mutable environment config, invisible to git — +# a broker can trust nothing it did. Trusted provisioning belongs here. +# +# WHAT THIS PROVES (and doesn't): the box has NO root of trust of its own — no +# TPM, no SEV-guest, no reachable instance-identity doc (see +# docs/prx/cloud-box-attestation.md). So the `claim.*` and `base_image.*` fields +# are SELF-ASSERTED (forgeable). The real anchors are `anchor.*`: the GitHub +# identity the proxy authenticates as, and the (repo, branch, head_commit) a +# broker RE-VERIFIES via the GitHub API — never taking the box's word. +# +# Fail OPEN + non-blocking + no network + no secrets: writes one JSON file and +# exits 0. The broker call and the push that make it GitHub-attested are separate, +# deliberate steps — a hook must never do them silently. +set -uo pipefail + +command -v git >/dev/null 2>&1 || exit 0 + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +[ -n "$repo_root" ] || exit 0 + +state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/prx" +mkdir -p "$state_dir" 2>/dev/null || exit 0 +out="$state_dir/box-attestation.json" + +branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)" +head_sha="$(git -C "$repo_root" rev-parse HEAD 2>/dev/null || echo unknown)" +head_tree="$(git -C "$repo_root" rev-parse HEAD^{tree} 2>/dev/null || echo unknown)" +# origin is the GitHub-proxy remote (http://…@127.0.0.1:PORT/git/OWNER/REPO); +# extract OWNER/REPO without leaking the embedded proxy credential. +repo_slug="$(git -C "$repo_root" remote get-url origin 2>/dev/null \ + | sed -E 's#^.*/git/##; s#\.git$##' || echo unknown)" + +virt="$(command -v systemd-detect-virt >/dev/null 2>&1 && systemd-detect-virt 2>/dev/null || echo unknown)" +os="$(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-unknown}")" +sess_url="" +[ -n "${CLAUDE_CODE_REMOTE_SESSION_ID:-}" ] && \ + sess_url="https://claude.ai/code/${CLAUDE_CODE_REMOTE_SESSION_ID/#cse_/session_}" + +# hardware root-of-trust surface (empirically absent in Claude cloud; recorded so +# a verifier sees the box asserting its OWN lack of attestation capability). +rot="none" +{ ls /dev/tpm* >/dev/null 2>&1 || ls /dev/sev-guest >/dev/null 2>&1; } && rot="present(unexpected)" + +# Single heredoc write (fail-open on any write error). Values are ids/tags/paths; +# no secrets. A broker treats claim.*/base_image.* as self-asserted and verifies +# anchor.* independently at GitHub. +cat > "$out" 2>/dev/null < Status: **proposed** (spike, evidence-backed). Establishes what a Claude Code +> on the web session can and cannot prove about itself, so a privileged action +> — e.g. filing a bead into the Dolt-backed beads DB (see +> `docs/prx/batch-transport.md` for why that comes up) — can be gated by an +> **external broker** without ever placing a credential inside the box. The +> `.claude/attest-box.sh` SessionStart hook emits the attestation this ADR +> specifies. Sibling to `docs/prx/beadsd-door-wiring.md` (the door that would +> consume it). + +## Problem + +Work done in a Claude Code on the web session sometimes needs a capability the +box does not hold — canonically, a **DoltHub write credential** to file a bead +(the beads backend is Dolt in server mode; the cloud box has no such +credential, and the repo deliberately forbids the git-JSONL side door). The +docs are explicit that there is **no secrets store**: environment variables and +setup scripts are visible to anyone who can edit the environment. So "inject the +token" is not a safe answer. + +The alternative is to have the box **prove properties about itself** to an +external broker that holds the credential, and let the broker decide. That only +works if we are honest about what is actually provable. This ADR pins that down +empirically. + +## Evidence — what this box actually is + +Probed from inside a live session (2026-07): + +| Property | Observation | Consequence | +| --- | --- | --- | +| Hardware root of trust | no `/dev/tpm*`, no `/dev/sev-guest`, no efivars | **no self-attestation**: no vTPM quote, no confidential-compute report | +| Virtualization | `systemd-detect-virt → docker` | the box is a **container**, not a measured VM boot from its own vantage | +| Cloud metadata service | `169.254.169.254 → HTTP 403`; GCP metadata unresolvable | the one hardware-anchored identity surface is **locked down** — no signed instance-identity document | +| Base image identity | `ANT_IMAGE_REPOSITORY=sandbox-ccr-default`, `ANT_IMAGE_TAG=74306e8b…` | image is **named** by Anthropic via env — a tag, not a content digest; forgeable in-box | +| Real credentials at rest | `GITHUB_TOKEN=proxy-…` (a *proxy* token); no `~/.git-credentials` | the real GitHub token is held by Anthropic's proxy, **outside** the box | +| Git transport | `origin → http://…@127.0.0.1:PORT/git/OWNER/REPO`; `url.…insteadOf https://github.com/` | all git auth is **mediated** by a localhost credential-translation proxy | +| Egress | `HTTPS_PROXY` + `CCR_EGRESS_GATEWAY_ENABLED=1` + CA bundle | every outbound byte passes an Anthropic proxy (audited, filtered) | +| Session identity | `CLAUDE_CODE_REMOTE_SESSION_ID=cse_…`, `CLAUDE_SESSION_INGRESS_TOKEN_FILE` (an *ingress* token) | a bearer id + an **inbound** control token — not a key the box can present outward | + +**The base image is identifiable but not attestable.** `ANT_IMAGE_TAG` is a +name Anthropic assigned, not a digest the box computed that a third party can +verify; with no TPM/SEV/IMDS, the box cannot bind itself to a measured image. +Any base-image "attestation" is Anthropic-side, verified out-of-band by whoever +trusts Anthropic — not something the box proves. + +## The load-bearing conclusion + +**The box has no root of trust of its own, so nothing it says about itself is +provable by the box.** Every real anchor lives on the *far side* of a channel +Anthropic authenticates on the box's behalf. Attestation must therefore be built +from those channels and from git content-addressing, treating the box as +untrusted compute throughout. + +## What is provable to an external broker (ranked) + +All achievable with **zero credentials in the box**: + +1. **Control of a specific `(repo, branch)` as a specific GitHub identity — + strong.** The box can push to the PR branch; the proxy authenticates it as + the connected account and restricts push to the working branch. A broker + issues a nonce → the box pushes a commit / `signed ref-snapshot` bearing it → + the broker **re-verifies via the GitHub API, independent of the box**. This + is the primitive to build on. +2. **Read access to a private ACL repo — strong, and already in production.** + `.claude/inject-org-context.sh` clones the private `bounded-systems/.github-private` + through the GitHub proxy; its own comment states the semantics: *"Access + follows the session's GitHub auth — maintainers succeed, outside contributors + fail open."* A private repo the broker controls thus becomes an allowlist: + "can this session read repo X?" = "is this session's GitHub identity + permitted?" — decided by GitHub, no token in the box. +3. **Origin-from-Anthropic-egress — weak, composable.** The broker endpoint only + accepts calls from Anthropic's managed egress. Confirms "an Anthropic cloud + session called me," shared across all sessions — a filter, not an identity. +4. **Session provenance (`cse_…` + transcript URL) — weak, bearer.** Meaningful + only if the broker trusts Anthropic to attest it; no public verification API, + env not confidential. A hint, never the proof. + +**Not provable:** VM/base-image integrity, that the env or a setup script was +not tampered by its editor, a stable cross-session box identity, or +confidentiality of any injected secret. + +## Why the attestation belongs in a hook, not a setup script + +The web docs split provisioning by ownership, and that split *is* the trust +boundary: + +| | SessionStart hook | Setup script | +| --- | --- | --- | +| Attached to | the **repository** (part of the clone) | the **cloud environment** (mutable config) | +| Bound to | the head commit a broker verifies at GitHub | nothing in git | +| Broker can trust its bytes? | **yes** — content-addressed | **no** — invisible, editor-mutable | + +So trusted provisioning belongs in a git-tracked hook. The repo already lives +this: `ensure-beads.sh` and `inject-org-context.sh` are hooks, not setup +scripts. `.claude/attest-box.sh` follows suit — its exact bytes are pinned to +the commit the broker re-verifies, so the attestation *and the code that +produced it* are covered by the same GitHub anchor. + +## Decision — the broker protocol + +Because the box demonstrably cannot hold a secret, **the door performs the +privileged write; the box only submits an artifact plus proofs.** This is the +`beadsd-door-wiring.md` shape: + +1. Box builds the proposed bead as a content-addressed artifact (`signed + ref-snapshot`) and **pushes it to the PR branch** via the GitHub proxy. +2. Box emits `.claude/attest-box.sh`'s bundle and calls the external broker over + the egress proxy with `{repo, branch, PR#, head_commit, artifact digest, + session_url}`. +3. Broker **verifies against GitHub, not the box**: the commit is on branch B of + repo R under the expected identity (proof 1), the identity can read the ACL + repo (proof 2), the digest matches — optionally with a fresh-nonce round + trip. +4. Broker — holding the DoltHub credential **outside** the box — performs the + bead write and stamps provenance (`session_url`, `head_commit`). The raw + token never touches the box. + +This maps onto primitives prx already has: the proxy's per-branch push +restriction *is* capability attenuation (`git-gateway-permission-intersection`), +and `signed-ref-snapshot` / `worktree-provenance` are the artifact-identity +layer the broker checks. + +## Alternatives considered + +- **Inject the credential into the environment.** Rejected: no secrets store — + proven that env is visible to any environment editor, so any token here is + exposed. +- **Self-host Dolt / SSH.** Rejected: a self-hosted DB forks canonical + `bounded-systems/prx` and still needs DoltHub push creds to sync back — more + infra for the same requirement. +- **TPM / SEV / IMDS attestation.** Unavailable: empirically no TPM, no + SEV-guest, and the metadata service returns 403 — there is no hardware-anchored + attestation surface to build on. +- **Trust base-image self-measurement.** Rejected as an anchor: a package BOM / + `ANT_IMAGE_TAG` fingerprint is useful for comparison against a reference, but + untrusted compute can fabricate it; base-image trust is Anthropic-side, + out-of-band. diff --git a/prx.jsonld b/prx.jsonld index 92877013..78a13ac5 100644 --- a/prx.jsonld +++ b/prx.jsonld @@ -323,6 +323,15 @@ "@id": "https://github.com/bounded-systems/prx" } }, + { + "@type": "TechArticle", + "@id": "https://github.com/bounded-systems/prx/blob/main/docs/prx/cloud-box-attestation.md", + "name": "ADR — attesting the cloud box: what an untrusted Claude-Code-web session can prove", + "url": "https://github.com/bounded-systems/prx/blob/main/docs/prx/cloud-box-attestation.md", + "isPartOf": { + "@id": "https://github.com/bounded-systems/prx" + } + }, { "@type": "TechArticle", "@id": "https://github.com/bounded-systems/prx/blob/main/docs/prx/dolt-start.md", From f8b532b7ecc33a3ebdfc0dd624e1847f3262bb19 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 13:59:39 +0000 Subject: [PATCH 3/6] fix(init): emit attest-box SessionStart hook from the settings builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit hand-edited .claude/settings.json to add the attest-box.sh hook, but that file is generated by buildOrgHarnessSettings and drift-gated. Add the hook (and update the ordering assertion) in the builder — the source of truth — so the checked-in settings match. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Amt3X8s8CEdJW5civefipL --- packages/prx/src/init/claude_settings.ts | 6 ++++-- packages/prx/test/init/claude_settings.test.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/prx/src/init/claude_settings.ts b/packages/prx/src/init/claude_settings.ts index fdbbe2cd..2886e229 100644 --- a/packages/prx/src/init/claude_settings.ts +++ b/packages/prx/src/init/claude_settings.ts @@ -96,8 +96,9 @@ export function buildClaudeSettings(): ClaudeSettings { * (inert until an OTEL endpoint is configured out-of-band). * - SessionStart: ensures the per-repo beadsd is up (host-side bridge for * the retired auto-start, prx-82b Slice 2e.4 — see `.claude/ensure-beads.sh`), - * then injects the org canonical context via `.claude/inject-org-context.sh` - * (both fail open). + * injects the org canonical context via `.claude/inject-org-context.sh`, + * then emits the cloud-box identity attestation via `.claude/attest-box.sh` + * (see docs/prx/cloud-box-attestation.md — all three fail open). */ export function buildOrgHarnessSettings(): ClaudeSettings { return { @@ -114,6 +115,7 @@ export function buildOrgHarnessSettings(): ClaudeSettings { hooks: [ { type: "command", command: "bash .claude/ensure-beads.sh" }, { type: "command", command: "bash .claude/inject-org-context.sh" }, + { type: "command", command: "bash .claude/attest-box.sh" }, ], }, ], diff --git a/packages/prx/test/init/claude_settings.test.ts b/packages/prx/test/init/claude_settings.test.ts index 817f4d33..41913345 100644 --- a/packages/prx/test/init/claude_settings.test.ts +++ b/packages/prx/test/init/claude_settings.test.ts @@ -62,7 +62,7 @@ describe("buildOrgHarnessSettings", () => { }); }); - test("wires the beadsd-ensure hook ahead of the context-injection hook", () => { + test("wires beadsd-ensure, then context-injection, then box-attestation", () => { const sessionStart = buildOrgHarnessSettings().hooks?.SessionStart; expect(sessionStart).toEqual([ { @@ -70,6 +70,7 @@ describe("buildOrgHarnessSettings", () => { hooks: [ { type: "command", command: "bash .claude/ensure-beads.sh" }, { type: "command", command: "bash .claude/inject-org-context.sh" }, + { type: "command", command: "bash .claude/attest-box.sh" }, ], }, ]); From 2aa9310c46202955e42b8354e31131b80b8df09b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 14:00:49 +0000 Subject: [PATCH 4/6] chore: add empty changeset for the box-attestation hook (no release) The settings-builder change is repo-internal harness config, not a change to released @bounded-systems/prx behavior, so record a no-release changeset to satisfy the changeset gate. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Amt3X8s8CEdJW5civefipL --- .changeset/box-attestation-hook.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/box-attestation-hook.md diff --git a/.changeset/box-attestation-hook.md b/.changeset/box-attestation-hook.md new file mode 100644 index 00000000..f0371107 --- /dev/null +++ b/.changeset/box-attestation-hook.md @@ -0,0 +1,7 @@ +--- +--- + +Register the cloud-box identity attestation as a SessionStart hook +(`.claude/attest-box.sh`) in the org-internal harness settings, and add the +`docs/prx/cloud-box-attestation.md` ADR. Repo-internal only — the public `prx +init` scaffolder and released package behavior are unchanged, so no version bump. From b7f1548cb36ad8f7a3a36cba131d26e48cbb5787 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 14:07:50 +0000 Subject: [PATCH 5/6] =?UTF-8?q?docs(prx):=20expand=20cloud-box=20attestati?= =?UTF-8?q?on=20=E2=80=94=20signing,=20launch=20RoT,=20brokers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the multi-party attenuation model (a caveat chain: launch → box → artifact → broker → DoltHub, each verified by a different party — "more than the box, more than the cloud"), GitHub web-flow signing on the token's authority, passing a root of trust from the launch origin, and three concrete broker realizations (GitHub Actions + Environments, Cloudflare Worker/Tunnel, keeperd) with recommended sequencing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Amt3X8s8CEdJW5civefipL --- docs/prx/cloud-box-attestation.md | 109 +++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/docs/prx/cloud-box-attestation.md b/docs/prx/cloud-box-attestation.md index fc4e0992..d769d3fd 100644 --- a/docs/prx/cloud-box-attestation.md +++ b/docs/prx/cloud-box-attestation.md @@ -7,7 +7,10 @@ > **external broker** without ever placing a credential inside the box. The > `.claude/attest-box.sh` SessionStart hook emits the attestation this ADR > specifies. Sibling to `docs/prx/beadsd-door-wiring.md` (the door that would -> consume it). +> consume it) and to the keeper door (`docs/spikes/keeper-door-secret-validation.md`, +> prx-b44y) — one candidate broker. Attenuation is a **multi-party caveat +> chain**, not a box boundary (see *Layered attenuation*); the broker holds the +> DoltHub credential off the cloud entirely (see *Broker realizations*). ## Problem @@ -122,6 +125,110 @@ restriction *is* capability attenuation (`git-gateway-permission-intersection`), and `signed-ref-snapshot` / `worktree-provenance` are the artifact-identity layer the broker checks. +## Layered attenuation — a caveat chain, not a box boundary + +Attenuating "what Claude can do" **only** at the box boundary is too weak: the +box's one authenticated power (push to its branch) is a coarse grant, and a +single boundary is a single point of forgery. Attenuation must be a **chain of +caveats**, each narrowed and — critically — **verified by a different party**, so +that no single compromise (not the box, not Anthropic's platform, not GitHub +alone) is sufficient to effect the privileged write. Two slogans capture the two +directions this must extend: + +- **More than the box.** Authority is narrowed at every hop, not just at the + sandbox edge: launch → box → artifact → broker → DoltHub. +- **More than the cloud.** The *roots of trust* are distributed off the cloud: + GitHub (signer + branch/ACL authority), the launch origin (the operator's + pre-registration / launch key), the broker's own host-backed key, and an + optional human gate. Anthropic's cloud channel *mediates* but is never the sole + root. + +| Hop | Authority it holds | Attenuated to | Verified by | +| --- | --- | --- | --- | +| Launch origin | operator account + optional launch key | one session, one `(repo, branch)` | operator (pre-registration) | +| Box | GitHub-proxy push | its working branch only | the proxy (per-branch restriction) | +| Artifact | a web-flow-signed commit | one bead request + its digest | GitHub (`verification.verified`) | +| Broker | the DoltHub secret | one scoped write per verified request | itself, re-checking GitHub | +| Human gate (opt.) | approval | release / deny | reviewer (Environment / keeper lease) | +| keeperd | host-backed signing key | the signed push, nothing else | a key that never entered the cloud | + +Each row is a caveat over the last — the `git-gateway-permission-intersection` +model, extended past the git edge to the DoltHub write. + +## Signing on the token's authority — GitHub web-flow + +A GitHub token is a bearer credential, not a signing key, and the box holds only +a `proxy-…` translation of it — so the box cannot *sign* with it. But a commit +**created through the GitHub API** (contents endpoint / merge) is signed by +GitHub's `web-flow` GPG key and returns `verification.verified = true`, +attributed to the session's authenticated identity. So the broker upgrades step +1: the box **creates the bead-artifact commit via the API** (web-flow signed) on +its branch rather than `git push`-ing it, and the broker checks +`verification.verified == true && reason == "valid"` and that the signer is +GitHub's web-flow key. This is a *portable cryptographic signature* over the +artifact — evidence that survives outside the branch context — with still no +secret in the box. + +Caveat on what it attests: the signature is **GitHub's**, asserting "GitHub made +this commit on behalf of the token-holder," *not* "the user's private key signed +this." It proves account-authorized-via-this-session, mediated by GitHub. Local +`git push` commits are **not** web-flow signed — they are attributed only by +spoofable email→account mapping — so attestation must use API-created commits. + +## Passing a root of trust from the launch origin + +The box has no innate root of trust, but the **operator who launches it does**, +and can seed one at launch (`claude --remote`, the Remote/Routines SDK): + +- **Correlation key (no secret).** The launch returns `{session_id, repo, + branch, account}`; pre-register it with the broker. A submission proving + GitHub-verified control of that `(repo, branch)` and reporting that + `session_id` is bound to the job the operator actually started — turning + "*some* session controls this branch" into "the session **I launched** does." +- **Delegated launch key (strong).** Mint an ephemeral keypair on the trusted + machine; inject the private half at launch, register the public half with the + broker. Now the box can sign the artifact / a broker challenge, verified + against the pre-registered key. Best form: use it as a git commit-signing key + (`git commit -S`), optionally registered to the account via the token + (`POST /user/gpg_keys`) so it *also* verifies on GitHub — two independent + anchors on one object. + +Boundary: a launch key protects against a forging third party, not against the +platform itself (a compromised base image could read it from the env within its +validity window — there is no TPM/SEV/IMDS to detect that). This is acceptable +only because the same platform already holds the real GitHub token behind the +proxy — the launch key rides trust already extended, it does not widen it. Scope +it **single-use, session-scoped, short-lived**. + +## Broker realizations + +Three ways to stand up the door that holds the DoltHub credential outside the +box and performs the write on verified evidence: + +| Realization | Where the secret lives | Reaches the box how | Attenuation layers it adds | Cost | +| --- | --- | --- | --- | --- | +| **GitHub Actions + Environments** | repo **Actions secret** (`DOLTHUB_TOKEN`) | box pushes / dispatches → workflow runs on GitHub's runners | branch protection, CODEOWNERS, **Environment required-reviewers** (human gate), secret scoped to the workflow | **lowest** — no new infra | +| **Cloudflare Worker / Tunnel** | Worker secret (or Access-gated origin) | box calls the Worker over egress (allowlist its domain); or a **Tunnel** fronts a local keeperd | Cloudflare Access (mTLS/service tokens), edge rate-limit, Worker re-verifies at GitHub, DoltHub write via its HTTP SQL API | medium — deploy a Worker | +| **keeperd (prx-native)** | **host-backed** podman secret → tmpfs (never in image/git) | door-bridge (authenticated TCP/vsock) from the pod; from cloud, via a Cloudflare Tunnel ingress | caveat-based leases (`forge-d` scoping), the keeper door signs/pushes and nothing else, key is a **separate** root of trust | highest — run keeperd + tunnel | + +Recommended sequencing: + +1. **Start with GitHub Actions + Environments.** It uses the GitHub secret store + you already have, needs zero new infrastructure, and its **Environment + required-reviewer** gate is the "human approval" caveat — realizing *more than + the box* immediately. The box produces a web-flow-signed request commit; a + workflow (not the box) verifies it, installs bd+dolt, and writes the bead with + the secret. This is the fastest path to a working, multi-party broker. +2. **Graduate to keeperd fronted by a Cloudflare Tunnel** as the end-state — the + prx-native door already validated by `prx-b44y` (host-backed secret + live + push), with the Tunnel giving the cloud box an authenticated ingress to reach + a keeperd you run. Richest attenuation (lease caveats) and the strongest + *more than the cloud* posture: the signing key never touches Anthropic infra. + +A standalone **Cloudflare Worker** is the middle option when you want an +always-on edge broker without running keeperd — DoltHub's HTTP SQL API makes the +write feasible from the Worker, and it re-verifies against GitHub itself. + ## Alternatives considered - **Inject the credential into the environment.** Rejected: no secrets store — From c120f9401ec8109e9374dcc159fdfd7473959f8e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 14:16:21 +0000 Subject: [PATCH 6/6] docs(prx): data-plane decision + bead-write broker scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records that bd is always the writer (cloud box holds no dolt): keeperd + dolt-box, or bd → a remote dolt sql-server; never the raw DoltHub HTTP SQL API (off-model, bypasses bd's ids/events/schema). Adds the bead-write broker contract (spec-as-schema, mirroring keeperd/contract.ts) and the fail-closed keeper-side verify → bd create → signed-push pipeline reusing the prx-b44y host-backed-secret path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Amt3X8s8CEdJW5civefipL --- docs/prx/cloud-box-attestation.md | 88 ++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/docs/prx/cloud-box-attestation.md b/docs/prx/cloud-box-attestation.md index d769d3fd..cbdcb041 100644 --- a/docs/prx/cloud-box-attestation.md +++ b/docs/prx/cloud-box-attestation.md @@ -226,8 +226,92 @@ Recommended sequencing: *more than the cloud* posture: the signing key never touches Anthropic infra. A standalone **Cloudflare Worker** is the middle option when you want an -always-on edge broker without running keeperd — DoltHub's HTTP SQL API makes the -write feasible from the Worker, and it re-verifies against GitHub itself. +always-on edge broker without running keeperd — but to stay aligned it must +still route the write **through bd** (see *Data plane*), not hand-write SQL. + +## Data plane — bd is the writer; the box holds no dolt + +A bead write is **not raw SQL**: bd owns id allocation, dependency edges, the +events audit trail, JSONL export, and high-water marks. So the write must go +*through bd*, or it drifts from bd's invariants (the same drift the repo's +dolt-canonical rule forbids). This ranks the deployment shapes — and note the +**cloud box never needs dolt**; it holds nothing and only submits the signed +artifact. dolt lives in the *broker's* domain: + +| Shape | dolt instance | Who writes | Aligned? | +| --- | --- | --- | --- | +| keeperd + `dolt-box` (local) | a real dolt server you run, ingress-fronted | **bd** → local dolt; keeper pushes | **best** — this *is* `beadsd-box`+`dolt-box`+keeper | +| bd → remote dolt sql-server | dolt as a shared service (hosted / managed `dolt-box`); no local clone | **bd** over MySQL protocol (`dolt_mode: server`) | aligned — bd stays the writer | +| raw DoltHub HTTP SQL API | none | reimplements bd's schema + events | **off-model** — bypasses bd | + +Chosen: **bd is always the writer.** Run it against a local `dolt-box` (richest, +prx-native) or point it at a remote dolt sql-server (lighter ops) — but never the +raw HTTP SQL path. + +## Scaffold — the bead-write broker contract + +Mirrors keeperd's spec-as-schema seam (`packages/prx/src/keeperd/contract.ts`): +both ends `parse()` every frame, so a malformed request is a validation error at +the seam, never a half-executed write. The box holds no dolt and no secret; it +submits the bead plus the GitHub-anchored proof the door **re-verifies**. + +```ts +// Box → broker (keeperd). The door NEVER trusts these fields — proof.* is +// re-verified against GitHub before any write. +export const BeadWriteRequestSchema = z.object({ + kind: z.literal("bead-write"), + // The proposed bead — these become `bd create` args (bd is the writer). + bead: z.object({ + title: z.string().min(1), + type: z.enum(["bug", "feature", "task", "chore", "epic"]), + body: z.string().default(""), + // optional: labels, priority, dependency edges — whatever bd create accepts + }), + // The evidence, re-checked at GitHub (an authority independent of the box). + proof: z.object({ + repo: z.string().min(1), // owner/repo + branch: z.string().min(1), // the box's working branch (push-restricted) + artifactCommit: Sha1, // web-flow-signed commit carrying the bead artifact + artifactDigest: z.string().min(1), // sha256 of the canonical bead artifact + sessionId: z.string().min(1), // cse_… — matched to the operator's launch pre-registration + // optional delegated-launch-key signature over (artifactDigest || nonce) + launchSig: z.object({ alg: z.string(), sig: z.string(), nonce: z.string() }).optional(), + }), +}); +// Response mirrors KeeperRemoteResponse: ok { beadId, doltPushRef, signedDerivation? } +// | error { code, message }. +``` + +Keeper-side pipeline — **fail closed**, each step a caveat verified by a +different party: + +1. **`parse()`** the frame (schema seam). +2. **Ingress auth** — the call arrived over an authenticated channel (door-bridge + TCP/vsock, or a Cloudflare Tunnel service token), not an open port. +3. **GitHub re-verify** (independent authority): `artifactCommit` is on `branch` + of `repo` with `verification.verified == true && reason == "valid"` and signer + = GitHub web-flow; its tree contains the artifact whose sha256 == + `artifactDigest`; and the authenticated identity can read the ACL repo (the + `inject-org-context.sh` maintainer gate). +4. **Launch binding** — `sessionId` ∈ the operator's pre-registered launches for + `(repo, branch)`; if `launchSig` is present, verify it against the + pre-registered launch pubkey. +5. **Policy caveats** — idempotency/replay guard keyed on `artifactCommit` (a + web-flow-signed commit is unique + tamper-evident, so a replay writes no second + bead), rate-limit, optional human gate (Environment reviewer / keeper lease). +6. **Write via bd** — `bd create --type … --title … …` against `dolt-box` (or the + remote sql-server). bd owns ids/events/schema. +7. **Signed push** — keeperd performs ONLY the security-sensitive step, the push + to DoltHub with the **host-backed secret** (`prx-b44y`: podman secret → tmpfs, + never in an image/git/host-plaintext). Optionally emit a signed + `beadwrite/v1` derivation into a ledger ref, mirroring keeper's `push/v1`. +8. **Provenance return** — reply `ok` with `{ beadId, doltPushRef }`, stamped with + the `session_url` for traceability. + +Reuses, end to end: the box's push-restriction (proxy) → the web-flow signature +(GitHub) → the door's GitHub re-verification → bd's write correctness → keeper's +host-backed signed push. No single party — box, Anthropic platform, or GitHub +alone — can effect the write. ## Alternatives considered