Skip to content

v0.2.0

Choose a tag to compare

@github-actions github-actions released this 07 Aug 18:57
· 104 commits to main since this release
7a8dbdc

Added

  • The tag-triggered release pipeline lands (plan 27 slice 3).
    .github/workflows/release.yml fires on a v* tag and publishes with
    GITHUB_TOKEN alone, invoking only root-Makefile targets so the release's
    executable source stays the Makefile: release-tag-check (the version must
    be the changelog's newest released section — served by the tool's new
    latest subcommand — and the commit must sit on origin/main; nothing
    publishes otherwise), release-images (one multi-arch server build pushed
    as ghcr.io/opensdlc-dev/managed-agent-platform/{controlplane,brain,executor}:X.Y.Z
    — same digest, three names, the coordinates the Helm chart composes — plus
    …/gate:X.Y.Z; deliberately no latest tag; without PUSH=1 it builds
    linux/amd64 into the local daemon and nothing leaves the machine),
    release-chart (the chart, its version and appVersion both checked
    against the release PR's bump before anything publishes, to
    oci://ghcr.io/opensdlc-dev/charts), and release-binaries
    (version-stamped worker tarballs for linux/darwin × amd64/arm64 with
    sha256sums). The GitHub Release's notes come from make changelog-notes CAP=120000: the notes subcommand now clamps an over-cap section to whole
    leading Keep-a-Changelog groups plus a link to the full CHANGELOG.md
    section — GitHub rejects a body over 125,000 characters, and the first
    cut's absorbed legacy backlog (measured 443,570) exceeds it. Deploy docs go
    live accordingly: the Helm values/README image guidance now points at the
    published coordinates (from v0.2.0 onward) instead of "build and push your
    own", README gains the helm install oci://… path, and RELEASING.md's
    "What the tag triggers" section replaces its "Not yet built" placeholder —
    including the one-time note that GITHUB_TOKEN-created GHCR packages
    start private and need a public flip for anonymous pulls.

  • Binaries know their version (plan 27 slice 2). New internal/version
    package — a single Version variable, "dev" unless the build injects it
    via -ldflags -X — wired into the Dockerfile as an ARG VERSION=dev on the
    shared build stage, so both the server image and the gate image stamp their
    binaries at release-build time. All five binaries log it on their existing
    startup line (controlplane listening / brain running / executor running / worker running / gate listening gain a version attribute),
    and the worker — the one binary users download and run standalone — answers
    --version (and -version) with the bare version string before touching
    any configuration. Deliberately no version API endpoint: that would be
    net-new wire surface (plan 27 decision 3).

  • Release management lands — the fragment half (plan 27 slice 1; the plan
    starts in-progress). Changelog entries move out of CHANGELOG.md's
    [Unreleased] section and into changelog.d/ fragments — one file per
    PR per Keep-a-Changelog group, the body being the final entry verbatim (this
    entry is the first one) — so parallel PRs stop contending for the same
    top-of-file insertion point; only a release PR touches CHANGELOG.md, via the
    new make changelog VERSION=X.Y.Z. The assembler (tools/changelog, tested
    with mutation evidence) folds fragments into a dated section in KaC group
    order (entries newest-first by adding commit), moves any legacy
    [Unreleased] body byte-identically below them — the one-time affordance
    the 5,300-line backlog needs — leaves a pointer paragraph as the new
    [Unreleased] body, advances the Keep-a-Changelog link references, and
    refuses cleanly (empty release, existing version, malformed fragment names
    so a typo'd section cannot silently drop an entry) leaving both files and
    fragments untouched; make changelog-notes extracts a released section for
    the coming release workflow. docs/RELEASING.md is the new ritual:
    SemVer 0.x (Added/Changed → minor, fix-only → patch; 1.0 reserved for an
    explicit stability promise), plan-archive-driven timing, annotated
    vX.Y.Z tags, chart version/appVersion in lockstep — with the
    tag-triggered publishing pipeline explicitly marked as arriving in plan 27
    slice 3. CLAUDE.md/AGENTS.md step-2 wording and the verifier's
    docs-consistency rung now require the fragment instead of a direct
    CHANGELOG.md edit.

Added

  • github_repository session resources — the clone lands, and #55 closes
    (plan 25 slice 2; the plan archives). A repository attached at session create
    is now cloned into the sandbox before the agent's first tool runs. The clone
    happens platform-side, in the executor, with go-git
    (github.com/go-git/go-git/v5, a new primary dependency): the executor opens
    the sealed token through secrets.Cipher, clones over HTTPS with the token in
    the Authorization header (x-access-token basic auth, so the on-disk
    .git/config carries only the clean URL), packs the checkout to a tar, ships
    it over the existing WriteFileStream path, and extracts it. The token never
    enters the sandbox
    — no credential helper, no remote URL rewrite, nothing for
    the agent to read — and the egress gate is never involved, because no sandbox
    process talks to GitHub. checkout resolves here rather than at create: a
    branch clones single-branch, a commit clones then checks out the sha, and
    null takes the remote's default branch. Repositories materialize before
    files, so a file mount may deliberately overlay into a checkout.
    Idempotence is probe-only<mount>/.git present means materialized —
    which is what lets a workspace restored from a plan-24 checkpoint keep the
    agent's work instead of being re-cloned over; a marker file would have been
    stripped at capture. Extraction stages into a sibling directory and renames,
    so a <mount>/.git the probe trusts can only ever name a complete tree.
    Failure is surfaced, not fatal: a repository that will not clone records a
    session.error of the new type github_repository_clone_error (reasons
    auth, not_found, network, checkout, too_large, timeout,
    internal, classified over go-git's typed sentinels rather than message
    text — with a status go-git has no sentinel for, a 5xx or a 429, read off the
    transport error and reported as network, because a git-host outage told as
    internal sends the operator to the wrong logs; a remote that answers 200
    with an outage page and a checkout descriptor go-git will not build a refspec
    from are network and checkout for the same reason, and a clone this
    platform cancels — a lost lease, a shutdown — is internal, because it is),
    deduped per
    (resource, reason) so a polling session cannot flood its own log, and carrying
    retry_status: retrying like every other session.error the platform writes —
    the next work item re-probes and clones again, so no clone failure is ever the
    last attempt. The session runs on with its other repositories mounted, and a
    repository that is already materialized is never reported as failed, even on an
    executor whose cipher configuration has drifted away from the control plane's. Self-review found and
    fixed one real leak on that path: go-git copies a failing response's body
    into the errors it builds for 401/403/404, so a host that named the
    credential it rejected — and a host that rejects one has already decoded it —
    put the token into a line we log, on the likeliest clone failure there is.
    Every error the clone returns is now scrubbed of the token verbatim and
    of the base64 basic-auth blob it was sent as, through a wrapper that keeps
    the error chain intact so the reason classification still reads the sentinel
    through it. Two new
    budgets bound the work per repository — EXECUTOR_REPO_CLONE_MAX_BYTES
    (default 1 GiB, metered as the bytes land, symlink targets included, and
    shared across the .git chroot go-git takes, so an oversized repository is
    abandoned mid-clone rather than after) and EXECUTOR_REPO_CLONE_TIMEOUT
    (default 5m, covering the checkout and the packing as well as the fetch, which
    is all go-git's own context reaches) — both exposed in the compose file and
    the Helm chart. The executor judges a mount path again before building the
    rm -rf that lands a checkout there, refusing the sandbox workdir, the skills
    tree materialized into it moments earlier, and another repository's staging
    path — three things the control plane cannot know it is looking at. The brain
    appends a "Mounted repositories" block to the system prompt naming each
    path, url, and checkout, gated to cloud environments: BYOC workers
    materialize nothing (deliberate, #322), so asserting a checkout there would be
    a false statement to the model. Unit M of the plan's verification matrix
    lands as executor and brain integration tests — a real git repository served
    over real smart-HTTP by an in-package fixture, ten rows against real Docker
    sandboxes, and a token sweep of the materialized .git — with red-run
    mutation evidence for every new guard, the review rounds' included, and the
    post-fetch context bound pinned as the bound it is rather than as three
    separate guards (its re-checks are redundant by design — each downstream one
    catches what the one before it would have — so no single one's removal is
    observable)
    (docs/HISTORY.md carries the running record). The end-to-end repo-answer eval
    joins the opt-in suite (RUN_EVALS=1 plus GITHUB_EVAL_REPO_URL /
    GITHUB_EVAL_REPO_TOKEN in .env): a passphrase reachable only through a
    real cloned GitHub repository, asked for without naming the mount, so the
    brain's block is the only way to find it. Divergences: the slice-2 clone
    semantics registered INFERRED and the no-BYOC-materialization stance
    CONFIRMED in docs/DIVERGENCES.md.

  • github_repository session resources — the wire half lands (plan 25
    slice 1; the git half of #55 starts, and the plan flips in-progress).
    POST /v1/sessions resources[] now accepts the repo variant: an exact
    canonical https://github.com/{owner}/{repo} URL (userinfo/port/query/
    fragment carriers all 400 — the url is a rendered field and must never
    smuggle a credential), a write-only authorization_token (non-empty, ≤8 KiB)
    sealed through secrets.Cipher into the new session_resource_credentials
    table (migration 0020, ciphertext + key id, ON DELETE CASCADE with the
    session), and a strict branch/commit checkout union stored and rendered
    as given (null when omitted). The rendered resource is token-free by
    construction — the echoed jsonb never holds the secret. Token rotation
    (POST …/resources/{rid}) goes live: re-seals the ciphertext, bumps the
    resource's updated_at, returns the full rendered resource; file resources,
    archived sessions, and cipher-less deployments keep their rejections.
    Post-create immutability is total — the add endpoint stays file-only
    (wire-faithful per the SDK's typed Add) and repo DELETE is rejected
    (attached-for-lifetime). Mount rules: clean-form literal paths (unlike the
    file arm, which #323 roots under the uploads directory — the cross-resource
    uniqueness and nesting rules compare files by their resolved paths),
    no resource above a repo mount — enforced at create and by the post-create
    add alike — no nested repos, / and /tmp reserved, at most eight repos
    per session; the derived default mount is validated like a supplied one,
    with the URL grammar bounding both segments so the default is clean and
    storable by construction. Tokens seal before the transaction opens
    (create and rotation both, the vault-credential precedent), so the cipher's
    network round trip never runs under the session row lock. The review round
    also hardened the OpenBao client's error scrub for every sealed secret
    (vault credentials included): a hostile transit endpoint reflecting the
    request's plaintext decoded — not just verbatim — into its error body is
    now redacted in both forms before the text can reach a log. Unit W of the plan's verification
    matrix lands as pgtest integration tests — token-sweep probe across every
    response surface included — with red-run mutation evidence recorded for each
    new guard. Divergences: the create-rejection entry carved down
    (memory_store alone stays rejected) and the repo validation/rotation
    strictness registered INFERRED in docs/DIVERGENCES.md.

  • Plan 25 authored (draft): git/repo mounting — github_repository session
    resources
    (docs/plan/25_git-repo-mounting.md,
    the second half of #55; the Files half landed with plan 08). Deep-researched against
    the reference on 2026-08-06 — the public managed-agents GitHub guide and
    session-resources API pages, the anthropic-sdk-go typed schema (pinned v1.61.0,
    byte-identical to the checkout tip), and the ant CLI + SDK worker source, which
    prove the clone is server-side (zero client-side repo handling) and that the
    reference mounts nothing on self_hosted environments. Three user-settled decisions
    (2026-08-06) shape the design: the clone is control-plane resource materialization
    via go-git — the token never enters the sandbox and the egress gate is never
    involved; BYOC stays reference-faithful (no worker materialization; #322 takes the
    symmetric extension); and a failed clone surfaces as a
    github_repository_clone_error session.error variant without failing the run.
    Two slices: the wire + sealed token storage (secrets.Cipher, migration 0020,
    token rotation goes live), then the executor clone + the brain's "Mounted
    repositories" block + the repo-answer eval. The plan carries an executable
    verification matrix — fixtures + acts + observations at the wire / sandbox-filesystem
    / event-log surfaces, mandatory adversarial probes, a mutation duty on every new
    guard, and PASS/FAIL/BLOCKED/SKIP verdicts — adapted from the verifiable-react
    runtime-observation philosophy.

  • Plan 26 drafted — verification hardening: checkers proven by what they
    refuse, verdicts that travel with their evidence

    (docs/plan/26_verification-hardening.md).
    The plan comes out of a comparative study of Anthropic's how-we-claude-code workshop sample
    (a "verifiable component architecture": runtime observation at the surface,
    mandatory adversarial fixtures, a PASS/FAIL/BLOCKED/SKIP verdict taxonomy)
    against this repo's regime. Most workshop ideas are already here in stronger
    form; five gaps survived the adversarial comparison, and the plan closes them
    in five independent slices: known-bad subjects the contract suite and eval
    graders must be seen to refuse; a BLOCKED(environment) marker on
    environment-provisioning failures (labeled, still red); a structured per-rung
    JSON verdict from the verifier plus red-run evidence required by default for
    the diff's own new guards; failure artifacts that carry their own rerun
    command; and an acceptance harness that dumps its buffered wire transcript on
    failure. Equally deliberate is what it refuses to build: no self-declared
    introspection contract, no implementer/certifier unification, no
    BLOCKED-as-skip, no replay video, no registry over go test.

  • The idle-TTL tier: an idle session's sandbox is checkpointed and reaped,
    and the next message gets it back
    (plan 24 slice 5 — the final slice; #64,
    closing the workspace-continuity half of #28). The reaper gains its fourth
    tier: an idle cloud session whose last activity
    (sessions.updated_at) is older than the new EXECUTOR_SANDBOX_IDLE_TTL
    (default 24h; 0 disables; compose and Helm expose the knob — the Helm
    template reads the value through toString, so the YAML integer 0
    disables the tier instead of being folded into "unset" and silently arming
    the default) has its
    workspace captured through the slice-4 engine and its sandbox destroyed —
    the TTL tier is the engine's first production trigger. Three exclusions,
    all evaluated under the session's advisory lock: a session owing work (a
    queued/starting/active work item — a pending harvest or tool run
    must find the tree it was enqueued against, read in the same snapshot as
    the status and TTL-age predicates — or one stopped within the executor's
    lease TTL
    : an interrupt cancels the row instantly but its physical
    claimant only notices at the next lease renewal, so until a lease TTL has
    passed the tool may still be running in the sandbox; normally-completed
    items carry no stopped_at and no such grace), a session with an unanswered
    tool-confirmation ask (HITL-idle is still mid-turn; the ask check is its
    own, deliberately earlier read — ordered before the main criteria query,
    because a
    confirmation batch answers the ask, enqueues the tool's work and flips the
    session running in one transaction — asks-first means that transaction can
    never land between the two reads with both coming back permissive), and a
    disabled tier (zero TTL, or an executor with no object store — which logs
    the disablement once at startup rather than silently discarding
    workspaces). user.interrupt still never reaps; an
    interrupted-then-abandoned session falls to the TTL like any other. Only
    the capture failures the sandbox itself causes degrade (loudly) to
    reap-without-checkpoint — the workspace over the budget and an unreadable
    sandbox, exactly the two plan 24 D8 sanctions (too_large and error are
    separate metric outcomes; a failed capture writes no marker, so the next
    provision starts fresh) — while a failure outside the sandbox (the
    executor's spool disk, the object store, the marker write) aborts the
    reap
    : the sandbox stays owned, the TTL is a floor not a deadline, and
    the next pass retries with the workspace intact (the review caught the
    first cut degrading on every failure, turning a transient blob-store
    outage into permanent workspace loss). The session_checkpoints
    marker row's cleanup owner is the deleting transaction itself
    (deleteSession) — the acceptance run caught the reaper being unable to
    own it: a session whose sandbox the idle tier already reaped never
    reappears in Owned, so a row left to the reaper's deleted tier would
    linger forever — and the capture's marker write is guarded against the
    reverse race: it inserts under a KEY SHARE lock on the session row only
    while that row still exists, so a DELETE landing mid-capture cannot have
    its marker resurrected (the capture withdraws its just-uploaded blob and
    the reap proceeds, deletion having wanted the data gone; a failed withdraw
    aborts instead, leaving the sandbox owned so the next pass's deleted tier
    retries the blob delete). Plan 24 archives with this slice.

  • The checkpoint/restore engine: an idle-reaped session will resume with its
    workspace
    (plan 24 slice 4; #64, the workspace-continuity half of #28).
    sandbox.Provider grows Export(sessionID, root) on both backends — one
    directory root streamed out as a tar (Docker through the archive endpoint,
    which reads a stopped container; K8s through an in-pod tar exec) —
    and the executor gains the engine over it. Capture combines the three
    durable roots (the workdir, the persistent shell's cwd/env state, the
    published deliverables; /tmp deliberately not) into one gzipped tar in
    object storage, stripping the workdir's materialization sentinels so a
    restored workspace re-materializes skills and files instead of trusting an
    agent-writable marker, validating every member (clean relative paths;
    regular files, directories and symlinks only) and budgeting the framed tar
    stream itself against the new EXECUTOR_CHECKPOINT_MAX_BYTES (default
    2 GiB; compose and Helm expose the knob) — the same measure restore's
    decompressed bound enforces, so an accepted capture is always restorable
    under the same cap. Restore fires on the new session_checkpoints
    marker, never on "the container is fresh" — a mid-turn container death
    still recreates fresh on the event log's truth — and runs inside the
    provision lock: a ready marker makes provision replace whatever exists
    (the half-restore replacement rule), re-validate the downloaded tar under
    the same budget, stream it in, extract it with one in-sandbox tar as the
    sandbox user (the transfer path that preserves modes and symlinks), and
    flip the marker consumed only after the extraction, so a crash mid-restore
    is retried, never adopted. Two new instrument pairs:
    sandbox.checkpoint / sandbox.restore counters{outcome} with duration
    histograms. Failure modes fail closed: an executor deployed without an
    object store refuses to provision a session holding a ready checkpoint
    rather than silently handing the agent an empty workspace, executor startup
    refuses a workdir that would alias the checkpoint's other roots
    (ValidateWorkdir), bounds the cap at 1 PiB, and raises the connection
    floor to 3 (provision lock + reaper lock + transient queries each pin one).
    In this slice the engine's only trigger is its test suite — the idle-TTL
    tier that will drive it in production is slice 5.

  • The reaper runs: terminal sessions lose their sandboxes (plan 24 slice 3;
    #64). Sandbox destruction has an owner for the first time: every executor's
    Run now starts a goroutine that sweeps its own endpoint once per
    EXECUTOR_REAP_INTERVAL (default 1m; compose and Helm expose the knob) —
    Owned for the candidates, the session's database lifecycle for the
    verdict, never a caller's claim — and only for cloud sessions: a
    self_hosted session's sandbox carries the same ownership label but belongs
    to the customer's BYOC worker, so on a shared daemon it is skipped in every
    tier (the tombstone records the environment kind because the row is gone by
    the time the reaper asks). A deleted cloud session — its row gone and its
    tombstone present in the new deleted_sessions table
    , written by the
    delete in the same transaction — is reaped and its workspace-checkpoint blob
    deleted (blob first, so a failed delete keeps the retry trigger); a holding
    whose id this database never saw is skipped — a missing row alone also
    describes another deployment's sandbox on a shared Docker daemon or K8s
    namespace, or a contract suite's fixtures next to a compose stack, and none
    of those are this reaper's to destroy; an archived or terminated session is
    reaped with its checkpoint kept until the row goes; idle, running and
    rescheduling sessions are untouchable (the idle-TTL tier is slice 5's).
    Reap-versus-provision is serialized by a per-session Postgres advisory lock:
    provisionSandbox holds it blocking around every provision, the reaper
    try-locks (a provision in flight means the session is in use — skip, next
    pass re-asks) and re-reads the criteria under the lock, on the lock's own
    connection
    , so a session revived between classification and lock is left
    alone and a deliberately tiny pool cannot self-deadlock (a one-connection
    pool is refused at executor startup). DELETE /v1/sessions/{id} also drops
    the checkpoint blob best-effort on a context detached from the request — the
    reaper covers a session that still owns a sandbox, this covers one whose
    sandbox is already gone, which no reap pass will visit again, and a client
    hanging up must not skip it. One new metric: sandbox.sessions.reaped
    counter by tier. This
    closes #64's core: a session's containers no longer outlive it, and the
    cumulative-sandbox ceiling stops being a function of uptime.

  • The sandbox provider learns Owned and Reap on both backends — the
    reaper's hands, with no reaper yet
    (plan 24 slice 2; #64).
    sandbox.Provider grows the teardown contract: Owned(ctx) lists the
    distinct session ids behind every container/pod carrying the ownership label
    on this endpoint — running or stopped, gates included, endpoint-local by
    design so N executors shard the reap with no coordination — and
    Reap(ctx, sessionID) destroys a session's whole holding from its id alone,
    needing no live handle: on Docker it force-removes every labeled container
    with its anonymous volumes, sandbox before its netns-owning gate, waiting out
    the daemon's "removal already in progress" 409 — the window a racing reaper
    actually lands in — rather than surfacing the racer as an error; on
    Kubernetes it selects the session's pods by label (never the derived name)
    and waits until each pod object is gone from the API (the force-deleting
    Destroy's own bound: a partitioned node's kubelet may lag). Both revoke the
    session's gate token
    first through a new provider-level GateTokenRevoker (Reap has no Spec
    to carry one; a failed revoke aborts with nothing removed, so the next pass
    retries both halves — #197's ordering), which cmd/executor wires from its
    pool via backend.Config and the database-less BYOC worker leaves nil. The
    Docker API client gains its one missing endpoint
    (GET /containers/json?all=1 + label filter) and the Helm executor Role the
    matching list verb. Four new shared contract rows (owned-after-provision,
    reap-removes-all, reap-idempotent, reap-unknown-noop) run against both real
    backends; the gated-pair ordering, revoke-first, 404-as-removed and
    label-not-name behaviors are pinned by backend-specific suites (scripted
    daemon, fake clientset), each verified to fail against its target mutant. No
    production caller yet — slice 3's reaper loop is the consumer.

  • Plan 24 drafted — sandbox teardown: the reaper, and the workspace that
    survives it — and its first slice: a running session refuses archive and
    delete
    (docs/plan/24_sandbox-teardown.md;
    #64, and the workspace-continuity half of #28). The plan builds the missing
    destruction lifecycle: a reaper goroutine in the executor (the only process
    holding both the sandbox provider and the pool) as the single owner —
    sandboxes are invisible on the wire, so eventual teardown is
    wire-indistinguishable from immediate — with Owned/Reap joining
    sandbox.Provider, reap criteria derived from the session's database
    lifecycle only (deleted / archived / terminated now; idle-past-TTL with a
    blob checkpoint/restore of the agent's durable state later), a per-session
    advisory lock linearizing reap against provision, and user.interrupt
    deliberately not a reap trigger. Slice 1 is the structural precondition the
    reference documents: archiving or deleting a running session now answers
    400 invalid_request_error (requireNotRunning, checked under the session
    row lock in the same transaction as the mutation, so a confirmation flipping
    the session to running cannot slip between check and commit) — the refusal's
    status and wording are ours, recorded INFERRED in docs/DIVERGENCES.md. No
    gate-token change rides along: gatetoken.Authenticate already fails closed
    on archived sessions. Tests: TestRunningSessionArchiveAndDeleteRejected
    (red before the guard, green after — both mutations refused while running,
    neither mutating the row, both succeeding once idle).

  • Plan 23 drafted: a write the sandbox cannot land becomes the model's error,
    not the platform's fault

    (docs/plan/23_classified-unwritable-write.md,
    docs/REFERENCE_PROJECTS.md; #306). The plan
    records what the reference implementation does — the SDK's
    tools/agenttoolset (the toolset the real ant worker runs) answers every
    write failure to the model as an is_error tool_result, worded by
    fsErrorMessage's four-entry normalization table with raw-text passthrough,
    and puts no error-code taxonomy on the wire — and designs the convergence:
    a new internal ErrNotWritable sentinel (exit 20), both backends deriving
    the reason from the sandbox's own bash strerror text (k8s in-script,
    docker via a classify-on-refusal probe — no daemon-text parsing), and a
    fileFault row that normalizes wording the way the reference does.
    REFERENCE_PROJECTS.md now names tools/agenttoolset as the
    behavior-and-wording authority for agent_toolset_20260401 tools. Status
    draft; implementation is a follow-on PR that flips it in-progress.

  • A session override's replacement system prompt answers to the SDK's
    documented 100,000-character ceiling

    (internal/api/sessions.go, #291). The pinned SDK
    bounds an agent_with_overrides replacement system at 100,000 characters —
    a bound documented on the session override params only; agents' own
    create/update system has no documented ceiling — but nothing enforced it:
    an arbitrarily large replacement prompt was stored and could fail at the
    provider on every turn. resolveAgent now rejects an over-cap override with
    a 400 naming the limit, counted in runes (the filesupload.go precedent for
    character-documented limits, shared with the #66/#289 metadata caps). The
    check binds only what the override supplies: an over-cap stored
    system still resolves via a plain reference or an omitted override field, and
    system:null (clear) is never counted. The reject shape and counting unit
    are ours — recorded in docs/DIVERGENCES.md (INFERRED). Surfaced by the #290
    review pass.

  • A session's resolved agent answers to the whole-spec agent caps
    (internal/api/sessions.go, #287). The #66
    validations ran on agent create/update only, so a session's
    agent_with_overrides could assemble exactly the specs agent create refuses —
    129 tools, a duplicate or unreferenced MCP server, a colliding tool name — and
    every turn of that session failed at the provider instead of the create being
    a 400. resolveAgent now runs wire.go's validateAgentSpec on the merged
    resolved spec — on every resolve, so a plain reference to a stored spec that
    predates #66's enforcement and violates a cap also 400s at create — and
    session update validates the patch's merged result the way agent update does:
    a patch that strands a stored mcp_server or grows past a cap rejects, and a
    rejected update leaves the stored snapshot intact. Whether
    the reference rejects the same set on the session surface is unobserved —
    recorded with the #66 INFERRED entry in docs/DIVERGENCES.md.

  • Session create/update enforce the SDK's documented metadata caps
    (internal/api/sessions.go, #289). The pinned SDK
    bounds session metadata with the same sentence it uses for agents — at most
    16 pairs, keys up to 64 chars, values up to 512 (betasession.go) — but
    sessions enforced nothing: an over-cap bag was stored and echoed. Both call
    sites now run wire.go's shared validateMetadataCaps (rune-counted, same
    messages as agents and vaults): create validates the parsed bag, update the
    post-patch stored bag, closing the gap #66 recorded. Environments stay
    unbounded — nothing documents caps for them (docs/DIVERGENCES.md).

  • Agent create/update enforce the reference's documented caps — a malformed agent
    is a 400 at create, not a stored agent whose every turn fails

    (internal/api/wire.go, #66). The pinned SDK documents
    them on the create/update params: at most 128 tools and 20 MCP servers, server
    names unique and every server referenced by an mcp_toolset in the agent's
    resulting tools, and metadata at most 16 pairs with 64-char keys and 512-char
    values (counted as Unicode code points, not bytes) — the same numbers vaults
    already enforced, so the metadata check is now one shared
    validateMetadataCaps (moved from vaults.go, messages unchanged).
    Tool names must also be unique once the agent_toolset expands — resolved
    against what the entry actually enables, so a custom tool may reuse a disabled
    built-in's name — because a duplicate reaches the Messages API as a 400 on every
    turn of the agent. Update validates the merged result (the SDK's "resulting
    tools" / "stored bag" wording), so an update that clears tools while keeping
    stored servers rejects without bumping the version. The reject messages, the
    entry-counting of the 128 cap, and the not-rejected dangling mcp_toolset
    reference are ours — recorded in
    docs/DIVERGENCES.md; session agent_with_overrides
    applied none of these until #287 closed the gap (entry above).

  • Sandbox pods can pull from a private registry — SANDBOX_K8S_IMAGE_PULL_SECRETS
    (internal/sandbox/k8s/pullsecrets.go,
    deploy/helm, #199). The K8s
    provider's pods carried no imagePullSecrets, so a private executor.sandboxImage
    failed every provision with ImagePullBackOff even though the platform's own pods
    could pull it. The provider now takes comma-separated Secret names — validated at
    startup as the DNS-1123 subdomains the API server requires, refusing empties and
    duplicates, naming the variable (#65's rule, the placement precedent) — and puts
    the references on every sandbox pod at the pod level, so one knob also covers a
    limited session's net-setup image and a gated session's gate sidecar; nil unless
    configured, leaving an
    unconfigured deployment's pods byte-identical. Threaded through
    backend.Config to both cmd/executor and cmd/worker. The chart grows
    sandboxImagePullSecrets (same {name: ...} shape as imagePullSecrets):
    null inherits the top-level list — the sandbox pods run in the release
    namespace, where the same Secrets answer — an explicit [] opts out, and a name
    that would corrupt the comma encoding, or a non-list root that with would
    silently read as the opt-out, fails the render. Tests: the parse contract
    (accept/trim, reject bad name/empty/duplicate naming the env var), podSpec carries
    the references on plain and gated pods and stays nil unconfigured, New refuses a
    malformed value before reaching a cluster, and the CI helm job asserts the
    default render stays clean, the encoding, the inheritance, the opt-out, and the
    render-time refusals.

  • /work/poll honours block_ms — a true long poll on a work-items NOTIFY
    (internal/api/workapi.go,
    internal/queue/queue.go,
    internal/events, #74). The poll used to return
    immediately whatever block_ms said, leaving the protocol correct but chattier than
    the reference's. Now a created tool_exec item rides a map_work_items NOTIFY on the
    enqueuer's own handle (inside a transaction it is delivered on commit, so a woken poll
    can always see the row), the SSE broker's listener carries the third channel keyed by
    environment id, and pollWork holds an empty poll open — subscribe before the first
    poll, then poll → wait on wake/deadline/disconnect, with one final re-poll at the
    deadline. Window semantics follow the SDK's recorded contract: absent = non-blocking,
    the server ceiling is 999ms (over-cap values clamp to it), and an explicit 0 — which
    the reference rejects — is a 400, extended to negative, present-but-empty,
    unparseable, and repeated values. The
    worker's EmptyPollSleep deliberately stays: the reference client sleeps between
    empty polls the same way, so an idle worker's cadence remains wire-identical
    (block + sleep). Reclaim-driven availability has no NOTIFY and is found by the next
    poll, at most one ≤999ms window late. Tests: mid-wait enqueue wakes the poll early
    with the trace-context headers intact, the deadline answers null after the full
    window, over-cap clamps, invalid values 400, the NOTIFY is commit-gated, and the
    broker's wake is environment-keyed.

Fixed

  • A mount_path is rooted under the session's uploads directory, the way the
    reference documents it
    (internal/api/sessionresources.go;
    #323). The public docs state the rule outright — "a mount_path of
    /data.csv places the file at /mnt/session/uploads/data.csv in the
    sandbox", and "paths should be absolute" is that page's style advice, not
    a filesystem root. The platform read the leading / literally and required
    it, so it was wrong in both directions at once: the relative form Anthropic's
    own SDK example and workshop sample use (data.csv, app.log) was rejected
    with a 400
    — the report that opened #323 — while the absolute form from the
    docs' own worked example was accepted and mounted at the container's
    filesystem root, silently putting the file somewhere no client expected
    it. (The SDK example is not perfectly self-consistent — its prompt then names
    /uploads/data.csv, a third spelling matching neither rule — so the docs, not
    the samples, are what this change follows.) resolveMountPath now resolves
    both spellings to the one documented place: /data.csv and data.csv alike
    land at /mnt/session/uploads/data.csv. An already-rooted absolute path
    passes through cleaned but not re-rooted — the reference's data-analyst
    cookbook mounts at the full /mnt/session/uploads/<name> and prompts the
    agent with that same path, so re-rooting it would break the reference's own
    teaching code — and an omitted mount_path still defaults to
    /mnt/session/uploads/<file_id>. A
    path is cleaned before it is rooted, so two spellings of one path resolve
    alike — /../../etc/passwd and /mnt/session/uploads/a/../../../../etc/passwd
    both clean to /etc/passwd and both land at /mnt/session/uploads/etc/passwd.
    Only a relative path can still climb out once cleaned (../etc/passwd),
    and that, along with a path naming the root itself, is a 400. The length,
    storable-text and per-session uniqueness checks now all apply to the
    resolved path, so /data.csv and data.csv collide as one mount instead
    of passing as two; the uniqueness check also cleans the stored side, so a
    non-canonical literal written before this change (/mnt/session/uploads//x)
    still counts as taken against a freshly resolved /x rather than letting a
    second resource silently overwrite the first's bytes. Bounding the resolved
    path also means a supplied path is effectively capped at 1004 bytes once
    rooted, where 1024 used to be accepted. Both the create-time resources[] and
    the POST …/resources add route resolve identically. This changes where a
    newly created mount lands:
    a client that mounts at /workspace/in.txt now
    gets /mnt/session/uploads/workspace/in.txt. That is the point — it is what
    the reference does — and it narrows containment as a side effect: a resource
    created from now on cannot name a path outside /mnt, the sandbox's
    writable set. Two limits on that, stated because the previous wording of this
    entry overclaimed them: resolution is create/add-time only — stored rows
    are not backfilled (deliberately: re-rooting a live session's mount would move
    a file the agent's system prompt has already named), so a pre-upgrade session
    keeps its literal path and can still fail to materialize under a read-only
    root; and containment is lexical, over the stored string — the uploads
    directory is agent-writable, so a symlink planted there can still redirect a
    mount's bytes, the same accepted single-tenant tampering residual the mount
    sentinel carries. One unlooked-for benefit falls out of the same rooting: the
    outputs directory the executor harvests (/mnt/session/outputs) is no longer
    a nameable mount_path, so a caller can no longer inject a file into a
    session's deliverables by mounting into it. The brain's "Mounted files"
    system-prompt block renders the
    resolved path, so the agent is always told where the file actually is. This
    also settles an unresolved review thread from the original Files slice
    (PR #157), which asked for path.Clean normalization before the uniqueness
    comparison.

  • A failed bulk write's payload is taken back too — the #310 fix, extended to
    batches, still executing nothing
    (best effort, as #310's own is: the
    emptying raises no error, and a daemon that refuses it leaves the residue
    where the sandbox user already could not reach it)
    (internal/sandbox/bulkwrite.go,
    internal/sandbox/docker/docker.go,
    internal/sandbox/k8s/k8s.go; #316, raised by
    the verifier and the Claude reviewer on #310's PR). #310 covered the single
    write. A batch lands the same way — the docker daemon extracts its members on
    the host, as root — but its only sheds were the sandbox user's rm, in
    __map_bulk_rename and in __map_bulk_discard alike, so a batch refused
    under a parent that user cannot write left every member's full payload
    behind, up to 10,000 of them. Measured on a real non-root image before the
    fix: a two-member batch into /etc left 8192 bytes the sandbox could neither
    unlink nor read away
    (TestBulkWriteIntoARootOwnedParentOnANonRootImage, red first).

    Both sheds now name what their own rm could not take — on stdout, a
    member by its manifest index and the two bookkeeping files as m/d, two
    builtin tests per member and no process — and the docker backend empties
    exactly those through the daemon in one archive: a refused batch leaves
    every member at once, and ten thousand HEAD-plus-PUT round trips is not a
    cleanup. Naming rather than emptying blindly is what keeps the pass from
    recreating a temporary the rm already removed. The report steers nothing:
    the shell counts a manifest the sandbox can rewrite, but the index is
    resolved against the platform's own list, so what is emptied is always one of
    the paths that batch chose — never a target, never a file the batch did not
    put there. A successful batch is asked too, its last act being the rm of
    the bookkeeping files the same root extraction landed in the workdir.

    Two ways the sandbox could have answered for itself are closed rather than
    assumed, both found in review. An image owns this stream before the script
    does — bash -c sources BASH_ENV, the channel #310 measured — so a forged
    map-bulk-left 0 would have put an empty file back exactly where the cleanup
    had just taken one away; only the lines after the shed's own opening marker
    are read now, and a stream without one is not a report at all. And the
    manifest the shed walks lives in the sandbox's workdir, delivered a round
    trip before the exec that reads it (#206 named that window), so deleting one
    file would have left the pass with nothing to name and stranded every
    member — the shed now says when it lost its list, and the one branch that
    knows the members were delivered answers by emptying the platform's own list
    instead.

    Both of those answers were themselves wrong on the first attempt, and the
    verifier measured both against a real daemon before merge. The opening marker
    is printed with a newline in front of it, because the stream arrives as
    frames concatenated with no separator: an image whose output does not end in
    a newline absorbed the marker into its own last partial line and left its
    forged copy as the last valid one — the framing handing an attacker exactly
    what it was written to take away. And the list that answers a deleted
    manifest carries the members only: it had carried the two bookkeeping files
    too, which that same branch removes itself before reporting, so the
    fallback recreated as zero-byte files precisely what the shed had just
    deleted. Both are pinned by rows that go red without the fix.

    The branch that must not empty is the same one as in #310: a rename whose
    exec failed may have left a mv in flight, so it keeps rm and stops
    there, and a fake-daemon row asserts no emptying archive is sent even when
    the shed reports the whole payload still present. The one #310 called
    "vacuous to fix" — the reported-fault return that sheds nothing because
    __map_bulk_rename has already rm -f'd — turns out to be where the residue
    actually is: under a root-owned parent every member's mv is refused and
    every rm with it, so that return now empties rather than re-running a
    discard whose rm has already failed.

    The k8s twin needed no emptying — its tar runs inside the pod, as the
    sandbox user, so nothing it leaves is unremovable — but its discardBulk
    now runs on a context detached from the caller's (context.WithoutCancel
    plus a 10-second budget), which #310 gave the docker cleanups and left it
    out of: a batch cancelled mid-transfer could strand up to 10,000 members'
    bytes in the pod until it died. No clientset fake can observe that (an exec
    is a SPDY stream over the REST config, not a typed call, so no reactor ever
    fires), so the row stands up an API server and asserts the pod was asked at
    all — red without the change, 0 requests against 1. Detaching alone would
    not have covered the case it was written for, which review caught: a caller
    that goes away mid-delivery returns from deliverBulk's error branch, which
    shed nothing at all. Both of those branches now shed.

    One limit the batch has that the single write does not, stated rather than
    closed: reclaim asks the daemon directly, so it still empties when the shed
    exec could not run, while a batch's emptying is driven by that exec's report
    and has none without it. Covering it would cost a HEAD per member — ten
    thousand round trips for a sandbox already too broken to run rm.

  • A refused write's payload no longer sits in the sandbox for the
    container's life — and no privileged exec was introduced to remove it

    (internal/sandbox/docker/docker.go,
    internal/sandbox/docker/api.go; #310, the
    verifier's finding on #306's PR). The docker daemon extracts a write's
    archive as root, so when the rename is what fails — a root-owned parent
    under a non-root uid, the route #306 classifies — the script's own rm -f
    runs as a user who cannot unlink from that directory, and the refused
    payload stayed behind as a root-owned .map-write-* file, one per refused
    write (measured on a real non-root image). The daemon is asked to take
    back what it landed
    : after a HEAD that says something is still there, the
    same archive endpoint extracts a zero-byte entry of the same name over it.
    Where that succeeds the payload is gone and only an empty name remains; it
    reports nothing of its own, so it is best effort and a daemon that refuses
    leaves the residue the sandbox user already could not shed. A put that dies
    mid-transfer gets the same treatment, its residue being a partial payload
    rather than an empty name (a verifier finding on the first exec-free
    iteration).

    Two boundaries came out of review. The emptying is not asked for on the
    branch where the rename's exec itself failed: that error does not say the
    script never started, so a mv may be in flight, and emptying the temporary
    under it would land zero bytes on the very target the caller is being told
    it did not touch. That branch unlinks with the sandbox user's rm and stops
    — keeping a payload the container will take away beats destroying data the
    write promised to leave alone (Codex, round 5; the fake-daemon row asserts
    no HEAD and no archive on that route). And every cleanup on this backend
    now runs on a context detached from the write's own (context.WithoutCancel
    plus a 10-second budget) — the two single-write sheds, and the batch's
    discardBulk alongside them — because a tool call that timed out
    mid-transfer is exactly the write whose residue must still be shed:
    inheriting its cancellation skipped the cleanup in the case that caused it,
    and left a batch's members (up to 10,000 of them) behind for the container's
    life. Sites that shed both ways can now hold a failed write for two budgets
    before returning, which the code says out loud. Codex and CodeRabbit found
    the single-write half; the verifier found that the batch had been left out
    of it. The k8s twin keeps the caller's context for now — its temporaries are
    the sandbox user's own, so nothing there is unremovable, which is what
    #310 is about — and #316 carries the question for both backends.

    The route not taken is the point: the obvious fix — an exec as User: "0"
    running rm -f — was written, measured against four escalation channels an
    image controls, and abandoned, so the platform still runs nothing in a
    sandbox as anyone but the sandbox's own user
    . What pins that is structural
    (docker's execConfig has no User field to set); the real-image row
    TestTheRootShedRunsNoAgentCodeOnANonRootImage covers the shell-hook
    channel behind it. The evidence, the four channels (over the premise they
    all sit on) and the rejected design are recorded in
    docs/HISTORY.md. The k8s backend needs nothing here:
    its temporary is created by the sandbox user, so the same credential that
    made it removes it. The docker bulk write is knowingly left out — its
    shed is the sandbox user's rm, and what makes that enough is the caller
    (the one batch the platform writes goes under the workdir), not the
    directories it creates, since mkdir -p leaves a root-owned parent that
    already exists exactly as it found it — and #316 tracks closing that rather
    than resting on the caller. Red observed first on every row: the real-image residue
    (the refused payload's bytes in /etc, want 0), the fake-daemon emptying
    archive that did not exist, the raw route's re-pin — where the script's own
    rm worked, no emptying archive may be sent, or the cleanup would make the
    litter it exists to remove — and round 5's three.

  • A write the sandbox cannot land is the model's error, not the platform's
    fault
    (internal/sandbox/sandbox.go,
    internal/sandbox/filefault.go,
    internal/sandbox/k8s/k8s.go,
    internal/sandbox/docker/docker.go,
    internal/toolset/file.go; #306,
    plan 23, archived in this
    PR). A replaceable target whose temporary file could not be created — a
    read-only root outside the writable mounts, a root-owned parent under a
    non-root uid, a full disk — or whose missing parent could not be made,
    failed raw: k8s's exit 1, docker's daemon http 400, both abandoned by
    the executor to lease reclaim and retried until the lease ran out. The
    reference's own toolset answers every write failure to the model
    (tools/agenttoolset: each failure path returns through errorf,
    surfaced as an is_error tool_result; no error-code taxonomy on the
    wire), so both backends now classify: ErrNotWritable (exit 20), carried
    as PathNotWritableError with the sandbox's own bash strerror text as
    the reason — k8s's write script captures the failed create's or mkdir's
    message in-script and exits 20 with the reason on stdout (a path fault
    still wins at 15; the mid-stream tee failure stays a raw exit 1, a
    failure of the transfer rather than the target); docker attempts the same
    create in a classify-on-refusal probe exec when the daemon refuses the
    PUT on a replaceable target, takes mkdir's own stderr tail in
    mkdirAll, and asks the same probe after a failed rename — the daemon
    extracts as root, so a root-owned parent under a non-root uid takes the
    PUT and refuses only the move, and a probe that succeeds keeps the raw
    error there, a failure of the transfer rather than the target — no
    daemon-text parsing anywhere, and both backends' reasons come from the
    same shell's strerror, which is what keeps #205's identical-answers
    invariant. The toolset words the result the way the reference's
    fsErrorMessage table does: both permission spellings (EACCES's
    Permission denied, EPERM's Operation not permitted — the table
    matches fs.ErrPermission, which Go answers for both errnos) take the
    reference's permission denied, everything else passes through raw. Red observed first on every
    new test: the host-bash exit-20 + reason rows, docker's probe
    classification, mkdirAll strerror, and rename-refusal tests (the last
    measured against a real non-root image, whose raw mv … Permission denied was exactly the abandoned-to-reclaim error), the toolset table
    rows, and the read-only-root contract cells tightened from err != nil
    to ErrNotWritable (buffered, quarter-megabyte streamed, and a
    new-parent cell — green on both real backends in seconds). docs:
    DIVERGENCES' #205 convergence entry gains the Closed (#306) record,
    ARCHITECTURE's sandbox.go/toolset/contract rows name the sentinel and
    its wording provenance, and plan 23 archives with its summary in
    HISTORY.md.

  • Every exit the k8s write script can take with the body unread now drains
    it first, so a failed large write returns instead of deadlocking

    (internal/sandbox/k8s/k8s.go,
    internal/sandbox/k8s/k8s_internal_test.go,
    internal/sandbox/sandboxtest/contract.go;
    issue #304, the follow-on #303 filed rather than widened in). Three failure
    branches exited with stdin unread: a mkdir -p the path refuses, a
    temporary file that cannot be created (a read-only root outside the
    writable mounts, a root-owned parent, ENOSPC), and a tee dying part way.
    A small body races the exit code home; past the exec stream's flow-control
    window the stdin copy holds the framer lock the teardown needs, and the
    call hangs until the pod dies — #304's measurement was a 64 MiB streamed
    write onto a read-only root deadlocking three of three on the : >
    branch. Each branch now drains (cat >/dev/null) before its exit, the
    tee branch shedding its temporary first so a teardown mid-drain cannot
    leave it, and the mkdir branch classifying first — __map_path_fault
    runs in a subshell so its verdict is carried past the drain rather than
    exiting ahead of it — because the classification describes the path that
    made mkdir fail, and a drain's worth of sandbox activity later that
    moment has passed (the same freshness the #305 review demanded of the
    unreplaceable probe; raised for this branch by the #307 review pass). Unlike #303's refusal branches, all three can be staged from the
    host's bash, so the drains are pinned by behavior rather than by literal:
    a new script test feeds the body through a pipe whose producer is the
    verdict — head -c 1M finishes when the script drains and dies of
    SIGPIPE when it does not (red-proven: producer exit 141 on all three
    branches against the undrained script) — with the mkdir branch staged
    by a file blocking the path, the : > branch by a directory squatting
    the temporary's name, and the tee branch by a PATH shim dying
    mid-stream. Two quarter-megabyte cells pin the real transport, hanging
    rather than passing on regression: WriteUnderNonDirectory streams large
    onto a blocked path (still ErrNotDirectory, now returned rather than
    hung) and the read-only-root hardening row streams large onto /etc (an
    error, not a hang — the raw refusal's classification is now its own
    tracked defect, #306, deliberately out of this drain pass as it was out
    of #303's). docker is untouched: its script reads no streamed stdin.

  • The unreplaceable-target refusal no longer depends on the write being able
    to start
    (internal/sandbox/docker/docker.go,
    internal/sandbox/k8s/k8s.go, #303). The #205
    probe lived inside the rename scripts, so it only ran when the write got far
    enough to run them — and the very hardening that makes unreplaceable targets
    common keeps it from getting there. Under a read-only root filesystem
    (Hardening.ReadOnlyRootfs, the recommended posture), docker's daemon
    refuses the archive PUT outright before any script (http 400: container rootfs is marked read-only), and k8s's tee cannot create the temporary
    file next to a target on the read-only root: writing onto /etc/hosts (its
    bind mount's parent sits on the read-only root) surfaced the raw refusal on
    both backends — the unclassified fault the executor abandons to lease
    reclaim and the same doomed call retries until the lease runs out, exactly
    what #205 closed for the writable-root case. The k8s write script now asks
    the directory check and the __map_unreplaceable probe before creating the
    temporary file rather than only just before the move, and each refusal
    drains the body (cat >/dev/null) before its exit: sitting ahead of tee,
    the refusals must do its consuming themselves, because an exit that leaves a
    larger-than-flow-control-window body unread deadlocks the exec stream
    instead of returning — measured, and the same pre-existing hazard on the
    script's other early exits is now #304. The unreplaceable probe is asked
    again after the body lands, just before the move — the pre-move position
    it held before this change, and the one docker's rename has always asked
    from — so the early ask buys reachability without trading away freshness: a
    target that becomes a device node while a large body streams is still
    refused, not supplanted (no drain needed there; tee has consumed the body
    by then). docker, whose script the daemon never ran, asks the same questions
    in an exec of its own when the PUT fails, classifying the refusal after the
    fact. The same reorder is what fixes
    k8s's half of the non-root corner (a root-owned /etc or /dev refuses
    the temporary's creation to the sandbox uid); docker's half needed no code —
    its temporary is the daemon's, created as root, so an unwritable parent
    never blocked it. Two new contract rows
    (WriteOntoUnreplaceableTargetUnderReadOnlyRoot,
    WriteOntoUnreplaceableTargetAsNonRoot) pin both hardened postures on both
    backends — buffered, streamed, and quarter-megabyte bodies, the node still a
    device after; the non-root row runs the classification end-to-end as uid
    65534, though with the read-only root necessarily along (RunAsUser travels
    with ReadOnlyRootfs so the entrypoint can make the workdir) the refusal
    fires for both reasons at once, so it pins the posture rather than isolating
    the uid mechanism — and the writable-root device-node row gains a large-body
    cell that hangs rather than passes if a refusal ever stops draining; the
    k8s script-pin test asserts both asks and their order around tee, the
    race between them being nothing a contract row can stage deterministically.
    Found by the #302 review pass (a CodeRabbit finding, verified real and
    deferred to its own issue rather than widened in-PR); the independent
    verifier's large-body measurement is what put the drains in, and the #305
    review pass (a Codex bot finding, independently converged on by the Claude
    review) is what put the pre-move ask back.

  • A write onto a target a rename cannot replace is a classified refusal on
    both backends, not an unclassified fault

    (internal/sandbox/filefault.go,
    internal/sandbox/docker/docker.go,
    internal/sandbox/k8s/k8s.go,
    internal/toolset/file.go, #205). The atomic
    write lands bytes under a temporary name and renames them into place (#71),
    and some targets cannot be renamed onto: a file bind-mounted into the
    sandbox (/etc/hosts) fails the mv with EBUSY, and a device node is
    worse — the k8s mv succeeded and supplanted /dev/null with a regular
    file, while docker failed unclassified because the daemon extracts the
    temporary file into the image's /dev on the overlay, invisible under the
    tmpfs mounted over it. Either way an unclassified error reached the
    executor, which stopped the tool set and abandoned the work item to lease
    reclaim — the same doomed call retried until the lease ran out. Both write
    scripts now ask a shared __map_unreplaceable probe (a device-node test
    plus a /proc/self/mountinfo mount-point walk, shell builtins only)
    before the move and exit a new shared code (19,
    sandbox.ExitPathNotReplaceable — 17/18 belong to the bulk namespace),
    which both backends map to a new sandbox.ErrNotReplaceable sentinel and
    the file tools hand to the model as an actionable result: the target cannot
    be replaced, bash redirection writes through it. A symlink stays
    replaceable even when it points at a device (the rename replaces the link —
    the documented supplant behavior), and the probe misses into the old
    unclassified error rather than ever misclassifying (a mount point whose
    path carries a space, a sandbox without readable mountinfo). The two
    backends now agree on both targets, pinned by two new contract rows
    (WriteOntoBindMountedTarget, WriteOntoDeviceNode — target intact, node
    still a device, no temporary-file residue the sandbox can see; docker's
    daemon-side copy for a device target lands on the overlay under the tmpfs,
    invisible either way) that failed red on both backends
    before the classification landed; bulk writes (WriteFiles) keep today's
    behavior — no built-in tool routes a bind-mounted or device target through
    them. The docs/DIVERGENCES.md atomicity entry's standing residual closes
    with this.

  • The issue-triage bash guard no longer denies every command on hosts where
    python3 is the Microsoft Store stub

    (.claude/hooks/issue-triage-bash-guard.sh,
    #295). The guard's parse step piped the PreToolUse payload to python3 -c
    with || exit 2, so a host whose python3 is the WindowsApps alias stub —
    which exits without reading stdin — denied every Bash call the triage
    subagent made, allow-listed or not, before the allowlist ever ran; triage
    silently degraded to a no-evidence needs_plan default that looked like a
    policy denial, not an environment fault. The parse now probes python3,
    then python, with a known-good document, so with a working interpreter a
    malformed payload still hard-denies exactly as before; only when neither
    works does it fall back to a POSIX sed extraction that fails closed on
    any value it cannot decode faithfully (a backslash in the raw match — an
    escape it would have to interpret, or a truncated match at an escaped
    quote — denies with a rephrase-without-quotes message) or attribute
    unambiguously (anything but exactly one "command" key in the payload
    denies — the greedy match would otherwise judge the last key while the
    harness executes tool_input.command). The allowlist,
    metacharacter screen, and flag denials are untouched and stay fail-closed;
    real-python hosts keep identical exit codes on every row, and the stub-host
    change is that a broken interpreter no longer masquerades as policy.
    Verified by a 19-row exit-code matrix on the stub host (allow rows now 0
    — pretty-printed payloads included; deny rows still 2, among them a
    second "command" key, a blank-line variant of it, and a zero-key
    payload — except the one fallback row, a truncated payload whose
    extractable command is clean and allow-listed, which passes the screens;
    unreachable from the harness, which emits only valid JSON), a 19-row
    old-vs-new exit-code diff under a real python3 (identical on every row,
    malformed JSON included), and a live triage dispatch through the fixed
    hook whose transcript shows zero guard denials.

  • The NUL-sanitization test's preview drain is bounded by a sentinel frame,
    not a coalesced wake
    (internal/brain/nul_test.go,
    #294). One CI run drained zero preview frames from a turn that demonstrably
    settled. The old drain's premise — "the settle commit's NOTIFY follows every
    preview frame, so a single Wake bounds the drain" — misread the wake
    contract: wakes are coalesced re-read signals, and the listener buffers a
    coverage-healing wake the moment LISTEN activates — long before any turn
    activity — so the drain's single Wake() consumed that token and its
    non-blocking frame drain raced the dispatcher — losing under CI load. The
    ordering guarantee is real but lives on the frames lane: the one listening
    connection delivers notifications in commit order, so a sentinel frame
    published after runOnce returns autocommits after the settle and arrives
    after every frame the turn broadcast. The drain now reads the frames lane
    blockingly until the sentinel — which also lets the all-NUL control prove
    an empty set deterministically instead of by timing. The broker is
    untouched: its wake contract is documented, its own tests flush pre-buffered
    wakes (drainWakes) before asserting, and production consumers use wakes
    correctly (re-read after every wake). Evidence: a mechanism probe over the
    old drain observes a wake with zero commits since Subscribe, and the fixed
    test holds green under -race with full-core CPU load.

  • Kubernetes pod adoption validates the pod's fixed-at-create spec, not just
    its ownership label and gate shape

    (internal/sandbox/k8s/k8s.go, #296 — the k8s
    twin of #29, below). Both adoption paths — the existing-pod path and the
    create-race loser — checked only the session's ownership label (ours) and
    the gated-vs-ungated shape (hasGateSidecar), so a session re-provisioned
    under a changed spec silently adopted the old pod: an ungated limited
    request adopted an unrestricted session's pod — which lacks the netsetup
    init container that enforces limited, so the session kept open egress — and
    a changed image or workdir was equally invisible. A pod spec is immutable, so
    none of it could be reconciled by adopting. The k8s adoptable now compares
    the pod's fixed-at-create shape — netsetup init-container presence for the
    ungated case (the gated egress path is already pinned by the sidecar), the
    sandbox container's image, and its workdir — against the normalized requested
    spec on both paths, before the pod is handed to a tool run. A mismatch fails
    closed with the same sandbox.ErrSpecMismatch sentinel and deletes nothing,
    the same contract as Docker's — including the same recovery: the session
    keeps failing provision until the stale pod is removed by hand, with the
    product-level alternative tracked in #297 (the rollout consequence —
    drain before changing networking/image/workdir — is now spelled out in
    docs/self-hosted-security.md, which previously described these settings as
    silently kept). On the existing-pod path the check runs after the
    readiness wait, so the #198 wedged-gated-pod reclaim stays reachable — a pod
    that is wedged and mismatched is reclaimed, not stranded behind a refusal
    that deletes nothing. Reachability matches the Docker
    half: a gate-less deployment's unrestricted→limited flip reaches this path
    today; a gated one is absorbed by the hasGateSidecar replace. Tests:
    fake-clientset rows for each mismatch (with a reactor proving no delete
    touches the pod), the matching-limited adoption, the gated mismatch, and the
    wedged-plus-mismatched reclaim, built from podSpec's
    own output so the fixtures cannot drift from real creates; a live
    kind-cluster test proving the refusal deletes nothing (by pod UID — the
    derived name would survive a recreate) and that the refused pod stays
    adoptable under its original spec; and a new shared contract-suite row
    (SpecMismatchRefusesAdoption) pinning the refuse-then-readopt behavior on
    every backend.

  • Docker sandbox adoption validates the container's fixed-at-create spec, not
    just its ownership label

    (internal/sandbox/docker/docker.go,
    internal/sandbox/sandbox.go, #29). Both
    adoption paths — the existing-container path and the create-race loser —
    checked only that the session's ownership label (and, since #197, the gate
    pairing) matched, so a session re-provisioned under a changed spec silently
    reused the old container: a limited request adopted an unrestricted
    session's bridge container and kept its open egress, and a changed image or
    workdir was equally invisible. A shared adoptable check now compares the
    container's fixed-at-create configuration — the effective ungated
    HostConfig.NetworkMode (gated mode is already pinned by pairedWithGate),
    image, and workdir — against the normalized requested spec on both paths,
    before the container is started or anything runs in it. A mismatch fails
    closed with the new sandbox.ErrSpecMismatch sentinel and deletes nothing:
    replacement is an explicit lifecycle the platform does not have, per the
    issue — so a session whose existing container mismatches keeps failing
    provision until the stale container is removed by hand. Contrary to #29's own
    severity framing (written pre-gate), the networking half is reachable today:
    the control plane accepts an environment networking patch mid-session (only
    the kind is immutable) and the executor reads the environment's config live
    at each tool run, so in a gate-less Docker deployment
    unrestricted→limited reached exactly this adoption path and silently kept
    bridge egress — now it fails closed. (A gated deployment absorbs the same
    flip via the #197 pairedWithGate remove-and-rebuild reshape.) Whether the
    control plane should instead refuse the patch while sessions are live is
    #297. The k8s provider's twin gap was #296, fixed above. Tests: fake-daemon rows for
    each mismatch (structurally proving no start/remove/exec touches the
    container) and the matching-none adoption, plus a real-daemon test reading
    the effective HostConfig.NetworkMode off docker inspect for both the
    refusal and the matching-adoption legs.

  • A claim racing executor shutdown no longer escapes as a fatal error
    (internal/executor/executor.go, #282). Run
    recognised cancellation only as errors.Is(err, context.Canceled), but a Claim
    in flight when the context dies can surface a transport-level failure from the
    dropped connection instead — an i/o timeout on a loaded host in #282's gate run —
    and that error escaped Run where the test (and a supervisor) expect a clean nil
    stop. Run now checks ctx.Err() after a step error, the same
    liveness-after-failure convention worker.Run already follows; a genuine claim
    failure with the context live stays fatal. Same load-induced-flake family as #265
    (2a533ec), one layer up: the connection, not the container start. Tests: a stub
    context whose cancellation lands exactly between the loop-top check and the error
    inspection — the race window, made deterministic — over a closed pool (the stand-in
    for the dropped connection) gets nil from Run; the same closed pool with a live
    context still returns the claim error.

  • A dismantled egress gate's token is revoked, and an ungated re-provision no longer
    adopts a still-gated sandbox
    (internal/gatetoken,
    internal/sandbox,
    internal/sandbox/docker,
    internal/sandbox/k8s,
    internal/executor, #197). Gate-token revocation
    only ever happened as a side effect of minting a successor (Ensure's
    revoke-on-re-mint), so the one transition that dismantles a session's gate without
    replacing it — gated→ungated, when the session's vaults detach or its networking
    stops being limited — left the old token live until the session archived. Both
    backends now revoke it at the moment they observe that transition, through a new
    sandbox.GateTokenRevoker seam carried on Spec itself (deliberately not on
    GateSpec: the provision that needs it is precisely the one with Gate == nil),
    implemented by the executor over gatetoken.Revoke — idempotent, and ordered before
    the teardown so a failed teardown retries both. The K8s call site is the existing
    shape-mismatch pod replacement. The Docker provider had the deeper half of the bug:
    pairedWithGate treated an ungated session as trivially paired, so a gated→ungated
    re-provision silently adopted the still-gate-networked sandbox and never observed the
    transition at all — it now requires an ungated session's sandbox to not be
    gate-networked, and the mismatch path removes the sandbox and — after the same
    ownership-label check every adoption path makes, never by name alone — its gate
    container before rebuilding directly networked. A gate orphaned with no re-provision
    ever arriving remains the standalone teardown reaper's job (#64). Tests: a
    revoked token stops authenticating and a second revoke is a clean no-op; each
    backend's reshape test asserts exactly one revoke on the ungated direction — landing
    before the teardown (the k8s revoke still sees the gated pod, the docker revoke sees
    zero removals issued), the ordering that lets a failed teardown retry both — and
    none on the gated one; the Docker dismantle test asserts sandbox and gate are both
    removed.

  • An uploaded file's extension-fallback mime no longer depends on the serving host
    (internal/mimetab,
    internal/api/filesupload.go, #277 — the #264 fix
    carried to the registry's other writer). fileMimeType still fell back to
    mime.TypeByExtension when an upload part declared no (or a generic) Content-Type —
    the same host-merged lookup #264 removed from the harvest path — so the same filename
    could land different files rows depending on which path created the row and which
    host served the upload. The pinned table #264 built now lives in a shared package,
    internal/mimetab (the harvest's table moved, not forked: internal/executor and
    internal/api both consult mimetab.ByPath, and the grader-coupling and
    host-independence tests moved with it), and the upload fallback consults it in place
    of the process mime registry. Declared-Content-Type-wins, the octet-stream fallback,
    and every table value are unchanged; a regression test uploads a .jsonl file — in
    the pinned table, absent from Go's builtin one — with no declared type and asserts
    the row is text/plain; charset=utf-8 on every host.

Changed

  • The GCP deployment holds no downloaded key material at all: the GCS HMAC pair is gone
    (deploy/gcp, deploy/helm/managed-agent-platform,
    #240, plan 22 slice 2 — the plan is archived with
    this PR). Slice 1 built the backend that needs no credential; this slice moves the
    deployment onto it and deletes what it replaced. The chart grows a third
    object-storage mode, gcsObjectStorage.enabled + .bucket, which emits BLOB_BACKEND
    and BLOB_BUCKET and no credential key whatsoever — mutually exclusive with both the
    bundled MinIO and externalObjectStorage, each rejected at render with a named fail
    rather than by whichever one the template happened to read last, and refused outright
    alongside existingSecret, where the chart writes no Secret and the value would select
    nothing. (Precisely: no credential value exists in the release. The three Deployments
    still declare optional: true references to the S3 keys, because the env helper is one
    uniform block; they resolve to unset because the Secret has no such keys.) deploy/gcp
    follows:
    the map-storage service account, the two Secret Manager containers that held the key's
    halves, and every trace of the pair in bootstrap.sh are removed, and the bucket is
    bound to the identities the three workloads already run under.

    Three bucket grants, not the two the plan predicted, and read from the code rather than
    assumed.
    The controlplane and executor Put, Get and Delete objects, so each takes
    roles/storage.objectUser; the brain only ever Gets, so it takes
    roles/storage.objectViewer. objectAdmin was considered and rejected, on a
    measured difference rather than a remembered one: it adds exactly four permissions over
    objectUserobjects.getIamPolicy/objects.setIamPolicy, which the bucket's uniform
    bucket-level access turns off so nothing could exercise them, and
    objects.setRetention/objects.overrideUnlockedRetention, which are live but govern
    object retention the platform never sets. None of the four is anything the code calls.

    bootstrap.sh drops from 313 lines to 157: with no key to materialize, the whole
    create-once-read-once dance goes, along with its rollback trap, its python3
    dependency, and the two secrets it fed. Its three-outcome version probe stays — the
    distinction between "no version" and "could not tell" is what stops a second version
    landing on top of a live one — and now says in its own comment that the second caller it
    was shaped for retired here. check_split.py's MIN_PROTECTED drops 10 → 7, which is
    what its own failure message asks an operator to do when the foundation legitimately
    shrinks; google_storage_hmac_key stays in its unrecoverable-kinds table as the guard
    that stops one reappearing in the disposable half. One of the three stated reasons the
    foundation/environment split exists — deleting a service account deletes its HMAC keys —
    stopped being true, so the four passages that assert it now record it as retired instead
    of claiming it; the split still stands on the KMS key ring, which cannot be deleted at
    all, and on secrets as the reconciliation source.

    An existing mode-2 deployment migrates by runbook rather than automatically. The three
    retired resources are in Terraform state, and foundation/'s
    deletion_policy = "PREVENT" is read from state rather than configuration, so simply
    deleting the blocks makes a later apply fail — the guard working as designed.
    docs/deploy-gcp.md gains a migration section: the three
    terraform state rm addresses, the deletion order (HMAC key before service account,
    always, since deleting the account takes its keys with it and strands a secret nothing
    can ever read again), and — before anything moves — the three bucket bindings added out
    of band, because make gcp-env-apply adds the new grants and drops the old map-storage
    ones in the same apply, so no ordering of the two Terraform states alone is gap-free.
    It also says to remove the retired blob-* keys from a hand-assembled Secret rather
    than merely stop selecting them: the chart's env helper references every key optionally,
    so one left behind is still injected into all three pods. Terraform's removed blocks
    and deletion_policy = "ABANDON" would each automate the state half, and the runbook
    says why neither is used — a one-time migration would leave permanent configuration in
    foundation/ to serve it.

    The brain's Workload Identity annotation stops being optional and three passages that
    said otherwise are corrected. It used to exist only for the Cloud SQL Auth Proxy, so the
    direct-database path could skip it; the brain now reads the bucket, so mode 2 needs it
    either way. Worth stating loudly because the failure is quiet: the brain's blob reads are
    written to degrade rather than error, so a missing annotation shows up as an unreadable
    rubric graded against the outcome description alone, not as an outage.

    Verified credential-free, which is the whole gate here: make gcp-fmt gcp-validate gcp-split-check gcp-lint plus the three run-rather-than-read tooling tests, all four
    chart object-storage modes rendered with every mutual-exclusion guard fired, and a new CI
    step asserting the keyless mode reaches all three processes — both variables, not just
    BLOB_BACKEND — while emitting no credential anywhere in the release, not merely in the
    Secret.
    No terraform apply was run and the mode-2 acceptance was not re-run — both cost money
    and are interactive by design, so they stay an operator action.

Fixed

  • A harvested deliverable's mime no longer depends on which executor host
    harvested it
    (internal/executor/harvest.go, #264).
    harvestMime consulted mime.TypeByExtension, which merges the Go builtin table
    with the host's /etc/mime.types — so the same outputs tree yielded different
    files rows on different executor hosts (the plan-21 acceptance saw build_dcf.py
    published as application/octet-stream from the compose executor image where a
    desktop host says text/x-python), and, through the grader's text-inline rule
    (text/*, application/json), the host also decided whether a deliverable's
    content reached the grader. harvestMime now consults only a fixed in-code
    extension table: a pinned copy of Go 1.26's builtin table (so a toolchain upgrade
    cannot change registry rows either) plus a bounded set of textual deliverable
    types it lacks (.py .md .yaml/.yml .tsv .log .sql .toml .ini
    .jsonl .rst .tex), those deliberately text/* so the grader's inline rule
    accepts them, and one binary addition (.tar, application/x-tar, alongside
    the builtin .gz/.zip) — with application/octet-stream still the
    unknown-extension fallback on every host. A test seeds the process mime registry
    and proves the seeded mapping cannot leak into a verdict, and pins the
    grader-intended entries to the inline rule's accepted side.

  • The per-test-binary Docker fixtures retry a dead container start instead of flaking
    (internal/pgtest,
    internal/secrets/secretstest,
    internal/blob/blobtest, #265). On a crowded
    Docker daemon a fixture's published port can come up dead — connection-refused for the
    whole readiness budget while sibling fixtures in the same run work. Diagnosing this on
    2026-08-04 showed waiting longer never heals it: pgtest's Postgres stayed refused to a
    120-second ceiling and, with the budget raised as an experiment, to a 300-second one
    (the store suite failing at 123s and then 302s on consecutive single-gate runs), and
    the OpenBao fixture had flaked the same way under a concurrent gate. So each fixture's
    Main now bounds one readiness wait at 150 seconds and, on failure, removes the
    container and tries once more with a fresh one — a fresh port mapping is what actually
    recovers. Around that retry, the failure paths stop eating evidence: the containers run
    without --rm (a crashed entrypoint used to be auto-reaped before its state and logs
    could be read), the failure message carries the dead container's state and last log
    lines, the readiness wait fast-fails as soon as the container is exited/dead
    instead of polling a corpse for the whole budget, a failed docker rm is reported
    instead of swallowed, and every forensic/cleanup docker call is itself bounded so a
    wedged daemon cannot hang the run. secretstest's transit-engine mount moves inside the
    retried region, so a mount failure retries with a fresh container like any other dead
    start. The per-probe timeouts in pgtest and secretstest also rise from 2 to 5 seconds
    so a loaded dial+auth round trip is not cancelled mid-handshake.

  • environment/main.tf's peering header no longer sells the manual
    gcloud services vpc-peerings delete as the way out of a stuck teardown

    (deploy/gcp/environment/main.tf, #270). The comment
    said "retry, and see docs/deploy-gcp.md for the manual gcloud services vpc-peerings delete if a retry is not enough" — but the manual command is subject to the same
    four-day producer-side wait the guide documents since #271, so it framed an escape
    hatch that can itself be closed. The comment now states the four days, that no
    retrying inside one session gets past them,
    that what the failure strands is non-billable, and points at the guide's "Tearing it
    down" section — which exists and covers exactly this. docs/HISTORY.md's "remaining
    half" sentence flips to the past tense in the same change. Closes the last half of
    #270; the guide half landed with #271.


Truncated: the full section is in CHANGELOG.md.