Added
-
Two documented lists and one documented limit are now enforced by tests (#413) — a doc that
goes stale silently is the failure this work exists to stop, so three of them are pinned. The
changelog assembler refuses a fragment over the 1,500-byte capchangelog.d/README.mdstates,
measured in bytes rather than runes because the entries are full of multi-byte punctuation; the
test reads that number back out of the README, so the cap cannot move in one place only, and the
gate now runs the loader over the repository's ownchangelog.d/— an over-cap or malformed
fragment fails in the pull request that writes it instead of months later in a release PR. An
offline test fails if any doc enumerates a wire ID-prefix list that disagrees with
internal/domain/id.go, and another fails if aRUN_LIVE_*consent variable the tree reads has
no row in README's tier table. Both found real drift on their first run and this release carries
the corrections:outc_was missing from both prefix lists,skillver_from CLAUDE.md's and
vcrd_from ARCHITECTURE's, and the tier table omittedRUN_LIVE_KMS_TESTS. -
The MCP path is covered by an eval trial and a live third-party server (#45) —
mcp-answerjoins the eval suite as its sixteenth trial: a passphrase that exists only in what an MCP server's tool returns, so a graded answer exercises discovery, the default confirmation gate and the call itself. A new live tier,RUN_LIVE_MCP_TESTS=1, dials a server this repository did not write —MCP_LIVE_SERVER_URL, plus an optionalMCP_LIVE_SERVER_TOKENwhose absence is an anonymous dial rather than a rotted credential. Its opt-in contract is every live tier's (README.md's tier table): the environment variable is the consent, never.env, and once opted in a missing URL fails rather than skips. With this the MCP toolset is complete — an agent'smcp_toolsetcalls a real MCP server's tools end to end. -
A sandbox reaches the MCP servers its agent declares — plan 29 slice 6a (#45) —
limitednetworking'sallow_mcp_serversnow widens the per-session egress gate as well as the platform's own dial: the gate config carries thehost:portendpoints the session's resolved agent declares MCP servers at, and a process inside the sandbox reaches them alongsideallowed_hosts. They ride beside the policy rather than folded into it, so an operator's own list is still served as written, and they are port-scoped, because an agent author naming one endpoint should not thereby open the ports beside it. A declaration that would widen the gate past the server it names is not sent — a wildcard host (anmcp_serversurl passes no URL grammar, sohttps://*.example.com/would become the suffix rule a host set reads it as), a scheme the platform would not dial, a literal address the platform's own MCP client refuses, an impossible port, or anything the operator list's own host grammar rejects (asked ofegress.ValidateHostEntryrather than restated, which also excludes IPv6 literals the gate cannot match consistently across its two handlers). Names cannot be judged that way, so the gate dials an endpoint that only these declarations admit under the same address floor, on the resolved address. This closes the MCP half of the gate's fail-closed divergence;allow_package_managersis still not honored (#50). -
An expired MCP OAuth token refreshes at the dial — plan 29 slice 5b (#45) — An
mcp_oauthcredential whoseexpires_athas passed, or is within a minute of passing, performs the RFC 6749 refresh-token grant against its stored token endpoint before the MCP server is dialled, and the rotated tokens are sealed back onto the credential — under a compare-and-set, and best-effort, so the next dial in this session or another starts from them while a lost write costs no more than another exchange. The exchange's wire shape now lives in one place,internal/oauthrefresh, shared with themcp_oauth_validateprobe that already performed it. A credential that cannot refresh sends the token it has and lets the server answer, which is the reference's documented no-refresh-token behaviour. An issuer that refuses the grant ismcp_authentication_failed_error, and so is any other answer this platform cannot turn into a token — including one whose access token cannot be sent as a header, whose replacement refresh token is stored anyway so a later dial can still buy a usable one; one that is unreachable, 5xx-ing, or answering 408, 425 or 429 is neither, so the work item retries rather than answering a failure that says nothing about the credential. The token endpoint is dialled through the same address guard and no-redirect rule as every other credential-supplied URL, and an address that guard refuses is reported against the credential rather than retried forever. -
MCP servers authenticate with the session's vault credentials (#45) — A session whose attached vaults hold an
mcp_oauthorstatic_bearercredential for a declared MCP server now dials it with that token asAuthorization: Bearer, on discovery and on tool calls. Matching is by normalizedmcp_server_url, the first vault with a match wins, and a server nothing matches is dialled with only what its URL itself carries — userinfo still sends net/http's Basic header, which a matched token replaces. Credentials re-resolve per dial with no cache, so a rotation or an archive reaches the next dial without a restart. A refused credential now reads as one: a 401 or 403, and a matched credential the platform cannot open — never dialled anonymously instead — aremcp_authentication_failed_error, notmcp_connection_failed_error. A failed lookup is neither, and retries. -
Oversized MCP answers spill instead of vanishing (#45) — An MCP tool answer too large for the model's context was truncated and the rest lost. Its text is now written whole into the session's sandbox and the result names the file —
/tmp/tool_outputs/<call id>.txt, one per call, the convention built-in tools have used since #226. Images and blobs are named, not written. The trigger is whether rendering dropped or truncated a block, never the answer's size. Spilling never creates or heals a sandbox — it uses a new read-onlysandbox.Provider.Attach— so a session with no running sandbox, and everyself_hostedsession (a deliberate divergence), truncates as before. Resource labels such as a link's URI are now capped at 2 KiB. -
An agent's MCP tools now reach the model (#45) — An
mcp_toolsetexpanded to nothing at request assembly; it now expands to the tools its server reported, applying the entry'sdefault_configandconfigs[]. Each enabled tool is offered to the model asmcp__{server}__{tool}— the session wire still carries the bare name andmcp_server_name— and a call commits asagent.mcp_tool_use. MCP calls gate like any other ask tool:always_askis the toolset default, so the turn idles withrequires_actionuntil confirmed. A prefixed name past the Messages API's 64-byte limit, a name already taken, or a definition past the 256 KiB one request carries costs that tool a log line, not the turn. A session with undiscovered servers suspends its first turn to list them. -
The
mcp_execdriver now answers MCP tool calls (#45) — Its second job beside discovery: an outstandingagent.mcp_tool_useis dialled, called, and answered with anagent.mcp_tool_result. A tool that ran and failed is a result withis_errorfor the model to self-correct from, not a work-item fault; a call that never reached its server gets a result plus asession.errortypedmcp_connection_failed_error(retry_statusretrying), its endpoint cut toscheme://hostsince anmcp_serversentry may carry a credential. Content the wire cannot carry is mapped, not dropped: an embedded resource becomes a document, a resource link and audio become text. An answer is held to the 100 KiB every tool result gets, one call to two minutes, and a whole pass toEXECUTOR_MCP_PASS_TIMEOUT(default 5m), which now covers discovery and execution alike. Nothing emits an MCP call yet. -
An admin can issue, list, disable and retire management API keys from the console (#378) — three routes under
/api/console/organizations/{org}/workspaces/{workspace}/api_keys(POST,GET, andPOST …/{key_id}) end "one credential, changeable only by restarting the control plane": issue a named key with an optionalexpires_atand receive the secret once, asraw_key; list every key with a maskedpartial_key_hintand never a secret; disable one reversibly and re-enable it; archive one for good. Omittingexpires_atmeans never, andexpiredis derived from it at read time rather than settable. Nothing is hard-deleted, andarchivedis terminal here. On the SSO lane the whole surface — the listing included — is gated at admin; a managementx-api-keyalso reaches these routes, and can mint further management keys. A key issued over SSO records the issuing human'sprincipal_-prefixed id. The key seeded fromCONTROLPLANE_API_KEYis listed but not console-mutable: rotate it by restarting with a new value. There is deliberately no/v1twin; that absence and the omitted fields are registered in docs/DIVERGENCES.md. Operator rules: docs/self-hosted-security.md §10. -
An MCP tool call can stop and ask a human, and a refused one is answered in its own shape (#45) —
agent.mcp_tool_usejoins the confirmable set: an ask-gated MCP call blocks therequires_actiongate, appears insession.status_idle'sevent_ids, and is released by the sameuser.tool_confirmationkeyed bytool_use_ida built-in uses.always_askis the MCP toolset's default, so most MCP calls arrive gated. A denial is answered in the family of the call it refuses —agent.mcp_tool_resultkeyed bymcp_tool_use_id,is_errortrue, carrying thedeny_message. Client-executed custom tools stay ungated: the platform cannot stop what it does not run. A session parked on an MCP approval is excluded from idle-TTL sandbox reaping, and its wait counts inapproval.wait.duration. -
Console SSO and RBAC, proven end to end — plan 31 archived (plan 31, #56) — the chain was driven over HTTP against the shipped
deploy/composestack with--profile iam, on genuine Casdoor tokens minted by authorization-code + PKCE: a viewer is refused a mutation with 403 and reads it at 200, an admin issues an environment key over the console surface, a realantlong-polls with it, and forged or revoked keys are 401. The property that matters: with the identity lane fully configured, a managementx-api-keystill answers 200 — humans were added, machines were not disturbed. The run, and the Casdoor quirk that its OAuth parameters ride the query string while the credentials ride the JSON body, are in docs/HISTORY.md. Its api-key issuance slice was deferred to #378. -
A session's MCP servers are discovered, and their tools are on record (#45) — A new internal work kind,
mcp_exec: for each MCP server a session's agent declares, the executor connects, lists the tools, and writes the listing into the newmcp_catalogstable — or records why it could not. It runs in the platform's process for cloud and self-hosted sessions alike, never on a BYOC worker. A failed server is recorded and re-dialled once per work cycle — not per turn — while a ready listing is not re-fetched. Every dial is checked against the session's networking policy first —limitedadmits a server throughallow_mcp_servers, an unrecognized policy refuses — a failure reason keeps onlyscheme://host, since an endpoint may carry a credential, and one listing is capped at 256 KiB. Rows are per session and drop whenmcp_serversis patched. Nothing offers these tools to a model yet. -
The bundled identity provider — single sign-on with no enterprise IdP (plan 31, #56) —
docker compose --profile iam upnow starts a pinned Casdoor (casbin/casdoor:3.152.0) behind a TLS-terminating proxy, and Helm renders the same bundle fromcasdoor.enabled(defaultfalse). Both are opt-in and inert when off: withIDENTITY_MODEunset, auth staysx-api-keyonly. The chart requirescasdoor.adminPassword— it re-owns thebuilt-in/adminaccount Casdoor otherwise creates on a published default — plusconsole.clientSecret,console.redirectURIs,ingress.hostandidentity.roleMap, and refuses to render on a mismatched audience or issuer. No upstream providers are configured and SAML and CAS are blocked; docs/self-hosted-security.md §9 records the CVE posture (VU#780781). Configure it from the compose README or the chart README, which carry the traps: the role claim must begroups, since Casdoor'srolescarries objects mapping to nothing, andSSL_CERT_FILEreplaces Go's certificate list rather than adding to it. On GCP, Terraform wires Google's IAP instead — newiap_backend_serviceandiap_membersvariables and a derivedidentity_proxy_audienceoutput — but the OAuth consent screen is not Terraform-managed and stays an operator step in deploy/gcp/README.md. -
The role matrix — every route says what it needs (plan 31 slice 3, #56) —
slice 2 gated the control plane at a floor that denied every human; each
identity-reachable route now declares its minimum role instead. Reads are
viewer, the streaming ones included; resource CRUD and session lifecycle are
developer; vault-credential create, update, delete, archive and
mcp_oauth_validateareadmin, alongside the whole environment-key surface,
listing included. Vault-credential reads stayviewer— they return sealed
metadata, never a secret. Two edges are deliberate and look like mistakes until you know
why: the vault itself isdeveloper, so deleting or archiving one purges the credentials
inside it —adminbounds who may read, write or mint a secret, not who may destroy the
container holding it — and the session-resource routes staydeveloperalthough they take
agithub_repositoryauthorization_token. The work API and the gate-config route keep
RoleNone, which no role satisfies, so a human gets 403 there; worker
environment keys and the managementx-api-keyare unaffected. A denial names
the route's requirement, not the caller's role, and no path or method moved. -
The identity lane — humans reach the control plane, default-denied (plan 31, #56) — with
IDENTITY_MODEset, a human credential (a Bearer JWT inoidcmode, the configured signed assertion header intrusted_proxy) is verified into a principal, provisioned on sign-in into a newprincipalstable keyed on(issuer, subject); roles come from the provider per request and are never stored, asubover 255 characters is refused rather than truncated, andsessions.created_byrecords the principal id. A managementx-api-keystill wins outright. On the dual-auth routes a Bearer with a JWT silhouette takes the human lane and anything else stays a worker environment key — platform-minted keys carry no dots, but a self-chosen pre-0021 key may, and 401s there. Every route declares a minimum role, all at the floor as this lands — denying every human — except the console environment-key routes (admin); the per-route split arrives with the role matrix, later in this same release. A token mapping to no role is 403, not 401. Retention — and why a returning person is provisioned afresh — is docs/self-hosted-security.md §8. Unset, the default, nothing changes save one exception every deployment gets: a repeatedx-api-keyheader is now a 401 rather than resolved by header order. -
internal/identity— the human-authentication boundary (#56). A strict OIDC
relying party for any compliant provider, plus a trusted-proxy mode (gcp-iappreset
orcustom) for deployments where the cloud terminates authentication.Verify
authenticates one compact JWT — the signature allowlist is
RS256/RS512/ES256/ES384/ES512, soalg:noneand HS256 never reach a key lookup —
returning a principal whose role (viewer<developer<admin) comes from a
configurable claim. How that claim name is read is fixed at configuration time so a token
cannot choose it: a URI-shaped name (https://corp.example/roles, the Auth0 convention) is
one flat key, dots included, while any other dotted name (resource_access.console.roles,
the Keycloak shape) is a path. Signing keys are cached for five minutes, so a revoked key
stops verifying within that bound.IDENTITY_MODEisdisabledby default;oidcand
trusted_proxyread theIDENTITY_OIDC_*/IDENTITY_PROXY_*/IDENTITY_CLAIM_*
variables and a requiredIDENTITY_ROLE_MAP, and any misconfiguration fails startup
rather than open. No route consumes it yet; the/v1wire, theantCLI and machine
credentials are untouched. -
Plan 31 drafted — console SSO and RBAC (docs/plan/31_console-sso-rbac.md, #56) — the design for the SSO and RBAC half of #56: today one
x-api-keyis the whole management authority, and the plan has humans authenticate through any standards-compliant OIDC provider, or a trusted identity-aware proxy (gcp-iappreset), with the control plane resolving each request to a principal holding one claim-mapped role —admin,developerorviewer— enforced per route. A hardened Casdoor ships as the self-host default, replaceable by config with Keycloak or any cloud IdP. Machine credentials and every documented CLI and SDK flow keep working unchanged; the one observable/v1change — management paths also accepting a Bearer JWT — is registered in docs/DIVERGENCES.md. Multi-tenant activation stays in #56, sized but not built. -
An operator surface for environment keys — issue, list and revoke over HTTP (#43) — a self-hosted operator no longer seeds a BYOC worker's credential into Postgres:
POST,GETandPOST …/{token_id}/revokeunder/api/oauth/organizations/{org}/environments/{environment_id}/tokensissue, list and retire keys. Issuance returns the plaintext exactly once, as an RFC 6749 token response (access_tokenplusexpires_in), and is refused on acloudor archived environment; the listing never shows a secret; revoke is idempotent, 204-bodiless, and reaches only the owning environment's keys. All three take the managementx-api-key; an environment key is rejected./v1gains no route — the namespace is off the wire, mirroring the reference console's own, with four deliberate departures registered in docs/DIVERGENCES.md. Curl recipes: docs/self-hosted-security.md §6. -
Plan 30 drafted — console-issued environment keys (docs/plan/30_environment-keys-console-issuance.md, #43) — the design for the operator surface that issues BYOC worker credentials, which the platform has never had: a self-hosted operator has had to seed a key into Postgres by hand. The plan settles the model on the reference console's, observed live on 2026-08-10 down to its request and response bodies, and keeps issuance off the
/v1wire on a namespace mirroring that console's own private API, giving future console-facing endpoints a convention to follow. Its four deliberate departures from that dialect are reasoned in the plan. Three slices follow: storage and primitives, the endpoints, then docs and a realant beta:workeracceptance run. -
An MCP client (#45) —
internal/mcpconnects to a customer-named MCP server over Streamable HTTP, lists its tools and calls one, keeping the officialmodelcontextprotocol/go-sdkinside that package: tools arrive in Anthropic tool-definition shape (input_schema), and an entry with an unusable name, a non-object schema, or a duplicate name is skipped rather than failing the whole listing. Connections are per-work-item and bounded — 30s to dial, 2 minutes per listing or call, 100 pages, 8 MiB of response bytes cumulative per connection and 64 KiB per header block. The client refuses redirects, sends its bearer token only to the endpoint's own origin, guards every dial throughinternal/dialguard, and propagates W3C trace context to the server. It ignoresHTTP_PROXY/HTTPS_PROXYdeliberately — a proxy would move the dial off the target and leave the guard vetting the proxy — so egress that must go through one needs its own client. Divergences from the reference are recorded in docs/DIVERGENCES.md. -
Continuous delivery to the GCP staging environment (#347; the ref assertion below was
corrected in #351). A new
.github/workflows/deploy.ymlbuilds, pushes, installs and smoke-tests on every push to
mainand onworkflow_dispatch, using the newdeploy/gcp/staging-values.yaml. The
deployment is mode 2 (Cloud SQL, Cloud Storage, Cloud KMS) behind the pre-created Secret
map-platform, so the values file carries no credential: the job authenticates by Workload
Identity Federation and assembles that Secret from Secret Manager, and there is no GitHub
secret. A dispatch from any ref butrefs/heads/mainis refused before the auth step, and
again cloud-side by the provider'sassertion.ref;id-token: writeis scoped to the one
job rather than the workflow. CD never runs Terraform —make gcp-bootstrapand
make gcp-db-initstay human-driven, which is also why rotation differs by secret:
deploy/gcp/README.md carries the per-secret procedure. The smoke
check demands 200 with the management key and 401 without — over plain HTTP on a bare IP, so
the key travels unencrypted, which is deliberate for staging only and costed in
docs/deploy-gcp.md under "Exposing the control plane".model-providers
holds a placeholder key a human must replace.
Changed
-
Plan 34 archived: the documentation is smaller than the system it documents (#413) —
Tracked markdown went 2,665,735 → 2,166,819 bytes (−19%), against 2,382,801 bytes of
non-test Go. The final slice cut nothing.docs/HISTORY.mdanddocs/history/hold what a
changelog structurally cannot — measurements, attacks that were tried, alternatives
rejected — so their 414 KB → 120 KB target is retired rather than met by deleting evidence,
the fourth of nine targets to fail on measurement.HISTORY.md's provenance paragraph now
carries what the next trimmer needs: that what sits under a cited heading is generally
recorded nowhere else, and that the heading is not the unit of citation, since much of what
points here quotes a sentence or a number instead of naming a section. Of the nine targets,
four were retired, three missed after real cuts, and two met or beaten. Why each was
retired, and the root cause they share, is indocs/plan/34_doc-trim.mdand the plan's
record indocs/HISTORY.md. -
docs/DIVERGENCES.mdbecomes the registry its own header promises (#413) — Twenty-four
entries had grown into design essays restating derivations their code comments already carry;
each is now a row that states the surface, what this platform does, whether that is CONFIRMED
or INFERRED, and where the reasoning lives. 313 KB → 228 KB with all 142 entries, both section
headers and every cross-document citation intact. Each row was drafted and then attacked by an
independent reviewer hunting for claims surviving neither in the row nor anywhere in the repo:
twenty-five did, and were restored — among them a publishedpause_turnsentence that is the
turn-classification entry's own strongest counter-evidence, and the strip-not-reject argument
the 0.2.0 changelog points back here for. Nine accuracy defects were fixed rather than carried
forward, most inherited from the original entries: an error message named as the only one
lacking a field path where the code has three, a field the SDK's error union requires on every
variant presented as ours, a bounded path-id enumeration widened to "every", two Anthropic
bounds attributed to the MCP protocol, and a cumulative byte ceiling stated as a bound on
delivered bytes when the code counts only part of what crosses the wire. Four citations of
an "SDK v1.62.0 checkout" are settled against the pinned v1.61.0 they were read at — one in
archived plan 29, edited for that correction alone. -
The steering docs and two deployment READMEs stop restating what they point at (#413) —
CLAUDE.md gains the standing rule this plan was written from: prefer a comment
beside the code, and let a document earn its place with what spans files. It then follows its
own rule, dropping the coverage-denominator list, the test-support inventory and the reviewer
pins in favour of the Makefile recipe, the naming convention and therun-reviewsskill that
own them. README.md stops narrating delivery the changelog already holds — its
status line says what runs today, and its roadmap lists only what is deferred, which its own
"progress is tracked in" pointer had always promised. The chart's "Notable values" table and
the compose stack's variable table were third homes behindvalues.yaml,docker-compose.yml,
.env.exampleand the binaries' own package docs; both become the shape of the decisions
instead. Three gaps closed rather than trimmed: how a role-claim name is read (a URI-shaped
name is one flat key, any other dotted name a path — fixed at configuration time so no token
can choose the reading); that a session's MCP servers are dialled from the executor
process, so a firewall around the sandbox does not bound them; and thatrunAsUseralone
does not turn off with0, which is a valid uid meaning root. -
ARCHITECTURE.md's package reference is a map now, not a second copy of the code (#413) — the
per-file tables were 298,654 of the file's 325,163 bytes, and they were the derivative: nearly
every package carries more comment than the document spent on it, most by three to ten times
(internal/api192 KB against 50 KB,internal/executor169 KB against 43 KB).
Those comments sit beside the code and cannot drift from it; the tables did, and one still
spelled the gate tokengatetok_where the constant has always beengtk_. What replaces them
is each package's place in the flow — the one thing a package doc cannot hold, because it is
about the neighbours — and a pointer to read the package. Nothing was deleted unchecked: of about
a hundred issue references only four were absent from the source, three of those pure provenance
and the fourth an open issue; every identifier was checked; and of the citations a 2026-07 note
warned might quote prose relocated into these rows, exactly one did —DIVERGENCES.mdrestates
that fact in full itself, and the note's now-stale pointer is corrected here. The
cross-package sections — overview, topology, execution flow, the wire model, security
invariants, observability, testing — are kept byte for byte. Recover the original with
git show 98c9ac9:docs/ARCHITECTURE.md. -
Changelog fragments have a size —
changelog.d/README.mdnow sets one: 60–120 words,
hard cap 1,500 bytes. A fragment carries what a release-notes reader needs; the longer forms
have homes already — thedocs/plan/file for a decision, docs/HISTORY.md
for an acceptance record, docs/DIVERGENCES.md for a wire claim, the
comment beside the code for a mechanism, and the PR itself for a bug's forensics. Every
unreleased fragment over the cap was recut to it, which is the last moment they can be: at the
nextmake changelogthey fold into CHANGELOG.md and are frozen. First slice of
plan 34 (#413). -
A slow MCP server no longer hides the ones behind it, and unreachable servers reach the session log (#45) — Discovery dialled a session's declared MCP servers serially, so one that never answered spent the whole pass budget and the servers behind it stayed unreached for the life of the session, their tools never offered to the model. Dials now run concurrently, at most eight in flight, each with its own 8 MiB response budget and rows still in declaration order, so a tarpit costs one dial-and-list rather than every server's, still bounded by
EXECUTOR_MCP_PASS_TIMEOUT(default 5m). A discovery failure now reaches the session log too, naming the server:mcp_connection_failed_error, ormcp_authentication_failed_errorwhen the credential was the problem, once per work cycle rather than per turn. A server the pass merely ran out of time on says nothing, and does not silence its later verdict. -
Plan 32 archived — the management-key lifecycle accepted against real Casdoor tokens and the real
antCLI (#378) — over real HTTP against the shippeddeploy/composestack with--profile iam: an admin issued a key on the console API, drove a read and a mutation on/v1with it, disabled it and was refused 401, re-enabled it and was served 200, then archived it for good. A viewer was 403 throughout, and no listing ever carried a key value. A key minted 45 seconds out lapsed on the clock, listed asexpired, refused re-activation, and still archived. A realant1.21.0 followed the same key through every state. One caveat the run measured:requireRolegates the human lane, so a managementx-api-keystill reaches these admin routes and can mint further management keys, recording the issuing key's row id rather than a person. Operator rules: docs/self-hosted-security.md §10. Transcript: docs/HISTORY.md. -
Management API keys gain a lifecycle: status, expiry and a masked hint (#378) — migration 0024 replaces the binary
revoked_atwith astatusofactive·inactive·archived, so a key can be disabled reversibly and "delete" is an archive that leaves the row readable; existing revoked rows become archived and the column is dropped. A nullableexpires_atarrives with absent meaning never, and existing rows are grandfathered rather than backfilled, so the migration cannot retro-expire a live credential. A fourth state,expired, is computed at read time against the database's clock and never stored, so a key stops working the instant it lapses.created_byrecords the issuer with no foreign key, so removing a principal from your IdP leaves the keys they issued working. One operator rule lands ahead of the console surface: naming a value inCONTROLPLANE_API_KEYmakes that key env-var-managed whatever it was before, adopting an already-issued row and clearing its issuer and expiry, logged as a warning at boot. No route changes here, and the samex-api-keykeeps working. Operator rules: docs/self-hosted-security.md §10. -
The
repo-answereval runs again (#358) — the repository-mounting trial
is back in the live suite now that its fixture exists. It needs
EVAL_GITHUB_REPO_URLandEVAL_GITHUB_REPO_TOKEN— renamed from
GITHUB_EVAL_REPO_URL/GITHUB_EVAL_REPO_TOKEN, since GitHub refuses to
store a secret whose name begins withGITHUB_— naming a private
repository holding a root-levelPASSPHRASE.txt, and a fine-grained token
scoped to that one repository at Contents: Read-only. That privacy is
load-bearing: it is the only answer-style trial with no per-trial nonce. The
token and the passphrase now join the artifact scrub, a clone the executor
retries past a network fault no longer reds the trial, and offline tests pin
the registered trial set and the count the docs spell, so the two cannot drift
apart again. -
Plan 30 archived — console-issued environment keys accepted against the real
antCLI (#43, docs/plan/30_environment-keys-console-issuance.md) — a key issued over the console API drove a realant beta:worker poll, with no manual database edits at any point. Revoking through the console returned a bodiless 204 and the running worker exited on the platform'sauthentication_error; with two hosts keyed separately, revoking one left the other polling on its still-valid key — the per-host revocation the retired rotate-on-mint model could not offer. A follow-up run closed #363, the one criterion the plan had archived without: a real worker claimed a queuedtool_exec, ranbashin-process and settled the result back on the same console-issued key. Both transcripts are in docs/HISTORY.md. -
Environment keys become per-host: named, individually revocable, expiring (#43) — a BYOC worker credential is no longer the credential: rotate-on-mint, which made each new key an environment's only live one, gives way to the reference console's model. The platform now generates the secret (256 CSPRNG bits behind an
sk-map-env01-prefix), returns it once, and stores only its SHA-256 hash alongside a name and a one-year expiry; an environment holds a key per host, and revoking one leaves the others polling. Keys minted before migration 0021 carry no name and no expiry, and keep working until revoked — backfilling one would have retro-expired credentials a running worker is authenticating with. No wire shape changed. Lifecycle, expiry and rotation: docs/self-hosted-security.md §6. -
The
repo-answereval was parked, then restored within this release (issue #358,
PR #359). The repository-mounting trial was registered unconditionally, butmake eval
setsRUN_EVALS=1and the trial's fixture configuration fails rather than skips once
opted in — so with no fixture repository anywhere, the nightly eval job aborted before
its first session, reddening every run from 2026-08-08 while its other trials passed.
Taking it out of the registered set returned the nightly to green and the task count to
the fourteen the docs spell;evals/repo_test.gokept the trial and its graders intact.
It rejoined the suite later in this same release, once the fixture existed — see the
restore entry; the full account is in docs/HISTORY.md. -
Deployment identifiers leave the public repository (#355, PR #356; console
#69). The
deploy workflow,staging-values.yamland the GCP runbook no longer name one
operator's project, cluster, registry, buckets, KMS key or identities. Eleven
GitHub Actions variables carry them instead (not secrets — none is a
credential):GCP_PROJECT_ID,GCP_ZONE,GKE_CLUSTER,ARTIFACT_REGISTRY
(oneHOST/PROJECT/REPOSITORYstring),WIF_PROVIDER,
DEPLOY_SERVICE_ACCOUNT,BLOB_BUCKET,KMS_KEY_NAME,
CONTROLPLANE_SERVICE_ACCOUNT,BRAIN_SERVICE_ACCOUNT,
EXECUTOR_SERVICE_ACCOUNT— sourced perdeploy/gcp/README.md. A deploy fails
before authenticating if any is unset, blank or comma-bearing.
staging-values.yamlbecomes a reference deployment written against
registry.invalidandyour-project; the workflow overrides those keys and
then reads the three Workload Identity annotations back from the cluster, so a
lost override fails the run instead of silently degrading the brain. -
Resolved toolset echo, and
mcp_toolsetvalidated like its built-in twin (#45) — Every agent read, a session's resolved-agent snapshot, and theagentinsession.updatednow render both toolset kinds withconfigsanddefault_configpresent and each omittedenabled/permission_policyfilled in:mcp_toolsetresolves to{"enabled":true,"permission_policy":{"type":"always_ask"}}, the built-in kind toalways_allow— both now documented rather than inferred (#59). Anmcp_toolsetnaming a server absent frommcp_servers, ormcp_serverscleared while a toolset still references it, is a 400 on agent create and update and on session create and update, correcting the one-way reading recorded under #66. Unknown keys, apermission_policy.typeoutside the enum, or a wrong-typed leaf are 400s naming the field path, closing the fail-open (#26) where a misspelled key silently resolved to the toolset default. -
docs/HISTORY.md splits by period (plan 28 slice 2, archiving the
plan). The 31 sections whose events belong to 2026-07 — 1,327 lines —
move todocs/history/2026-07.md, relative links re-based for the new
directory the waychangelog-archivedoes it — the split itself was
byte-reversible, a recomposition from the two output files reproducing
the pre-split file exactly. That is a property of the move, not a
standing invariant: the archive is an ordinary document afterwards, and
plan 34 has since corrected passages inside it.
HISTORY.md keeps its intro, the Delivery-slices table,
and the current month over a pointer paragraph naming the archives; 96
references to moved sections re-point (69 citations in
docs/DIVERGENCES.md, the rest across archived plan files,
docs/ARCHITECTURE.md, README.md and one Go comment), while references
inside archives themselves — the byte-frozendocs/changelog/files and
adocs/history/file recording its own era — resolve through
HISTORY.md's pointer instead. -
CHANGELOG.md slims to an index; released sections archive per release
(plan 28 slice 1). The newarchivesubcommand oftools/changelog
(make changelog-archive VERSION=X.Y.Z) moves a released section to
docs/changelog/X.Y.Z.md— re-basing its relative links for the new
directory, byte-reversibly — and leaves an index stub — the exact dated
heading over one line naming the section's Keep-a-Changelog groups and
linking the archive file — solatest, the release tag-sanity check, and
existingCHANGELOG.md § [X.Y.Z]citations keep resolving unchanged. The
move is guarded: the archive must be exactly one section, the document
must round-trip byte-for-byte, the written archive must invert to the
moved section, an existing archive file is never clobbered (an interrupted
run's byte-identical leftover converges the retry), andnotesrefuses to
ship a stub as a release body (re-runs of a release workflow read the
tag's checkout, where the section is still inline). Applied to 0.2.0 and
0.1.0: CHANGELOG.md drops from 6,448 lines to a 35-line index at the cut,
and the archiving step joins docs/RELEASING.md's ritual as its
post-release step.
Fixed
-
A falsified provenance claim, in all three places it was asserted (#413) — The archive
header ofdocs/history/2026-07.md, the unreleasedhistory-splitchangelog fragment, and
docs/HISTORY.md's own plan-28 record each promised that the archive andHISTORY.mdstill
recompose to the pre-split file byte-for-byte. Slice 3 of this plan had edited that archive,
so none of the three was true; all now scope byte-reversibility to the move rather than
asserting it as a standing invariant. The third copy was found only by review, after the
first two were fixed — grep for the claim, not for the copies you remember. Separately, a
quoted sentence about the docker wrapper's marker file had drifted:
internal/sandbox/docker/api_test.gocited the archive while quotingHISTORY.md's
version, which had merged two different archive sentences inside one set of quotation
marks. Both now quote one archive clause exactly and attribute the/tmpdetail to the
separate sentence it comes from. Two archived decisions that plan 29 has since reversed —
confirmation gating scoped toagent.tool_use, andSandboxProviderhaving noAttach—
carry dated notes rather than edits: an overturned decision is what an archive is for. -
Twelve comments described shipped code as unbuilt, or claimed more than it does (#413) —
internal/workersaid its lease loop "is a later increment" althoughcmd/workerhas driven it
for months, andinternal/queuesaid "two kinds share the work_items table" where five do. Eight
more still pointed at "a later slice" that has since landed: the gate that builds the egress
substitution engine, the resolution that fills it,Spec.Env's vault placeholders, three sites in
internal/identitywaiting on a principals tableupsertPrincipalwrites today, a fourth waiting
on a test fake clock that now exists, and the fake OpenID provider's own package doc. Two others overstated an invariant: the API's credential map
called every remaining/v1route management-only, though the work API takes the environment key
and nothing else, andSpec.Imageclaimed every mismatch is refused withErrSpecMismatch,
though a wedged gated pod is reclaimed first, deliberately. Each is rewritten from the code, and
the queue's dividing line is now stated correctly:Claimis the in-process lane,Pollserves a
BYOC worker exactly one thing — atool_execitem on aself_hostedenvironment, never a
model_turnrow. -
Two documentation defects with teeth —
changelog.d/cd-build-on-runner.fixed.mddid not
begin with-, which the fragment loader rejects outright, so the nextmake changelogwould
have failed at assembly; it is a well-formed entry now. Anddocs/deploy-gcp.md's sandbox-pool
sizing still said nothing callsSandbox.Destroy, so operators were told to size for
cumulative rather than concurrent sessions — a reap loop has run in every executor since plan
24, and the guidance now names what frees a pod and what weakens that: with no blob backend
the idle tier is off, so an idle session holds its pod until it terminates, and a
self_hostedsandbox belongs to the customer's worker and is never reaped. (#413) -
A wedged work item is ended and handed back, not held forever (#383). A sandbox
call that never returned (the Kubernetes exec stalls in CI, #318) kept its lease
renewing, so the item stayedactive, unreclaimable by any other executor, and crash
recovery never fired. The executor and the BYOC worker now bound an item's silence,
not its runtime: a holder that reports no finished step forEXECUTOR_STALL_TIMEOUT/
WORKER_STALL_TIMEOUT(default 30m) has its work cancelled and its lease left to
lapse, while results it already answered are committed best-effort, so a tool usually does
not re-run — but a settlement slower than the lease's remainder, or a call that ignores
cancellation, still commits nothing, and an outputs harvest always discards its staged bytes
rather than commit half a snapshot. Both refuse
a budget below the longest single step plus a minute (11m; more when
EXECUTOR_REPO_CLONE_TIMEOUTis raised). Recovery still needs a second replica (#396);
bounding the call itself is #395. -
A slow lease renewal no longer abandons work whose lease is still live (#392). Each renewal
is bounded so it cannot outlive the lease it races, but the bound only held for a punctual tick:
a renewal that overran its interval left the next attempt's deadline as much as a third of a
lease inside the lease just bought. A slow database then failed the renewal with a
deadline-exceeded error, cancelling a brain turn or an executor's tool run nothing else could
yet claim. Each attempt is now budgeted from the last successful renewal's return; a tick
arriving after that budget is spent reports the lease lost without dialing the database. A
renewal timeout still does not prove the row free (#400). -
A tool call the docker sandbox timed out is no longer reported as an ordinary kill (#390).
The deadline was always enforced; the label was not. Classification rested on two probes a
loaded host can schedule after the watchdog's kill has landed: both read false beside exit code
137, and the model was told its command had died rather than run too long. The watchdog now
marks its own kill, read as a third witness; the k8s backend lost the same verdict to the same
race (#95, #110) and shares the fix. The mark can only raise a timeout verdict — a hostile
command can suppress its own mark and get the old mislabel back — and nothing gates the kill on
it, so none outlives its deadline. -
The management-key lifecycle now matches the measured reference (#389). Probed against the
live console, three of five recorded inferences went against us and two more divergences
nobody had suspected surfaced. A repeated archive is refused with 400 instead
of succeeding as a no-op: nothing may be patched onto an archived row, an empty body included.
A key disabled and then left to lapse rendersexpired, notinactive; the precedence is
archived>expired> the stored status. A lapsed key admits archiving alone — disabling and
renaming it are now refused. A pastexpires_atis accepted at issue, minting a key born
expired, previously rejected. An empty patch on a live key answers 200 with the unchanged
resource. Operator rules: docs/self-hosted-security.md §10; the differing refusal wording is
registered in docs/DIVERGENCES.md. -
A BYOC worker mounts its session's files even when the download declares no
length (#386). The worker passed the download'sContent-Lengthto the sandbox
write seam unchecked; Go reports-1when an intermediary chunks or decompresses the
body, so the seam refused it and every such mount was skipped, leaving the session an
empty workspace the system prompt still described as mounted. A length-less body is
now spooled to a temp file to measure it, bounded at the Files API's 500 MB per-file
cap and refused rather than truncated above it; the spool is unlinked immediately, so
a killed worker leaves no customer bytes on disk. Streaming writes now reject a
non-length size before the first byte moves — a sandbox-contract rule every backend
must pass. One limit remains: a body delimited only by the connection closing cannot be
told from a complete one by any client, so behind such a hop a mount can still land short. -
A zero-byte write no longer opens a Kubernetes exec stream that can never close
(#318). A write of no bytes opened an exec stdin stream the client closed at once; the
pod's side then never completed, stalling the run untilgo test's package alarm killed
the whole binary. Such a write now asks for no stream. Because that stream also counted
the bytes, the write reads one byte itself first, so a stream that disagrees with its
declared size is still refused instead of landing an empty file over the target. The
backend's watchdog assertion now measures a paired difference rather than absolute
latency, so a loaded host no longer fails it. Bounding a wedged exec in production is
#383. -
A confirmation sent together with the last outstanding tool result no longer strands the session (#45) — Resolving a
requires_actiongate and posting an outstanding tool's result in onePOST /eventsleft the resume counting only the calls the batch denied, never the ones its own results answered. With a client-executed call remaining, the session committedrunningwith everything answered and nothing queued, refusing archive and delete until auser.interrupt. With a platform-executed one, it enqueued atool_exec(orweb_exec) for a call already answered, starting a sandbox for nothing, and on aself_hostedenvironment that empty work item was briefly pollable by a BYOC worker. The resume now counts the batch's own results alongside its denials. -
Environment-key tests now pin the invariants their comments claim (#362) — four test-only defects from plan 30 slice 1, on security-adjacent invariants: nothing was ever exposed, and no production code changed. Three were assertions that did not test what their comments said — an index pin the dropped index also satisfied, a rejection check any error passed, ordering assertions decided by clock resolution — and the fourth a doc comment describing the retired rotate-on-mint contract. The underlying invariants were all still covered elsewhere except the key listing's
id DESCtiebreak, which nothing covered and a new test now pins; the ordering fix also ends a failure that struck at random when two keys were issued inside one microsecond. -
A killed test run no longer strands its Docker fixture forever (#346). The four
Dockerized fixtures (pgtest,blobtest,secretstest,gcstest) remove their
per-test-binary container in adefera killed process never reaches, so a Ctrl-C or
ago testtimeout stranded the container and its anonymous volume for good: one
machine accumulated twenty strays holding 12.6 GB. Every fixture container now carries
the labeldev.opensdlc.managed-agent-platform.test-fixturenaming its harness, and
each fixture'sTestMainforce-removes labelled containers older than six hours, with
-vso the volume goes too. Nothing external has to cooperate: a bare
go test ./internal/storesweeps. The six-hour floor spares a sibling suite running
concurrently, so a stray outlives the run that killed it and is reclaimed later. -
The delivery pipeline builds its images on the runner (#349). The deploy
workflow now runsdocker buildanddocker pushitself instead of
submitting to Cloud Build, which failed at the build step with the caller
forbidden from the project's_cloudbuildstaging bucket and stayed red with
both documented IAM remedies applied.gcloud builds submitstages the source
as the caller rather than as the build's--service-account, so every
manual run that worked was made by a human with Owner and none exercised the
path CI takes. Building on the runner needs one permission the deploy identity
already holds,roles/artifactregistry.writer, and no bucket, staging upload or
Cloud Build API.deploy/gcp/cloudbuild.yamlis unchanged and remains the
manual path's build definition. -
The GCP Cloud Build path builds again.
deploy/gcp/cloudbuild.yamlhad
described a build nobody had run since the Dockerfile gained
FROM --platform=$BUILDPLATFORM golang:1.26-bookworm AS build:
BUILDPLATFORM,TARGETOSandTARGETARCHare BuildKit-only variables, so
under Cloud Build's classic builder they expand to the empty string and the
daemon rejects the platform specifier outright —
failed to parse platform : "" is an invalid component of "". Both docker
steps now carryenv: ["DOCKER_BUILDKIT=1"], which is what makes the build run
at all rather than a performance choice. Alongside it,
options.logging: CLOUD_LOGGING_ONLY, which becomes mandatory as soon as a
build names its own service account — and it must, because a project created
under an organisation no longer receives the automatic Editor grant on the
Compute Engine default service account, and that identity therefore cannot read
the source tarballgcloud builds submithas just uploaded
(does not have storage.objects.get access to … <project>_cloudbuild). The two
out-of-band IAM grants that follow from it —roles/logging.logWriteron the
project androles/storage.adminon the_cloudbuildbucket, both on the
deploy identity, which uploads that tarball as well as reading it back — are
recorded in
deploy/gcp/README.md, since
nothing in the repository would otherwise say they exist. -
The stray root build outputs are untracked and ignored (#338). PR #333
committed an 80 MB Mach-O arm64worker— a dev-time
go build -o worker ./cmd/workerswept in bygit add -A— and it shipped
inside the v0.2.0 tag..gitignorealready carried/gate, so the hazard was
known and only the list was short, which is exactly how it recurred: every
cmd/name (brain,controlplane,executor,gate,worker) and
tools/changelogis now listed, root-anchored so thecmd/<name>/
directories stay tracked..dockerignoregains the same set, which git
ignoring does not cover — a developer who builds locally still has the binary
on disk, and the Dockerfile'sCOPY . .would ship ~80 MB of host-arch
executable into the image and its layer cache, the same context-size concern
that file's.claude/worktreesentry already documents. History rewriting
stayed out of scope, so the blob remains in pack history: this shrinks fresh
working trees, not clone size.
Security
-
The dial guard reads every NAT64 layout and refuses three more IPv6 transition forms (#45) —
internal/dialguard, under the vault-credential probe and the new MCP client, decoded only NAT64's/96layout:64:ff9b:1:a9fe:a9:fe00:808:808read as8.8.8.8rather than the cloud metadata address169.254.169.254it targets, while conformant/48and/56mappings of legitimate targets were refused outright. All six RFC 6052 prefix lengths are tried now; IPv4-compatible, IPv4-translated and ISATAP addresses embedding a blocked target are refused; an address the guard cannot parse is refused rather than admitted; and the guard checks the address as well as its decoded target. Know the edges: only64:ff9b::/32is decoded, so NAT64 on an operator's own Network-Specific Prefix is not covered, and 6rd and RFC 2529 6over4 are left undecoded by decision. RFC 1918 stays allowed, and addresses under the RFC 8215 local-use prefix may be over-refused. -
hashicorp/setup-terraformmoved to its current major, 3.1.2 → 4.0.1
(Dependabot #344). The action had been pinned at 3.1.2 since the GCP staging
Terraform brought it in (#250), and this is the first bump Dependabot has
proposed for it; the new pin's SHA resolves to the tag its trailing comment
claims. v4.0.0's only breaking change is the runtime — the action now runs on
Node 24, the same runner floor the earlier batch of action majors
(#187–#190) established forcheckout,setup-go,upload-artifactand
setup-helm— and every job in this repository isubuntu-latest, so only a
self-hosted runner would need attention. v4.0.1 on top clears theDEP0169
url.parse()deprecation warning that the Node 24 move surfaced. Nothing in the
workflow changed beyond the pin: the action's sole consumer is the
terraformjob, and all seven of its checks are green on the new pin,
including the two that actually invoke the installed binary —gcp-fmtand
gcp-validate, the latter re-initialising both configurations with
-backend=falsebefore validating them.